파이썬 Iterable class, 그리고 yield 키워드

파이썬에 Iterable, Iterator, Generator Function/Expression, Generator Iterator 은 참 오묘한 관계이다.

Iterable, Iterator

예를들어 클래스 MyClass를 설계한다고 해보자. 이 클래스는 일정한 데이터를 저장하고 가공하여 제공하는 역할을 맡는다. 하여, 아래와 같은 코드가 돌아가게 만들고 싶다. 파이썬에서 굉장히 자주 쓰는 패턴이다.

class MyClass():
    # some mysterious code block

instance = MyClass()
# ... some data acquisition ...
for item in instance:
    # process `item` here
    print(item)

이 코드가 작동하려면 MyClass는 Iterable 객체이어야 한다. 흔히 "반복 가능한" 객체로 번역되는데, 의미 면에서 이 클래스는 데이터를 반복문(Iteration)에 공급할 수 있다는 의미이다. 문법 면에서 Iterable 객체는 __iter__() 메서드를 구현하며, 이 메서드를 호출하면 Iterator 객체를 반환한다.

그럼 Iterator 객체는 무엇일까. Iterable 객체가 직접 반복문에 데이터를 공급하지는 않는다. Iterable 객체에서 데이터를 가지고 와서 적절히 가공한 뒤 반복문에 공급하는 역할은 도우미 객체인 Iterator가 담당한다. 문법 면에서 Iterator 객체는 __next__() 메서드를 구현하며, 이 메서드를 호출하면 다음번 루프에서 사용할 데이터를 반환한다. 공급할 데이터가 고갈되었다면 StopIteration 예외를 발생시켜 반복문을 끝낸다.

Iterator 객체 자체도 Iterable 객체로 취급할 수 있다. 헬퍼 객체가 반복 가능하다니 무슨 소리인지 원, 의미 면에서 이해가 안 되는게 당연하다. 이는 단지 문법 면에서 Iterable을 받는 자리에 Iterator를 집어넣을 수 있도록 해 둔 것이기 때문이다. Iterator 또한 __iter__() 메서드를 구현하며, 일반적으로 이 메서드는 다른 작업 없이 자기 자신을 반환한다. Abstract Basic Class를 상속받는 경우, Mix-in 메서드로 기본 구현되어 있으므로 신경쓰지 않아도 된다.

class MyClass():
    # some mysterious code block
    def __iter__(self):
        return IteratorOfMyClass(self)

class IteratorOfMyClass():
    def __init__(self, my_class):
        self.my_class = my_class
        self.position = 0

    def __iter__(self):
        return self
    
    def __next__(self):
        if self.position >= len(self.my_class):
            raise StopIteration
        item = self.my_class.get_data(self.position)
        self.position += 1
        # preprocess item
        # some mysterious code block
        return item

instance = MyClass()
# ... some data acquisition ...
for item in instance:
    # process `item` here
    print(item)

그럼 왜 Iterable과 Iterator를 구분할까? 물론 하나의 클래스가 __iter__()__next__()를 동시에 구현하여, Iterable이면서 동시에 Iterator로 작동하는 경우도 자주 있다. 그런데 이런 방식이 썩 바람직한 구현은 아닌 것 같다. Iterable과 Iterator는 서로 목적도 다르고 내부에 저장해야 하는 자료도 다르다. 따라서 설계상 분리하는게 바람직하다. 또 다른 문제는, 하나의 Iterable 객체에서 여러 개의 Iterator를 만들어야 하는 경우이다. 하나의 클래스가 Iterable이면서 동시에 Iterator로 작동한다면, Iterator는 Singleton 패턴을 따르게 된다. 요약하자면 복수의 Iterator를 동시에 사용할 수가 없다. 굳이 그렇게 사용해야 한다면, Iterable 인스턴스를 복사하여 사본을 생성해야 한다. 지불해야 하는 비용이 매우 커진다.

class MyClass():
    # some mysterious code block
    def __iter__(self):
        self.iteration_position = 0
        return self
    
    def __next__(self):
        if self.iteration_position >= len(self):
            raise StopIteration
        item = self.get_data(self.iteration_position)
        self.iteration_position += 1
        # preprocess item
        # some mysterious code block
        return item

instance = MyClass()
# ... some data acquisition ...
for item in instance:
    # process `item` here
    print(item)

for a, b in zip(instance, instance):
    print(a, b) # prints item (0, 1), (2, 3), ..., (2n, 2n+1)

Generator Function/Expression, Generator Iterator

다음은 Generator. 파이썬 레퍼런스를 따르자면 Generator는 Generator 함수를 의미하지만, 경우에 따라서는 Generator Iterator로 혼동하여 사용하기도 한댄다.

사전에 실린 의미를 보면 Generator는 생성기이다. 파이썬에서 Generator 함수도 같은 의미이다. 어떤 값을 만들어내는 함수인데, 여기에 특징이 하나 붙는다. 함수가 실행하는 도중에 값을 생산한다. 어? 이게 무슨 소리래. 함수는 실행이 끝나면 결과값을 반환(return)하고 함수 범위를 빠져나가며, 이 때 함수의 내부 상태는 모두 삭제된다. 코드 실행 위치, 그리고 로컬 변수의 값 등이 내부 상태에 해당한다. 이 함수를 다시 호출하더라도 함수는 처음부터 다시 시작되고, 로컬 변수는 다시 초기화된다.

그런데 Generator 함수는 함수가 실행되는 도중에 결과값을 생산(yield)한다. 결과값과 프로그램 제어권이 호출한 곳으로 반환되지만, Generator 함수 자체는 휴식(idle) 상태로 넘어갈 뿐 함수의 내부 상태는 보존된다. 그리고 다음번 결과값을 요청받으면 마지막 생산 시점부터 작업을 이어서 진행한다. 이 과정이 Generator 함수가 종료되어 반환(return)할 때까지, 또는 더이상 결과값 요청이 없어질 때까지 반복된다.

따라서 Generator 함수는 그냥 호출할 수가 없다. Generator 함수 실행 상태를 저장하고 관리하며, 값 요청이 들어오면 실행을 재개하고, 더이상 생성이 필요없어진 경우 각종 뒷정리를 해 줄 도우미가 필요하다. 이 도우미가 바로 Generator Iterator이다. Generator Iterator도 __next__()를 구현하며 이 메서드를 호출하면 비로소 다음 생성값을 만들어서 반환해 준다. 만약 Generator 함수가 반환되었다면, 더 이상 값을 생성할 수 없으므로 StopIteration 예외를 던진다. 하는 행동이 똑같으므로 Generator Iterator는 일종의 Iterator로 취급할 수 있다. 문법 면에서도 __next__() 메서드와 자기 자신을 반환하는 __iter__() 메서드를 구현한다.

거기에 더하여, Generator Iterator가 소멸하는 경우 Generator 함수의 내부 상태를 정리해 주는 역할도 맡으며(close()), 휴식 중인 함수에 값을 던져주거나(send()), 특정 예외를 발생시킬 수도 있다(throw()). 마치 코루틴(Co-routine)과 유사하다.

여하튼, 이런 특성상 Generator 함수는 Lazy-evaluation 특징을 갖는다. 즉, 값을 미리 생성하여 메모리에 저장하고 있는게 아니며, 요청이 있을 때마다 그 때 그 때 함수를 실행하고 값을 만들어서 공급해 주는 것이다. 대부분의 반복문이 값을 순차적으로 휩쓸고 지나가는 식으로 작동한다는 점을 생각해 보면, 이런 Lazy-evaluation 에서 얻을 수 있는 장점은 매우 크다. 전체 연산값이 아닌, Generator 함수의 내부 상태만 저장하면 되므로 메모리를 무지막지하게 절약할 수 있다. 또한 무한(내지는 그렇게 취급해도 될 정도로 긴) 길이의 값을 생성해 낼 수도 있다. 병렬성을 얻기는 힘들겠지만, 파이썬이 언제 제대로 된 병렬처리를 지원했던가?

def my_generator(last):
    for n in range(last):
        yield n**2
    # function end = StopIteration
    
my_generator(10)  # returns generator object (=iterator)
next(my_generator(10))  # yields 0, the first item
next(my_generator(10))  # yields 0. each function call returns a new generator iterator.

list(my_generator(10)) # [0, 1, 4, 9, ..., 81]
list(my_generator(100**100)) # Out-of-Memory or other problem occurs

for i in my_generator(100**100):
    # do something
    if i > 1000000000000000:
        break

참고로 Generator Iterator는 Generator를 호출할 때마다 매번 새롭게 생성된다. 서로가 서로에게 영향을 주지 않는다.

Generator Expression. 사실 이건 List Comprehension의 Generator 버전이다. LC가 Iterable을 가공하여 리스트를 만들어 준다면, GE는 Generator Iterator를 만들어준다. 당연하지만 각각의 항목은 lazy-evaluation의 대상이 된다. 실제로 값을 배열로 만들어 저장하느냐, 아니면 루프를 돌 때 그때그때 만들어서 가져오느냐의 차이가 있을 뿐이다.


Iterable 클래스의 Generator Iterator

자 이제 드디어 본론이다. 예를 들어 아래와 같은 클래스를 생각해 보자.

class MyClass():
    def __init__(self, length=10):
        self.data = list(range(0, length))
    
    def __iter__(self):
        self.pos = 0
        return self
    
    def __next__(self):
        if self.pos == len(self.data):
            raise StopIteration

        retval = self.data[self.pos]
        self.pos += 1
        
        return retval

instance = MyClass()
for item in instance:
    print(item) # 0, 1, 2, ..., 9

Generator를 이해하기 전, Iterator를 yield를 이용해서 만들어 보려고 하였다. 대충 이런식으로 하면 될 줄 알았지.

class MyClass():
    def __init__(self, length=10):
        self.data = list(range(length))
    
    def __iter__(self):
        return self
    
    def __next__(self):
        for i in self.data:
            yield i

instance = MyClass()
for item in instance:
    print(item) # generator object, generator object, ..., ..., ..., ...

여기에서 꽉 막혔다. 대체뭐지. 왜 값이 안나오고 엉뚱한 객체만 무한히 튀어나오는거지?

위 코드를 좀 풀어서 쓰면 이렇게 된다.

instance = MyClass()
try:
    iterator_of_instance = iter(instance) # call instance.__iter__()
    while True:
        item = next(iterator_of_instance) # call instance.__next__()
        print(item)
except StopIteration:
    # we arrived the last of iterator
    pass

next(iterator) 부분이 문제인데, 위 구현을 보면 알겠지만 __next__() 메서드가 yield를 포함하는 Generator function이다. 따라서 이 메서드는 Generator Iterator만 던져줄 수 있을 뿐, 혼자서는 값을 생성하지 못하는 반편이라... 그러니 Generator Iterator만 무한히 나올 뿐 실제로 값은 받아볼 수가 없는 것이다.

그럼 이런 구현방식이 제대로 작동하려면 어떻게 짜야 할까. 질문을 조금 바꿔보자. 어디에서 Generator Iterator를 던져줘야 제대로 작동할까. 아까 Iterator는 __iter__() 메서드로 얻을 수 있다고 하였다. 따라서 Generator function이 되어야 하는 것은 __next__() 가 아니고 __iter__() 이며, 아이템을 얻어오는 실제 코드는 __iter__() 내부에 존재해야 한다.

class MyClass():
    def __init__(self, length=10):
        self.data = list(range(0, length))
    
    def __iter__(self):
        for i in self.data:
            yield i

    # we dont need to implement __next__

instance = MyClass()
for item in instance:
    print(item) # 0, 1, 2, ..., 9
for a, b in zip(instance, instance):
    print(a, b) # (0, 0), (1, 1), (2, 2), ..., (9, 9)

응? __next__()는 만들지도 않았는데 어떻게 작동하는거지? MyClass의 Iterator를 요구하면 __iter__() 메서드를 수행하게 된다. 근데 이 함수는 Generator Function이다. 따라서 Generator Iterator를 만들어서 반환하게 된다. ㅇㅋ, iter에서 Iterator를 반환했으니까 프로토콜에 맞는다.

이제 Generator Iterator의 __next__()가 호출되고, 비로소 __iter__() 메서드의 바디가 수행되면서, yield를 만날 때마다 실제 값이 하나씩 튀어나오게 된다. ㅇㅋ, next에서 값을 하나씩 뱉어내니까 프로토콜에 맞는다. iter 호출할 때마다 새로운 Generator Iterator가 생성되니까, Iterator가 싱글톤 패턴이 되는 문제도 없다. 코드도 한결 말끔해진다. 다 좋다.

단지 왜 Iterator를 반환해야 할 __iter__() 함수 안에 값을 반환하는 코드가 들어있는지 직관적으로 이해가 안 될 뿐이다.