제너레이터(Generator)를 활용한 코루틴(coroutine)

제너레이터는 Python 객체의 한 종류로 게으르기 때문에 효율적으로 메모리를 사용할 수 있도록 해준다. (정보를 한 번에 모두 가져오지 않고 정보가 필요할 때 조금씩 불러오는 것을 lazy하다고 표현한다.) 리스트를 활용했을 때와 제너레이터를 활용했을 때 메모리 사용량을 비교해 보았다. 0 ~ 10**6 범위의 자연수를 만들 수 있는 제너레이터와 0 ~ 10**6 범위의 자연수를 가지고 있는 리스트의 메모리 사용량은 아래와 같았다. (상대적인 비교에 초점을 두고 그래프를 보자.)

데이터의 크기가 큰 만큼 둘의 차이도 명확했다. 제너레이터는 하나의 객체이기 때문에 메모리를 많이 차지하지 않는다. 따라서 객체를 반환하거나 복사할 때도 큰 문제가 되지 않는다. 반면, 리스트는 모든 데이터를 담고 있기 때문에 제너레이터에 비해 많은 메모리 공간을 필요로 한다. 


yield

yield는 return과 같이 함수의 반환값을 호출 측으로 전달한다. 하지만 yield는 반환 값을 하나씩 메모리에 올려두기 때문에 한 번에 모든 값을 반환하는 return과 차이가 있다. 

def use_return():
    # retrun 사용
    return 0, 1, 2

def use_yield():
    # yield 사용
    yield 0
    yield 1
    yield 2

yield 함수를 사용해보면 아래와 같다.

>>> use_yield()
<generator object use_yield at 0x0000023838584820>
>>> generator = use_yield()
>>> next(generator)
0
>>> next(generator)
1
>>> next(generator)
2
>>> next(generator)
StopIteration

해당 함수는 제너레이터 객체를 생성하며 next를 호출할 때마다 yield의 값을 반환한다. 이때 yield를 통해 값을 반환하고 그 상태에서 멈춘다. 이후 다시 next를 통해 호출되었을 때 다음 yield의 값을 반환한다. 만약 모든 값을 반환했다면 StopIteration 예외를 발생시킨다. 마치 이터레이터(Iterator)와 비슷한 형태로 작동한다. (이터레이터와 제너레이터의 관계는 더보기 참고)

더보기

모든 제너레이터는 이터레이터이다. 

이터레이터는  __next__를 통해 다음 값을 반환하며 반환할 값이 없을 때 StopIteration 예외를 발생시키는 객체를 의미한다. 파이썬의 list, set, dict와 같이 반복이 가능한(iterable) 객체도 __iter__ 메서드를 통해 이터레이터가 될 수 있다. 

>>> list_ = [1, 2, 3]
>>> type(list_)
<class 'list'>
>>> iter = list_.__iter__()
>>> type(iter)
<class 'list_iterator'>

이것이 for... in 루프를 통해 리스트를 사용할 수 있는 이유이다.

따라서 이터레이터가 더 넓은 개념이며, 이터레이터를 편리하게 생성하는 방법 중 하나로 제너레이터가 있다. 

참고: difference-between-pythons-generators-and-iterators


yield from

yield from은 반복 가능한 객체를 내부에서 반복하지 않고 값을 하나씩 받아 호출 측에 반환하다. 아래 두 경우는 같은 결과로 동작한다. 

# for + yield
def f():
    for i in [1, 3, 5]:
        yield i


# yield from
def f():
    yield from [1, 3, 5]

따라서 서브 제너레이터를 만들어 제너레이터 내부에 제너레이터를 사용하는 것이 가능하다. 

def 제너레이터():
    yield from 서브-제너레이터

사실 yield from이 사용될 일이 많지 않다. 


제너레이터 표현식

리스트를 생성하기 위해 대괄호를 이용한 리스트 컴프리헨션(List Comprehension)을 사용하듯, 소괄호를 이용해 제너레이터를 생성할 수 있다. 

(값 for 값 in 반복-가능-객체 if 조건)

예:
(i for i in range(10))
(i for i in range(10) if i % 2 == 0)

문장이 다소 복잡해지지만 for문은 중첩으로 작성하는 것도 가능하다.

((i, j) for i in range(10) for j in range(10) if i % 2 == 0)

# 아래 표현식과 같은 반복
for i in range(10):
    for j in range(10):
        if i % 2 == 0:
            (i, j)

코루틴

코루틴(coroutine)은 서브 루틴을 일시 정지했다가 다시 실행할 수 있는 요소를 말한다. yield의 특징을 활용하면 구현이 가능하다.

코루틴에는 특별한 메서드를 사용하는데 바로 send()이다. yield는 함수에서 호출 측으로 일방적으로 정보를 반환했다면, send는 yield 측으로 값을 전달한다. 

def func():
    var = 7
    while True:
        received = yield var
        print(f"전달된 값: {received}")


f = func()
var = next(f)
var = f.send(40)  # 전달된 값: 40

먼저 제너레이터를 선언하고 next를 호출하면 yield 뒤에 있던 var이 호출 측으로 (7을) 반환된다. 그리고 제너레이터는 yield 자리에 멈춰있다. 이때 send() 메서드를 통해 값을 전달하면 해당 값이 yield 앞에 있던 received에 전달되는 것이다. 

하지만 처음에 next()를 한 번 호출해 yield 자리까지 실행해주어야 한다는 점이 번거롭다. 이 문제를 해결하기 위해 데커레이터를 활용하는 방법이 있다. 

def prepare_coroutine(coroutine):
    def wrapper(*args, **kwargs):
        generator = coroutine(*args, **kwargs)
        next(generator)
        return generator

    return wrapper


@prepare_coroutine
def func():
    while True:
        var = yield
        print(f"전달된 값: {var}")


f = func()
f.send(40)  # 전달된 값: 40

send 외에도 closethrow 메서드가 존재한다.

coroutine.close()

closeGeneratorExit 예외를 발생시킨다. 코루틴 내에서 GeneratorExit 예외에 대한 별다른 처리를 안 한다면 제너레이터가 값 생성을 중지하고 아무 일 없이 종료된다. (일반적인 예외처럼 코드가 멈추지 않고 계속 동작한다.)

coroutine.throw(예외)

예:
coroutine.throw(ValueError)

throw로 예외를 전달하면 yield 구문에서 해당 예외가 발생한다.