Dev

Python의 asyncio를 직접 만들어보자 (2)

October 17, 2018

Python의 asyncio를 직접 만들어보자 (2)

그럼 우리의 대안인 제너레이터가 언어 레벨에서 과연 무엇인지 알아보자.

이터러블, 이터레이터

제너레이터를 이해하려면 먼저 이터러블/이터레이터의 개념부터 이해할 필요가 있다.

  • 이터러블: __iter__() 또는 __getitem__() 메소드가 구현되어 있고 (빌트인 함수 iter()로 호출) 해당 메소드에서 이터레이터를 리턴하는 객체
  • 이터레이터: __next__() 메소드가 구현되어 있는 객체 (빌트인 함수 next()로 호출)

예를 들어 다음과 같은 클래스들이 있다면,

class Iterable:
    def __iter__(self):
        return Iterator()
    
class Iterator:
    def __init__(self):
        self.value = 1
    
    def __next__(self):
        old_value = self.value
        if old_value > 10:
            raise StopIteration
    
        self.value += 1
        return old_value

다음과 같이 사용이 가능하다.

able = Iterable()
ator = iter(able)  # ator is <iterator object at 0x123123>
value = next(ator)  # value == 1

그리고 Python의 for문에서는 위의 절차를 그대로 밟는다.

for value in Iterable():
    # value == 1, 2, 3, 4 ... 10
    # after StopIteration, escape for loop

사실 우리가 사용하는 대부분의 시퀀싱이 가능한 타입(리스트, 셋, 딕셔너리 등)은 전부 이터러블 객체라고 할 수 있다. 주의할 점은 어떤 타입이 반드시 이터러블이면서 동시에 이터레이터일 필요는 없다는 점이다.

제너레이터

제너레이터는 단순히 말하면 yield 키워드가 포함된 함수다.

def func():
    return 1 
    
def gene():
    yield 1

각각을 실행해보면,

f = func()  # a == 1
g = gene()  # g is <generator object at 0x123123>>

즉 제너레이터를 실행하면 값이 아니라 제너레이터 객체를 반환한다. 뭔가 iter() 빌트인 함수의 실행 결과와 비슷하다. 반환된 제너레이터 객체에 next() 빌트인 함수를 적용해보면,

value = next(g)  # value == 1
value = next(g)  # raise StopIteration

이터러블/이터레이터 인터페이스와 상당히 유사하게 작동한다는 것을 알 수 있다. 위의 이터레이터와 같은 기능을 하는 객체를 제너레이터로 구현하면 다음과 같다.

def gene():
    for i in range(10):
        yield i + 1
        
for value in gene():
    # value == 1, 2, 3, 4 ... 10
    # after StopIteration, escape for loop

yield문이 뭐길래 이런 동작을 하는 것일까?

제네레이터에 next() 함수를 적용하면,

  • yield를 만날 때 까지 실행한 뒤 yield 뒤의 값을 반환하고 해당 라인에서 실행을 멈춘다.
  • 다시 next()를 적용하면 아까 멈췄던 라인에서 시작해, 또 다음 yield문을 만날 때 까지 실행한 뒤 yield 뒤의 값을 반환하고 해당 라인에서 실행을 멈춘다.
  • return 혹은 StopIteration 예외를 만나면 동작을 멈추고 아예 빠져나온다.

예제에서 보듯이, 메인 함수와 gene 영역 두 개의 루틴이 서로 상호작용하고 있다. 그래서 제너레이터를 코루틴이라 부를 수 있는 것이다.

yield를 통해 제너레이터는 자연스럽게 다중 진입점을 가지게 된다. 일반 함수가 항상 함수의 첫 라인이라는 하나의 진입점만 갖는 것과 대조적이다.

send 메소드

추가적으로, 제너레이터에는 하나의 기능이 더 있는데, 바로 send() 메소드이다. next() 함수의 경우 단순히 suspend된 제너레이터를 다시 resume 시키는 역할을 할 뿐이지만, send() 메소드는 resume과 동시에 값을 주입할 수 있다.

def gene2():
    value = 1
    for i in range(10):
        value = yield value
 
g = gene2()
value = next(g)  # value == 1
vlaue = g.send(100)  # value == 100
value = g.send(222)  # value == 222
# ...

제너레이터 중첩과 yield from

당연하게도 제너레이터 안에서 다른 제너레이터를 부를 수 있다.

def gen1():
    for value in gen2():
        yield value
    
def gen2():
    for value in range(10):
        yield value
   
g1 = gen1()
value = next(g1)  # value == 0
value = next(g1)  # value == 1
# ...

gen1 안에서 gen2를 모두 소비하기 위해서 for 문을 사용했다. 그러나 Python 3.3부터 추가된 yield from 문을 이용하면 제너레이터에서 제너레이터를 소비하는 것을 더 쉽게 할 수 있다.

def gen1():
    yield from gen2()
    
def gen2():
    yield from range(10)
  
g1 = gen1()
value = next(g1)  # value == 0
value = next(g1)  # value == 1
# ...

아래 코드는 위의 코드와 정확히 같은 동작을 한다.

이렇게 제너레이터를 yield from으로 중첩하면 엄청 깊숙히 들어가 있는 제너레이터의 값도 바깥에서 순차적으로 꺼내 쓸 수 있다. 즉, 바깥 입장에서는 안에 얼마나 많은 제너레이터가 중첩되어 있는지 관계 없이 next(), send()같은 메소드로 제어가 가능하다.

yield from 문은 제너레이터 뿐 아니라 일반적인 이터러블, 이터레이터 객체를 대상으로도 동작한다.

async/await

Python 3.5부터 추가된 await 문은 yield from과 거의 똑같은 동작을 한다. 여기서는 그냥 인터페이스 일관성이나 안전성 그리고 몇가지 문법 설탕 등을 위해서 추가된 키워드라고 생각해도 좋을 것 같다.

await 문은 async def로 선언된 함수(실제로는 제너레이터, 코루틴)에서만 사용할 수 있다.