본문 바로가기
프로그래밍/Python

[Python] 코루틴(Coroutines), yield from

by 별준 2022. 3. 27.

References

  • Fluent Python

Contents

  • Classic Coroutines - Basic Behavior
  • Decorators for Coroutines
  • Coroutine Termination and Error Handling
  • Returning a Value from the coroutines
  • yield from

'to yield'라는 단어를 찾아보면 '생산한다(produce)'와 '양보한다(give way)'라는 두 가지 뜻을 볼 수 있습니다. 파이썬 제너레이터에서 yield 키워드를 사용할 때, 이 두 가지 의미가 모두 적용됩니다. 예를 들어 yield item 문장은 next()의 호출자가 받을 값을 생성하고, 양보하고, 호출자가 진행하고 또 다른 값을 소비할 준비가 되어 다음번 next()를 호출할 때까지 제너레이터 실행을 중단합니다. 이는 호출자가 제너레이터로부터 값을 꺼내오는 것입니다.

 

파이썬 코루틴은 본질적으로 .send(...) 메소드를 호출하는 제너레이터입니다. 코루틴에서 'to yield'의 본질적인 의미는 양보한다는 것입니다. 즉, 제어를 프로그램의 다른 부분에 넘겨주고, 다시 시작하라고 알려줄 때까지 기다립니다. 호출자는 my_coroutine.send(datum)을 호출하여 코루틴에게 데이터를 넘겨줍니다. 그럼 코루틴은 다시 재개되고 중단된 곳에서 datum을 yield 표현식의 값으로 받습니다. 일반적인 용법에서, 호출자는 이 방법을 통해 반복적으로 데이터를 밀어 넣습니다. 제너레이터와 반대로, 코루틴은 보통 데이터를 소비하며, 생산하지 않습니다.

 

데이터 흐름과 관련없이, yield는 협력하는 멀티태스킹을 구현할 수 있는 제어 흐름 장치(control flow device)입니다. 각 코루틴은 다른 코루틴을 활성화시킬 수 있도록 제어권을 중앙 스케쥴러에게 양보(yield)합니다.

 

3.5버전 이후로 파이썬에는 3가지 종류의 코루틴이 있습니다.

  • Classic coroutines:
    my_coro.send(data) 호출을 통해 전달된 데이터를 yield를 사용하여 읽어서 소모하는 제너레이터 함수. 클래식 코루틴은 yield from을 사용하여 다른 코루틴에게 위임할 수 있다.
  • generator-based coroutines:
    @types.coroutine으로 데코레이트된 제너레이터 함수. 파이썬 3.5에 도입된 await 키워드와 호환됨
  • native coroutines:
    async def로 정의된 코루틴. await 키워드를 사용해서 네이티브 코루틴을 다른 네이티브 코루틴이나 generator-based 코루틴으로 위임할 수 있음 (yield from 처럼)

네이티브 코루틴이나, generator-based 코루틴은 특별히 비동기 I/O 프로그래밍을 위한 것입니다. 이번 포스팅에서는 클래식 코루틴에 대해서만 알아보도록 하겠습니다. 비록 클래스 코루틴으로부터 네이티브 코루틴이 발전했지만, 이들은 서로를 완전히 대체할 수 없습니다. 클래식 코루틴에는 네이티브 코루틴에서는 할 수 없는 몇 가지 유용한 동작들이 있으며, 그 반대도 마찬가지 입니다.

 

클래식 코루틴은 간단한 제너레이터 함수를 계속해서 개선해온 산물입니다. 파이썬의 코루틴의 발전 과정을 따라가다 보면 단계별로 기능이 많아지면서 더 복잡해지는 특징을 이해하는 데 도움이 됩니다.

 

제너레이터를 어떻게 코루틴으로 만들 수 있는지 간략하게 살펴보고, 다음과 같은 코루틴의 핵심에 대해서 알아보도록 하겠습니다.

  • The behavior and state of a generator operating as a coroutine
  • Priming a coroutine automatically with a decorator
  • How the caller can control a coroutine through the .close() and .throw() methods of generator object
  • How coroutines can return values upon termination
  • Usage and semantics of the new yield from syntax

 


How Coroutines Evolved from Generators

코루틴은 문법적으로는 제너레이터와 같으며, 단순히 바디 안에 yield 키워드를 가지고 있는 함수입니다. 그러나 코루틴에서 yield는 보통 표현식의 오른쪽에 나타나며(e.g., datum = yield), 값을 생성하거나 생성하지 않을 수 있습니다. 만약 yield 키워드 이후에 표현식이 없다면, 제너레이터는 None을 생성(yield)합니다. 코루틴은 호출자(caller)로부터 데이터를 받을 수 있고, next(coro) 대신 coro.send(datum)을 사용해서 코루틴을 구동할 수 있습니다. 일반적으로 호출자는 값을 코루틴으로 밀어넣습니다. yield 키워드를 통해 어떠한 데이터의 교류가 없을 수 있습니다. 데이터보다는 주로 제어 흐름 측면에서 yield를 생각하면 코루틴이 왜 동시(concurrent) 프로그래밍에 유용한 지 이해할 수 있는 사고 방식을 가질 수 있습니다.

 

코루틴의 기반은 파이썬 2.5에 구현된 PEP 324에 설명되어 있습니다. 이때부터 yield 키워드를 표현식에 사용할 수 있게 되었으며, send() 메소드가 제너레이터 API에 추가되었습니다. 제너레이터의 호출자는 send()를 이용해서 제너레이터 함수 내부의 yield 표현식의 값이 될 데이터를 전송할 수 있습니다. 이렇게 제너레이터가 호출자에 데이터를 생성해주고 호출자로부터 데이터를 받으면서 호출자와 협업하는 프로시저인 코루틴이 됩니다.

 

PEP 324는 send() 메소드 외에 throw()와 close()를 추가했습니다. throw() 메소드는 제너레이터 내부에서 처리할 예외를 호출자가 발생시킬 수 있게 해주며, close() 메소드는 제너레이터가 종료되도록 만듭니다. 이 기능은 뒤에서 설명하도록 하겠습니다.

 

클래식 코루틴에서 최근의 혁신적인 스텝은 PEP 380(in Python 3.3)에 기술되어 있습니다. PEP 380은 제너레이터 함수에 다음과 같이 두 가지 구문 변경을 정의해서 훨씬 더 유용하게 코루틴을 사용할 수 있도록 만들었습니다.

  • 제너레이터가 값을 리턴(return)할 수 있다. 이전에는 제너레이터가 return문으로 값을 반환하면 SyntaxError가 발생했다.
  • 기존 제너레이터가 서브제너레이터에 위임하기 위해 필요했던 많은 코드들을 사용할 필요없이 yield from 구문을 사용해서 복잡한 제너레이터를 더 작은 중첩된 제너레이터로 리팩토링할 수 있게 한다.

이러한 변경 내용들도 뒤에서 살펴보도록 하겠습니다.

 


Basic Behavior of a Generator Used as a Coroutine

다음의 코드는 코루틴의 동작을 보여줍니다.

코루틴은 4가지 상태를 가집니다. 이 상태는 inspect.getgeneratorstate() 함수를 이용해서 현재 상태를 알 수 있습니다. 이 함수는 다음의 4가지 상태 중 하나를 반환합니다.

  • GEN_CREATED : 실행을 시작하기 위해 대기하고 있는 상태
  • GEN_RUNNING : 현재 인터프리터가 실행하고 있는 상태(이 상태는 멀티스레드 환경에서만 볼 수 있다, 제너레이터 객체가 자신에게 호출해도 볼 수 있지만, 그리 유용한 방법은 아니다)
  • GEN_SUSPENDED : 현재 yield문에서 대기하고 있는 상태
  • GEN_CLOSED : 실행이 완료된 상태

위에서 실행이 종료된 my_coro의 상태를 확인하면 다음과 같습니다.

 

send() 메소드에 전달한 인수가 대기하고 있는 yield 표현식의 값이 되므로, 코루틴이 현재 대기 상태에 있을 때는 my_coro.send(42)와 같은 형태로만 호출할 수 있습니다. 그러나 코루틴이 아직 기동되지 않은 상태(GEN_CREATED)인 경우에는 send() 메소드를 호출할 수 없습니다. 그래서 코루틴을 처음 활성화하기 위해 next(my_coro)를 호출합니다. 이 호출은 my_coro.send(None)을 호출하는 것과 효과가 동일합니다.

 

코루틴을 객체를 생성하고 난 직후에 바로 None이 아닌 값을 전달하려고 하면 다음과 같은 에러가 발생합니다.

에러 메세지를 확인하면 원인이 명확합니다.

처음 next(my_coro)를 호출할 때, 코루틴을 '기동(priming)'한다고도 표현합니다. 즉, 코루틴이 호출자로부터 값을 받을 수 있도록 처음 나오는 yield문까지 실행을 진행하는 것입니다.

 

yield문을 한 번 이상 호출하는 코드를 살펴보면, 코루틴의 동작을 좀 더 명확히 이해할 수 있습니다. 아래 예제 코드를 살펴보겠습니다.

 

코루틴 실행은 yield 키워드에서 중단되는 것을 잘 알고 있어야 합니다. 앞에서 설명한 것처럼 할당문에서는 실제 값을 할당하기 전에 '=' 오른쪽 코드를 실행합니다. 즉, b = yield a와 같은 코드에서는 나중에 호출자가 값을 보낸 후에야 변수 b가 설정됩니다. 이러한 방식에 익숙해지려면 신경을 더 써야 하지만, 이 방식을 제대로 알고 있어야 비동기 프로그래밍에서 yield 용법을 이해할 수 있습니다.

 

아래 그림처럼 simple_coro2 코루틴의 실행은 세 단계로 나눌 수 있습니다.

  1. next(my_coro2)는 첫 번째 메세지를 출력하고 yield a까지 실행되어 숫자 14를 생성
  2. my_coro2.send(28)은 28을 b에 할당하고, 두 번째 메세지를 출력한 후 yield a+b까지 실행되어 숫자 42를 반환
  3. my_coro2.send(99)는 99를 c에 할당하고, 세 번째 메세지를 출력한 후 코루틴을 끝까지 실행시킴

 

Example: Coroutine to Compute a Running Average

약간 더 복잡한 코루틴 예제를 살펴보겠습니다. 이동 평균을 계산하는 코루틴을 구현할 것인데, 이는 클로저나 함수 객체 등으로도 구현할 수 있습니다.

 

다음 코드는 코루틴으로 이동 평균을 구하는 방법을 보여줍니다.

def averager():
    total = 0.0
    count = 0
    average = None
    # 무한 루프로 구현. 이 코루틴은 호출자가 값을 보내주는 한 계속해서
    # 값을 받고, 결과를 생성한다. 이 코루틴은 close() 메소드를 호출하거나
    # 이 객체에 대한 참조가 모두 사라져서 가비지 컬렉트되어야 종료된다
    while True:
        # 이 yield문은 코루틴을 중단하고, 지금까지의 평균을 생성하기 위해 사용
        # 나중에 호출자가 이 코루틴에 값을 보내면 루프를 다시 실행한다
        term = yield average
        total += term
        count += 1
        average = total/count

코루틴을 사용하면 total과 count를 지역 변수로 사용할 수 있다는 장점이 있습니다. 객체 속성이나 별도의 클로저 없이 평균을 구하는 데 필요한 값들을 유지할 수 있습니다. 아래 예제 코드는 averager() 코루틴을 활용하는 방법을 보여줍니다.

위 예제 코드에서 next(coro_avg)를 호출하면 코루틴이 yield 문까지 실행되어 average의 초깃값이 None을 반환합니다. 이 값은 콘솔에 나타나지 않습니다. 이때 코루틴은 호출자가 값을 보내기를 기다리며 이 yield 문에서 중단합니다. coro_avg.send(10)은 코루틴에 값을 보내 코루틴을 활성화시키고, 이 값을 term에 할당하고, total, count, average를 계산하고, while 루프를 다시 돌아 average를 생성하고, 또 다시 다른 값이 들어오기를 기다립니다.

 

이 코드를 보면, 바디 안에 무한 루프를 가진 averager 객체를 어떻게 종료시킬 수 있을지 궁금할 수 있는데, 이는 뒤에서 설명하도록 하겠습니다.

 

코루틴의 종료를 살펴보기 전에, 코루틴을 기동하는 방법에 대해서 조금 더 알아보겠습니다. 코루틴은 사용하기 전에 기동해야 하지만, 이런 사소한 작업은 까먹기가 쉽습니다. 이런 문제를 해결하기 위해 특별한 데코레이터를 코루틴에 적용할 수 있는데, 바로 알아보도록 하겠습니다.

 


Decorators for Coroutine Priming

코루틴은 기동되기 전에 할 수 있는 일이 많지 않습니다. my_coro.send(x)를 처음 호출하기 전에는 반드시 next(my_coro)를 호출해야 합니다. 따라서 코루틴을 편리하게 사용할 수 있도록 기동하는 데코레이터가 종종 사용됩니다. 대표적으로 아래 코드의 @coroutine 데코레이터가 널리 사용됩니다.

from functools import wraps

def coroutine(func):
    """Decorator: primes `func` by advancing to first `tield`"""
    # 데코레이트된 제너레이터 함수는 primer() 함수로 치환되며,
    # 실행하면 기동된 제너레이터를 반환한다
    @wraps(func)
    def primer(*args, **kwargs):
        # 데코레이트된 함수를 호출해서 제너레이터 객체를 가져온다
        gen = func(*args, **kwargs)
        next(gen) # 제너레이터를 기동
        return gen # 제너레이터를 반환
    return primer

 

아래 예제 코드는 @coroutine 데코레이터의 사용법을 보여줍니다.

코루틴과 함께 사용하도록 설계된 특별한 데토레이터를 제공하는 프레임워크가 많이 있지만, 이 프레임워크들이 모두 코루틴을 기동시키는 것은 아닙니다. 코루틴을 이벤트 루프에 연결하는 등 다른 서비스를 제공하는 프레임워크도 있습니다.

 

뒤에서 yield from에 대해 설명하겠지만, yield from 구문은 자동으로 자신을 실행한 코루틴을 기동시키므로, 방금 설명한 @coroutine 데코레이터와 함께 사용할 수 없습니다. 파이썬 3.4 표준 라이브러리에서 제공하는 @asyncio.coroutine 데코레이터는 yield from과 함께 사용할 수 있게 설계되었으므로 코루틴을 기동시키지 않습니다.

 


Corouine Termination and Exception Handling

코루틴 안에서 발생한 예외를 처리하지 않으면, next()나 send()로 코루틴을 호출한 호출자에 예외가 전파됩니다. 아래 예제 코드는 @coroutine으로 데코레이트된 averager 코루틴을 사용한 예를 보여줍니다.

코루틴의 total 변수에 더할 수 없는 'spam'이라는 문자열을 전송했으므로 에러가 발생했습니다.

 

위의 예제 코드를 살펴보면 종료하라고 코루틴에 알려주는 구분 표시를 전송해서 코루틴을 종료할 수 있음을 알 수 있습니다. None이나 Ellipsis(...)과 같은 내장 싱글턴 상수는 구분 표시로 사용하기 좋습니다. Ellipsis는 데이터 스트림에서 상당히 보기 드문 표시라는 장점도 있습니다. 그리고 저자의 경우에는 StopIteration을 사용하기도 합니다(객체가 아니라 클래스 자체로 사용). 즉, my_coro.send(StopIteration) 형태로 사용합니다.

 

파이썬 2.5 이후 제너레이터 객체는 호출자가 코루틴에 명시적으로 예외를 전달할 수 있게 해주는 throw()와 close() 메소드를 제공합니다.

  • generator.throw(exc_type[, exc_value[, traceback]]) :
    제너레이터가 중단한 곳의 yield 표현식에 예외를 전달한다. 제너레이터가 예외를 처리하면, 제어 흐름이 다음 yield문까지 진행하고, 생성된 값은 generator.throw() 호출 값이 된다. 제너레이터가 예외를 처리하지 않으면 호출자까지 예외가 전파된다.
  • generator.close() :
    제너레이터가 실행을 중단한 yield 표현식이 GeneratorExit 예외를 발생시키게 만든다. 제너레이터가 예외를 처리하지 않거나 StopIteration 예외를 발생시키면, 아무런 에러도 호출자에 전달되지 않는다. GeneratorExit 예외를 받으면 제너레이터는 아무런 값도 생성하지 않아야 한다. 아니면 RuntimeError가 발생한다. 제너레이터에서 발생하는 다른 예외는 모두 호출자에 전달된다.

 

이제 close()와 throw()가 어떻게 코루틴을 제어하는지 살펴보겠습니다. 아래의 demo_exc_handling() 함수는 예제에서 사용되는 코루틴을 보여줍니다.

class DemoException(Exception):
    """An exception type for the demonstration"""

def demo_exc_handling():
    print('-> coroutine started')
    while True:
        try:
            x = yield
        # DemoException 예외를 따로 처리한다
        except DemoException:
            print('*** DemoException handled. Continuing...')
        else: # 예외가 발생하지 않으면 받은 값을 출력
            print('-> coroutine received: {!r}'.format(x))
    # 아래 코드는 절대 실행되지 않는다
    raise RuntimeError('This line should never run.')

demo_exc_handling()에서 마지막 행은 절대로 도달할 수 없습니다. 함수의 무한 루프는 처리되지 않은 예외에 의해서만 중단될 수 있으며, 예외를 처리하지 않으면 코루틴의 실행이 바로 중단되기 때문입니다.

 

demo_exc_handling()의 일반적인 연산은 아래와 같습니다.

 

DemoException을 코루틴 안으로 던지면, 이 예외가 처리되어 demo_exc_handling() 코루틴은 다음과 같이 계속 실행됩니다.

한편 처리되지 않는 예외를 코루틴 안으로 던지면 다음과 같이 코루틴이 중단되고, 코루틴의 상태는 'GEN_CLOSED'가 됩니다.

 

만약 코루틴이 어떻게 종료되든 어떤 정리 코드를 실행해야 하는 경우에는 다음과 같이 try/finally 블록 안에 코루틴의 해당 코드를 넣어야 합니다.

class DemoException(Exception):
    """An exception type for the demonstration"""

def demo_exc_handling():
    print('-> coroutine started')
    try:
        while True:
            try:
                x = yield
            # DemoException 예외를 따로 처리한다
            except DemoException:
                print('*** DemoException handled. Continuing...')
            else: # 예외가 발생하지 않으면 받은 값을 출력
                print('-> coroutine received: {!r}'.format(x))
    finally:
        print('-> coroutine ending')

 


Returning a Value from a Coroutine

아래 예제 코드는 위에서 구현한 averager() 코루틴을 변형해서 값을 반환합니다. 이 코루틴은 활성화할 때마다 이동 평균을 생성하지는 않습니다. 의미 있는 값을 생성하지는 않지만 최후에 어떤 의미 있는 값을 반환하는(예를 들면 최종 합계) 코루틴도 있음을 설명하기 위함입니다.

 

아래의 예제 코드의 averager()가 반환하는 결과는 namedtuple로서, 항목 수(count)와 평균(average)을 담고 있습니다. 그냥 average 값만 반환할 수도 있습니다.

from collections import namedtuple

Result = namedtuple('Result', 'count average')

def averager():
    total = 0.0
    count = 0
    average = None
    while True:
        term = yield
        if term is None:
            # 값을 반환하려면 코루틴이 정상적으로 종료되어야 함
            # 따라서 이 averager 버전에서는 루프를 빠져나오는 조건을 검사한다
            break
        total += term
        count += 1
        average = total/count
    # count와 average를 가진 nametuple을 반환
    # 제너레이터 함수가 값을 반환하므로 파이썬 3.3 이전 버전에서는 에러가 발생
    return Result(count, average)

새로 만든 averager()는 다음과 같이 사용할 수 있습니다.

return문이 반환하는 값은 StopIteration 예외의 속성에 담겨 호출자에 전달되는 것에 주의해야 합니다. 

다음 예제 코드는 코루틴이 반환한 값을 가져오는 방법을 보여줍니다.

 

다만, 이렇게 처리하면 사용자가 코루틴이 StopIteration 예외를 발생시킨다는 사실을 알고 있어야 합니다.

 

PEP 380에 코루틴이 반환한 값을 가져오는 방법이 정의되어 있으며, yield from이 StopIteration 예외를 내부적으로 잡아서 자동으로 처리합니다. 이는 for 루프 안에서 StopIteration을 사용하는 방법과 비슷한데 예외가 발생했다는 사실을 사용자가 모르도록 루프가 깔끔하게 처리합니다. yield from의 경우 인터프리터가 StopIteration 예외를 처리할 뿐만 아니라 value 속성이 yield from 표현식의 값이 됩니다. 불행히도 이 과정은 콘솔에서 대화식으로 테스트를 할 순 없는데, 함수 외부에서 yield from이나 yield를 사용하면 구문 에러가 발생하기 때문입니다.

 


Using yield from

이번에는 PEP 380에서 의도한 대로 결과를 생성하기 위해 averager() 코루틴을 yield from과 함께 사용하는 예를 살펴보도록 하겠습니다.

 

우선 yield from이 완전히 새로운 언어 구조라는 점을 명심해야 합니다. yield보다 훨씬 더 많은 일을 하므로 비슷한 키워드를 재사용한 것은 오해의 소지가 있습니다. 다른 언어에서는 이와 비슷한 구조를 await라고 하는데, 핵심을 잘 전달하므로 좀 더 좋은 키워드라고는 생각됩니다. 제너레이터 gen()이 yield from subgen()을 호출하고, subgen()이 이어받아 값을 생성하고 gen()의 호출자에 반환합니다. 실질적으로 subgen()이 직접 호출자를 이끕니다. 그러는 동안 gen()은 subgen()이 종료될 때까지 실행을 중단합니다.

 

[Python] Iterables, Iterators, and Generators

위 포스팅에서 yield from을 for 루프 안에 yield에 대한 단축문으로 사용할 수 있다고 언급했었습니다. 예를 들어 다음의 코드를 살펴보겠습니다.

위 코드는 다음과 같이 바꿀 수 있습니다.

특히, 위의 이전 포스팅에서 yield from에 대해 처음 살펴볼 때, 아래의 예제 코드로 사용법을 살펴봤습니다.

 

yield from x 표현식이 x 객체에 대해 첫 번째로 하는 일은 iter(x)를 호출해서 x의 반복자를 가져오는 것입니다. 이는 모든 반복형이 x에 사용될 수 있다는 의미입니다.

 

그러나 값을 생성하는 중첩된 for 루프를 대체하는 게 yield from이 하는 일의 전부가 아닙니다. yield from의 진정한 기능은 단순한 반복형을 이용해서는 설명할 수 없고, 중첩된 제너레이터를 복잡하게 사용하는 예제가 필요합니다. 이 때문에 yield from을 제안한 PEP 380의 제목이 'Syntax for Delegating to a Subgenerator' 입니다.

 

yield from의 주요한 특징은 가장 바깥쪽 호출자와 가장 안쪽에 있는 서브제너레이터 사이에 양방향 채널을 열어준다는 것입니다. 따라서 이 둘이 값을 직접 주고받을 수 있으며, 중간에 있는 코루틴이 일반적인 예외 처리 코드를 구현할 필요없이 예외를 직접 던질 수 있습니다. 이전에는 불가능했던 코루틴 위임(coroutine delegation)이 이 새로운 방식 덕분에 가능하게 되었습니다.

 

yield from을 사용하려면 코드를 꽤나 많이 준비해야 하는데, 필요한 부분들을 설명하기 위해서 PEP 380은 다음과 같이 주요 용어들을 상당히 구체적으로 정의하고 있습니다.

  • delegating gererator :
    yield from <iterable> 표현식을 담고 있는 제너레이터 함수
  • subgenerator :
    yield from 표현식 중 <iterable>에서 가져오는 제너레이터. PEP 380 제목의 subgenerator가 바로 이것이다.
  • caller :
    PEP 380에서는 delegating generator를 호출하는 코드를 '호출자(caller)'라고 표현한다. 문맥에 따라서 delegating generator와 구분하기 위해 'caller' 대신 'client'라는 용어를 사용하기도 한다. (subgenerator 입장에서는 delegating generator도 caller이기 때문)

 

아래 코드는 yield from이 동작하는 환경을 제공하고, 위의 그림은 이 예제에서 주요 부분들을 설명해줍니다.

from collections import namedtuple

Result = namedtuple('Result', 'count average')

# the subgenerator
def averager():
    total = 0.0
    count = 0
    average = None
    while True:
        term = yield
        if term is None:
            break
        total += term
        count += 1
        average = total/count
    return Result(count, average)

# the delegating generator
def grouper(results, key):
    while True:
        # 루프를 반복할 때마다 하나의 averager() 객체를 생성
        # 각 averager() 객체는 하나의 코루틴으로 동작
        results[key] = yield from averager()

# the client code, a.k.a. the caller
def main(data):
    results = {}
    for key, values in data.items():
        # group은 grouper()를 호출해서 반환된 제너레이터 객체. 코루틴으로 동작
        group = grouper(results, key)
        # 코루틴을 기동시킴
        next(group)
        for value in values:
            # 값을 하나씩 grouper()로 전달. 이 값은 averager()의 term = yield의 yield가 됨
            # grouper()는 이 값을 볼 수 없다.
            group.send(value)
        # None을 전달하면 현재 averager() 객체가 종료하고 grouper()가 실행을 재개
        # grouper()는 또 다른 averager() 객체를 생성해서 다음 값을 받는다
        group.send(None)
    
    print(results) # to debug
    report(results)

# output report
def report(results):
    for key, result in sorted(results.items()):
        group, unit = key.split(';')
        print(f'{result.count:2} {group:5}',
            f'averaging {result.average:.2f} {unit}')

data = {
    'girls;kg':
        [40.9, 38.5, 44.3, 42.2, 45.2, 41.7, 44.5, 38.0, 40.6, 44.5],
    'girls;m':
        [1.6, 1.51, 1.4, 1.3, 1.41, 1.39, 1.33, 1.46, 1.45, 1.43],
    'boys;kg':
        [39.0, 40.8, 43.2, 40.8, 43.1, 38.6, 41.4, 40.6, 36.3],
    'boys;m':
        [1.38, 1.5, 1.32, 1.25, 1.37, 1.48, 1.25, 1.49, 1.46],
}

if __name__ == '__main__':
    main(data)

위 코드를 실행한 결과입니다.

 

예제 코드에서 group.send(None) 구문이 중요합니다. None을 전송해야 현재의 averager() 객체가 종료되고 다음번 객체를 생성하게 됩니다. 이 코드를 제거하면 실행해도 아무것도 출력되지 않습니다. None이 전달되지 않았으면, 내부 루프가 끝났을 때 grouper() 객체는 여전히 yield from에 멈춰있으므로, grouper() 바디 안에서 results[key]에 대한 할당은 아직 실행되지 않습니다. 바깥쪽 for 루프가 이렇게 끝나고 다시 반복하면, 새로운 grouper() 객체가 생성되어 group 변수에 바인딩됩니다. 기존 grouper() 객체는 더 이상 참조되지 않으므로 가비지 컬렉트됩니다. 이때 아직 실행 종료되지 않은 averager() 서브 제너레이터 객체도 가비지 컬렉트됩니다.

 

이 예제는 delegating 제너레이터와 서브제너레이터가 하나씩만 있는 가장 간단한 형태의 yield from 예시를 보여줍니다. delegating 제너레이터가 일종의 파이프 역할을 하므로, delegating 제너레이터가 서브제너레이터를 호출하기 위해 yield from을 사용하고, 그 서브제너레이터는 delegating 제너레이터가 되어 또 다른 서브제너레이터를 호출하기 위해 yield from을 사용하는 과정을 반복해 아주 긴 파이프라인을 만들 수도 있습니다. 이 파이프라인은 결국 yield를 사용하는 간단한 제너레이터에서 끝나야 합니다. 또는 yield from <iterable>에서 끝날 수도 있습니다.

 

모든 yield from 체인은 가장 바깥쪽 delegating generator에 next()와 send()를 호출하는 클라이언트(=caller)에 의해 주도됩니다. 이 메소드들은 for 루프를 통해 암묵적으로 호출할 수도 있습니다.

 


The Meaning of yield from

Basic behavior of yield from

PEP 380에서는 proposal에서 6개의 항목으로 yield from의 동작을 설명합니다. 다음은 제안서의 내용을 거의 그대로 옮겼지만, 'Iterator'라는 말을 'Sub generator'로 변경하고, 말을 약간 다듬었습니다. 바로 위에서 살펴본 예제는 다음의 4가지 특징을 보여줍니다.

  • Sub Generator가 생성하는 값은 모두 caller(클라이언트)에 바로 전달된다.
  • send()를 통해 Delegating Generator에 전달한 값은 모두 Sub Generator에 직접 전달된다. 값이 None이면 Sub Generator의 __next__() 메소드가 호출된다. 전달된 값이 None이 아니면 Sub Generator의 send() 메소드가 호출된다. 호출된 메소드에서 StopIteration 예외가 발생하면 Delegating Generator의 실행이 재개된다. 그 외의 예외는 Delegating Generator로 전달된다.
  • 제너레이터에서 return expr문을 실행하면, 제너레이터를 빠져나온 후 StopIteration(expr) 예외가 발생한다.
  • Sub Generator가 실행을 마친 후 발생한 StopIteration 예외의 첫 번째 인수가 yield from 표현식의 값이 된다.

yield from의 나머지 특징 두 가지는 예외와 종료에 관련되어 있습니다.

  • Delegating Generator에 던져진 GeneratorExit 이외의 예외는 Sub Generator의 throw() 메소드에 전달된다. throw() 메소드를 호출해서 StopIteration 예외가 발생하면 Delegating Generator의 실행이 재개된다. 그 외의 예외는 Delegating Generator에 전달된다.
  • GeneratorExit 예외가 Delegating Generator에 던져지거나 Delegating Generator의 close() 메소드가 호출되면 Sub Generator의 close() 메소드가 호출된다. 그 결과 예외가 발생하면 발생한 예외가 Delegating Generator에 전파된다. 그렇지 않으면 Delegating Generator에서 GeneratorExit 예외가 발생한다.

yield from의 세부적인 의미는 미묘합니다. 특히 예외를 처리하는 부분에서 그런데, 제안서의 작성자인 Greg Ewing은 이렇게 미묘한 동작을 PEP 380에 잘 설명하고 있습니다.

 

또한 파이썬과 비슷한 의사코드를 이용해서 yield from의 동작을 문서화했습니다. 저자의 의견으로는 PEP 380의 의사코드를 분석해보는 것이 꽤나 도움이 된다고 생각합니다. 다만 40줄이나 되므로 한눈에 바로 이해하기에는 쉽지 않습니다.

 

가정을 통해 yield from의 동작을 간단히 살펴보겠습니다.

yield from이 Delegating Generator 안에 있고, 클라이언트 코드는 Delegating Generator를 기동하고 Delegating Generator는 Sub Generator를 기동합니다. 그리고 관련된 로직을 단순화하기 위해 클라이언트가 Delegating Generator의 throw()나 close()를 절대 호출하지 않는다고 가정하겠습니다. 그리고 Sub Generator는 실행을 마칠 때까지 예외를 발생시키지 않고, 실행을 마친 후에는 인터프리터가 StopIteration 예외를 발생시킨다고 가정하겠습니다.

 

의에서 살펴본 yield from 예제 코드는 이러한 가정을 기반으로 작성한 코드입니다. 사실 실제 코드에서는 Delegating Generator가 실행을 완료해야 합니다.

RESULT = yield from EXPR

아래에서 살펴볼 의사코드는 위의 문장 하나를 확장한 것과 같습니다.

# Iterator _i(Sub Generator)를 가져오기 위해 iter() 함수 적용,
# 어떠한 반복형도 EXPR로 사용할 수 있음
_i = iter(EXPR)
try:
    # Sub Generator를 기동시킴
    _y = next(_i)
except StopIteration as _e:
    # StopIteration이 발생하면 예외 객체에서 value 속성을 _r에 할당
    # 이 값이 RESULT 값이 된다
    _r = _e.value
else:
    # 이 루프를 실행하는 동안 Delegating Generator는 실행이 중단되고
    # caller와 Sub Generator 간의 통로 역할만 수행
    while 1:
        # Sub Generator에서 생성된 값을 그대로 생성하고,
        # caller가 보낼 _s를 기다림
        _s = yield _y
        try:
            # caller가 보낸 _s를 Sub Generator에 전달하면서 실행을 재개시킴
            _y = _i.send(_s)
        except StopIteration as _e:
            # Sub Generator가 StopIteration 예외를 발생시키면, value를 가져와서
            # _r에 할당하고 루프를 빠져나온 후 Delegating Generator의 실행을 재개함
            _r = _e.value
            break
# _r이 전체 yield from 표현식의 값이 되어 RESULT에 저장됨
RESULT = _r

위의 간단한 의사코드에서 PEP 380에 사용된 의사코드의 원래 변수명을 유지했습니다. 여기서 사용된 변수는 다음과 같습니다.

  • _i (iterator) : Sub Generator
  • _y (yielded) : Sub Generator가 생성한 값
  • _r (result) : 최종 결과값(즉, Sub Generator가 종료된 후 yield from 표현식의 값)
  • _s (sent) : Caller가 Delegating Generator로 보낸 값. Sub Generator로 전달됨
  • _e (exception) : 예외(여기서는 간단히 StopIteration 객체만 발생)

throw()와 close()를 처리하지 않는 것 외에도, 단순화한 의사코드에서는 클라이언트에 의한 next()와 send() 호출을 Sub Generator에 전달하기 위해 send()만 사용하고 있습니다. 일단 처음 구조를 파악할 때는 next()와 send()의 구분에 신경쓸 필요가 없기 때문에 이렇게 작성되었습니다.

 

그러나 클라이언트가 호출하는 throw()와 close()를 처리하기 위해 Sub Generator에 전달해야 하므로 실제 로직은 더 복잡합니다. 그리고 Sub Generator가 throw()나 close()를 지원하지 않는 단순한 반복형일 수 있으므로 이런 경우도 yield from 로직에서 처리해야 합니다. 즉, Sub Generator가 이 메소드들을 구현하지 않으면, 예외가 발생하고, 이 예외를 yield from에서 처리해야 합니다. Sub Generator는 Caller가 의도하지 않았던 예외도 발생시킬 수 있으며, 이 예외도 yield from 구현에서 처리해야 합니다. 마지막으로 최적화하기 위해 Caller가 호출하는 next()와 send(None)은 모두 Sub Generator의 next()를 호출합니다. Caller가 None이 아닌 값으로 send()를 호출할 때만 Sub Generator의 send() 메소드가 사용됩니다.

 

이 논리가 적용된 의사 코드는 PEP 380의 Formal Semantics에 있습니다. 위에서 살펴본 간단한 의사 코드에서 확장된 것이므로 실제 yield from의 동작을 더 자세히 살펴보고 싶으면, 위의 링크를 참조하시길 바랍니다.

위 링크의 의사코드 로직의 대부분은 4 depth까지 들어가는 6개의 try/except 블록에서 구현되므로 이해하기 약간 복잡합니다. 그 외 while 키워드가 1개, if가 1개, yield가 1개 사용되었습니다. while, yield, next(), send()에 주의해서 살펴보면 전체적인 구조를 이해하는 데 도움이 될 것 입니다.

'프로그래밍 > Python' 카테고리의 다른 글

[Python] Futures  (0) 2022.03.30
[Python] Concurrency Models  (0) 2022.03.29
[Python] Context Mangers and else Blocks  (0) 2022.03.26
[Python] Iterables, Iterators, and Generators  (0) 2022.03.25
[Python] 연산자 오버로딩  (0) 2022.03.24

댓글