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

[Python] Iterables, Iterators, and Generators

by 별준 2022. 3. 25.

References

  • Fluent Python

Contents

  • Iterables and Iterators
  • Generators and yield
  • Generator Expressions
  • itertools 모듈 (count, takewhile)
  • Generator Functions in the Standard Library
  • yield from
  • Iterable Reducing Functions
  • iter() Function

데이터 처리에서 반복을 기본입니다. 만약 데이터가 메모리에 다 들어가지 않는다면, 각 항목들을 지연(lazily)시켜 가져와야 합니다. 즉, 한 번에 하나씩 그리고 필요할 때 가져와야 합니다. 이것이 바로 이터레이터가 하는 역할입니다. 이번 포스팅에서는 이터레이터 패턴이 파이썬 언어에 어떻게 구현되어 있는지 살펴보도록 하겠습니다.

 

파이썬은 매크로가 없습니다. 그래서 이터레이터 패턴을 추상화할 수 있게 yield 키워드가 2001년 파이썬 2.2에 추가되었습니다. yield 키워드는 이터레이터를 반환하는 제너레이터 함수를 생성할 수 있게 해줍니다.

 

파이썬 3ㅔ서 제너레이터는 많은 곳에서 사용합니다. 예전에는 리스로 반환했던 내장 함수 range()조차도 지금은 제너레이터와 비슷한 객체를 반환합니다. range 타입을 이용해서 list를 생성하려면 명시적으로 list(range(100)) 처럼 작성해야 합니다.

 

파이썬의 모든 컬렉션은 반복형(iterable)이며, 다음과 같은 연산을 지원하기 위해 내부적으로 이터레이터를 사용합니다.

  • for 루프
  • 컬렉션 타입의 생성과 확장
  • 텍스트 파일을 한 줄씩 반복
  • List, dict, and set comprehesions
  • 튜플 언패킹(unpacking)
  • 함수 호출에서 *를 이용한 실제 매개변수 언패킹

 


A Sequence of Words

먼지 Sentence라는 클래스를 구현하면서 이터레이터에 반복형(iterable)에 대해 알아보겠습니다. 이 클래스의 생성자는 텍스트로 구성된 문자열을 받은 후 단어별로 반복할 수 있습니다. 첫 번째 버전은 시퀀스 프로토콜을 구현하며 반복합니다. 이는 모든 시퀀스는 반복할 수 있기 때문입니다. 여기서는 특히 왜 그렇게 되는지 알아보겠습니다.

 

다음 코드는 인덱스를 사용해서 텍스트에서 단어를 추출하는 Sentence 클래스입니다.

import re
import reprlib

RE_WORD = re.compile('\w+')

class Sentence:
    def __init__(self, text):
        self.text = text
        # re.findall()은 정규표현식에 매칭되는 중복되지 않은 문자열의 리스트를 반환
        self.words = RE_WORD.findall(text)
    
    def __getitem__(self, index):
        # self.words가 findall()의 결과를 담고 있으므로, 주어진 인덱스에 해당하는 단어를 반환
        return self.words[index]
    
    # 시퀀스 프로토콜을 따르려면 __len__() 메소드도 구현해야 되지만,
    # Iterable 객체에 이 메소드가 필요한 것은 아님
    def __len__(self):
        return len(self.words)
    
    def __repr__(self):
        # reprlib.repr()은 유틸리티 함수로서, 매우 큰 데이터 구조체를 표현하는 문자열을 축약하여 생성
        return 'Sentence(%s)' % reprlib.repr(self.text)

위에서 사용된 reprlib.repr()은 생성할 문자열을 30자로 제한합니다. 다음 예제 코드는 세션에서 Sentence를 사용하는 방법을 보여줍니다.

 

아래에서는 위의 예제 코드를 통과하는 다른 버전의 Sentence 클래스를 구현해볼 예정입니다. 하지만, 현재 버전의 Sentence의 구현은 시퀀스로서 다른 버전과는 조금 다르며, 다음과 같이 인덱스를 이용해서 단어를 가져올 수 있습니다.

 

Why Sequences Are Iterable: The iter Function

파이썬을 다룰 줄 안다면 시퀀스는 반복할 수 있다는 것을 알고 있을 것입니다. 이제 그 이유를 알아보도록 하겠습니다.

 

파이썬 인터프리터가 x 객체를 반복해야 할 때는 언제나 iter(x)를 자동으로 호출합니다.

여기서 iter() 내장 함수는 다음 과정을 수행합니다.

  1. 객체가 __iter__() 메소드를 구현하는지 확인하고, 이 메소드를 호출해서 이터레이터를 가져온다.
  2. __iter__() 메소드가 구현되어 있지 않지만 __getitem__()이 구현되어 있다면, 파이썬은 인덱스 0에서 시작해서 항목을 순서대로 가져오는 이터레이터를 생성한다.
  3. 이 과정이 모두 실패하면 파이썬은 'TypeError: 'C' object is not iterable'이라는 메세지와 함께 TypeError가 발생한다. 여기서 C는 대상 객체의 클래스이다.

이러한 이유로 모든 파이썬 시퀀스는 반복할 수 있습니다. 시퀀스가 __getitem__()을 구현하고 있기 때문입니다. 사실 표준 시퀀스는 __iter__() 메소드도 구현하고 있으므로 우리가 정의한 시퀀스도 이 메소드를 구현해야 합니다. __getitem__()을 특별히 다루는 이유는 하위 버전과의 호환성을 유지하기 위함이며, 미래에는 사라질 수도 있습니다.

 

이처럼 __iter__() 스페셜 메소드를 구현하는 객체뿐만 아니라 0에서 시작하는 정수형 키를 받는 __getitem__() 메소드를 구현하는 객체도 반복형으로 간주하는 것은 덕 타이핑의 극단적인 형태입니다.

 

구스 타이핑 기법을 사용하면 반복형에 대한 정의가 단순해질 수 있지만, 유연성이 떨어져서 __iter__() 스페셜 메소드를 구현하는 객체만 반복형이라고 간주합니다. abc.Iterable 클래스가 __subclasshook__() 메소드를 구현하고 있으므로 상속이나 등록은 필요 없습니다. 다음 예제 코드를 살펴보겠습니다.

하지만, 위에서 처음 구현한 Sentence 클래스는 반복형으로 사용하고는 있지만 issubclass(Sentence, abc.Iterable) 테스트를 통과하지 못합니다.

파이썬 3.9까지는 객체 x가 반복형인지 확인하는 가장 정확한 방법은 iter(x)를 호출하고 만일에 발생할 수 있는 TypeError를 처리하는 것입니다. 이 방법이 isinstance(x, abc.Iterable)을 사용하는 것보다 더 정확합니다. iter(x)는 __getitem__() 메소드도 확인하는 반면, Iterable ABC는 확인하지 않기 때문입니다.

 

객체를 반복하기 전에 그 객체가 반복형인지 명시적으로 검사하는 것은 필요하지 않습니다. 반복할 수 없는 객체를 반복하려고 시도하면 파이썬이 "TypeError: 'C' object is not iterable"이라는 메시지를 담은 예외를 발생시키기 때문입니다. 예외를 발생시키는 것보다 깔끔하게 처리할 수 있다면 try/except 블록으로 처리하는 것이 좋습니다. 나중에 반복하기 위해 객체에 저장해두는 경우 미리 명시적으로 검사하는 것도 좋습니다.

 


Iterables vs. Iterators

위에서 설명한 내용을 바탕으로 다음과 같은 정의를 도출할 수 있습니다.

  • Iterable (반복형) : iter() 내장 함수가 이터레이터(iterator, 반복자)를 가져올 수 있는 모든 객체와 이터레이터를 반환하는 __iter__() 메소드를 구현하는 객체는 반복형이다. 0에서 시작하는 인덱스를 받는 __getitem__() 메소드를 구현하는 객체인 시퀀스도 마찬가지다.

Iterable과 Iterator의 관계를 명확히 구분하는 것은 중요합니다. 파이썬은 반복형 객체에서 이터레이터를 가져옵니다.

 

다음 코드는 문자열을 반복하는 간단한 for 루프입니다. 여기서 'ABC' 문자열은 반복형입니다. 이터레이터가 보이지 않지만, 내부 어딘가에 있습니다.

for문 대신 while문을 이용해서 직접 이 과정을 따라하려면 다음과 같이 작성해야 합니다.

StopIteration은 이터레이터가 모두 소진되었음을 알려줍니다. 이 예외는 for 루프 및 리스트 컴프리헨션, 튜플 언패킹 등 다른 반복 과정에서는 내부적으로 처리됩니다.

 

이터레이터에 대한 표준 인터페이스는 다음과 같은 메소드 두 개를 정의합니다.

  • __next__() : 다음에 사용할 항목을 반환한다. 더 이상 항목이 남아 있지 않으면 StopIteration을 발생시킨다.
  • __iter__() : self를 반환한다. 그러면 for 루프 등 반복형이 필요한 곳에 이터레이터를 사용할 수 있게 해준다.

이는 __next__() 추상 메소드를 정의하는 collections.abc.Iterable ABC 및 추상 __iter__() 메소드를 정의한 서브클래스 Iterable에 공식화되어 있습니다.

위 그림에서 Iterable의 구상 서브클래스의 __iter__() 메소드는 Iterator 객체를 생성하고 반환해야 하며, Iterator의 구상 서브클래스는 __next__() 메소드를 구현해야 합니다. Iterator.__iter__() 메소드는 self를 반환해야 합니다.

 

Iterable ABC는 return self 문장만으로 __iter__() 메소드를 구현합니다. 이렇게 하면 반복형이 필요한 곳에 이터레이터를 사용할 수 있습니다. abc.Iterable의 소스 코드(link)는 다음과 같습니다.

class Iterator(Iterable):

    __slots__ = ()

    @abstractmethod
    def __next__(self):
        'Return the next item from the iterator. When exhausted, raise StopIteration'
        raise StopIteration

    def __iter__(self):
        return self

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Iterator:
            if (any("__next__" in B.__dict__ for B in C.__mro__) and
                any("__iter__" in B.__dict__ for B in C.__mro__)):
                return True
        return NotImplemented

 

파이썬의 Lib/types.py 모듈 소스 코드(link)에는 다음과 같은 주석이 붙어 있습니다.

즉, 파이썬의 이터레이터는 타입이 아닌 프로토콜이며 다양한 내장 타입들이 이터레이터의 일부를 구현합니다. 여기서 타입을 검사하면 안되며, 대신 hasattr()을 이용해서 '__iter__'와 '__next__' 속성이 있는지 검사하라고 합니다.

사실 이 방식이 abc.Iterable ABC의 __subclasshook__() 메소드가 수행하는 방식입니다.

Lib/types.py 모듈에 들어 있는 주석과 Lib/_collections_abc.py에 구현된 로직을 고려하면, x가 이터레이터인지 확인하는 가장 좋은 방법은 isinstance(x, abc.Iterable)을 호출하는 것입니다. Iterator.__subclasshook__() 메소드 덕분에 이 방법은 x가 Iterator의 실제 서브클래스인 경우와 가상 서브클래스인 경우 모두 제대로 동작합니다.

 

다시 Sentence 클래스로 돌아와서, 이제는 세션에서 iter()로 이터레이터를 생성하고 next()로 항목들을 소비하는 방법을 명확하게 이해할 수 있을 것입니다.

이터레이터가 필수적으로 구현해야 하는 메소드는 __next__()와 __iter__() 밖에 없으므로, next()를 호출하고 StopIteration 예외를 잡는 방법 외에는 항목이 소진되었는지 확인할 방법이 없습니다. 그리고 이터레이터는 'reset'할 수 없습니다. 다시 처음부터 반복해야 한다면 처음 이터레이터를 생성했던 반복형에 iter()를 호출해야 합니다. 이터레이터 자체에 iter()를 호출하는 것은 소용이 없습니다. 앞에서 설명한 것처럼 Iterator.__iter__()는 단지 self를 반환하도록 구현되어 있으므로 소진된 이터레이터는 재설정하지 못합니다.

 

따라서 이터레이터는 다음과 같이 정의내릴 수 있습니다.

  • Iterator(이터레이터) : 다음 항목을 반환하거나, 다음 항목이 없을 때 StopIteration 예외를 발생시키는, 인수를 받지 않는 __next__() 메소드를 구현하는 객체. 파이썬의 이터레이터는 __iter__() 메소드도 구현하므로 반복형이기도 하다.

내장 함수 iter()가 시퀀스에 제공하는 특별한 처리 덕분에 위에서 구현한 Sentence 클래스는 반복형이었습니다. 이제 표준 반복형 프로토콜을 구현해보도록 하겠습니다.

 


Sentence classes with __iter__

Sentence Version #2: A Classic Iterator

Sentence 클래스의 다음 버전은 고전적인 이터레이터 디자인 패턴에 맞춰서 구현합니다. 뒤에 리팩토링하면서 명확해지지만, 이 코드는 파이썬의 관용적인 방법은 아닙니다. 하지만 반복형 컬렉션과 이터레이터 객체 간의 관계를 명확히 정의하는데 큰 도움이 됩니다.

 

아래 구현된 Sentence 클래스가 반복형입니다. __iter__() 스페셜 메소드를 구현하고 있고, 이 메소드가 SentenceIterator를 반환하기 때문입니다. 이 방식이 원래 '디자인 패턴(GoF book)'에서 설명하고 있는 이터레이터 디자인 패턴입니다.

 

여기서는 반복형과 이터레이터의 차이 및 이 둘이 어떻게 연관되는지 명확히 보여주기 위해서 다음고 같이 구현되었습니다.

import re
import reprlib

RE_WORD = re.compile('\w+')

class Sentence:
    def __init__(self, text):
        self.text = text
        self.words = RE_WORD.findall(text)

    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)
    
    # 앞서 구현한 Sentence 클래스에 __iter__() 메소드만 추가
    # __iter__만으로 반복형이 된다는 것을 보여주기 위해 __getitem__은 제거되었다
    def __iter__(self):
        # __iter__()가 이터레이터 객체를 생성해서 반환함으로써 반복형 프로토콜을 완전히 구현
        return SentenceIterator(self.words)

class SentenceIterator:
    def __init__(self, words):
        # SentenceIterator는 단어 리스트에 대한 참조를 담고 있다
        self.words = words
        # 다음에 가져올 단어를 결정하기 위해 self.index를 사용
        self.index = 0
    
    def __next__(self):
        try:
            # self.index에 있는 단어를 가져온다
            word = self.words[self.index]
        except IndexError:
            # self.index에 단어가 없으면 StopIteration 예외를 발생시킨다
            raise StopIteration()
        self.index += 1 # self.index를 증가시킨다
        return word # 단어를 반환한다
    
    # self의 __iter__() 메소드를 구현한다
    def __iter__(self):
        return self

위의 코드는 첫 번째 Sentence가 통과한 테스트를 모두 통과합니다. 단, __getitem__() 메소드는 구현되어 있지 않기 때문에 인덱스로 접근할 수는 없습니다.

이 예제가 동작하는 데에는 사실 SentenceIterator에서 __iter__()를 구현할 필요가 없습니다. 하지만 올바른 구현입니다. 이터레이터는 __next__()와 __iter__() 메소드를 모두 구현해야 하며, 둘 다 구현해야 issubclass(SentenceIterable, abc.Iterator) 테스트를 통과할 수 있습니다. SentenceIterator가 abc.Iterator를 상속하면 구상 메소드인 abc.Iterator.__iter__()를 상속받습니다.

 

아래서 이 코드를 더 짧게 만드는 방법에 대해 알아볼텐데, 그 전에 잘못 구현된 방법을 살펴보도록 하겠습니다.

 

Don't make the iterable an iterator for itself

반복형과 이터레이터를 만드는 데 있어서 흔히 발생하는 오류는 둘을 혼동하기 때문에 발생합니다. 간단히 정리하면, 반복형은 호출될 때마다 반복자를 새로 생성하는 __iter__() 메소드를 가지고 있습니다. 이터레이터는 개별 항목을 반환하는 __next__() 메소드와 self를 반환하는 __iter__() 메소드를 가지고 있습니다.

 

따라서 이터레이터는 반복형이지만, 반복형은 이터레이터가 아닙니다.

 

Sentence 클래스 안에 __iter__() 외에 __next__()도 구현해서 Sentence 객체를 반복형이나 이터레이터로 만들고 싶을 수도 있습니다. 그러나 이는 정말 잘못된 생각입니다.

 

'디자인 패턴'의 이터레이터 디자인 패턴 중 'Applicability' 섹션에서는 다음과 같은 용도로 이터레이터 패턴을 사용하라도 설명하고 있습니다.

  • 집합 객체의 내부 표현을 노출시키지 않고 내용에 접근하는 경우
  • 집합 객체의 다중 반복을 지원하는 경우
  • 다양한 집합 구조체를 반복하기 위한 통일된 인터페이스를 제공하는 경우

다중 반복을 지원하려면 동일한 반복형 객체로부터 여러 독립적인 이터레이터를 가질 수 있어야 하며, 각 이터레이터는 고유한 내부 상태를 유지해야 합니다. 따라서 이 패턴을 제대로 구현하려면 iter(my_iterable)을 호출할 때마다 독립적인 이터레이터가 새로 만들어져야 합니다. 그렇기 때문에 두 번째 Sentence 클래스에서 SentenceIterator가 필요한 것입니다.

 

Sentence Version #3: A Generator Function

동일한 기능을 파이썬스럽게 구현하려면 SequenceIterator 클래스 대신 제너레이터 함수를 사용합니다. 

 

다음 예제 코드를 먼저 살펴보고, 제너레이터 함수에 대해 알아보겠습니다.

import re
import reprlib

RE_WORD = re.compile('\w+')

class Sentence:
    def __init__(self, text):
        self.text = text
        self.words = RE_WORD.findall(text)

    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)
    
    def __iter__(self):
        for word in self.words: # self.words를 반복
            yield word # 현재 단어(word)를 생성한다
        return # 함수가 끝에 도달하면 값을 자동으로 반환하므로, 이 return은 사실 필요없다
               # 제너레이터 함수는 StopIteration도 발생시키지 않고, 값을 모두 생산한 후 그냥 빠져나간다.
    
# 별도의 이터레이터 클래스가 필요없다

여기서 첫 번째 Sentence에서 테스트했던 코드를 통과하는 세 번째 버전의 Sentence 클래스를 구현했습니다.

두 번째 버전에서 Sentence 클래스의 __iter__()는 SentenceIterator() 생성자를 호출해서 이터레이터를 생성하고 반환했습니다. 세 번째 버전의 이터레이터는 사실 제너레이터 객체로서, __iter__() 메소드를 호출할 때 자동으로 생성됩니다. 여기서 __iter__()는 제너레이터 함수이기 때문입니다.

 

How a Generator Works

함수 바디 안에 yield 키워드를 가진 함수는 모두 제너레이터 함수입니다. 제너레이터 함수는 호출되면 제너레이터 객체를 반환합니다. 즉, 제너레이터 함수는 제너레이터 팩토리라고 할 수 있습니다.

일반 함수와 제너레이터 함수는 바디 안 어디에선가 yield 키워드를 사용한다는 구문 차이 밖에 없습니다. 제너레이터 함수는 def 대신 gen과 같은 새로운 키워드를 사용해야 한다고 주장하는 사람도 있지만, 파이썬 창시자 귀도는 동의하지 않았습니다. 그는 PEP 255에서 이 의견을 피력했습니다.

제너레이터의 동작을 잘 보여주는 예는 다음과 같습니다.

제너레이터 함수는 함수 바디를 포함하는 제너레이터 객체를 생성합니다. next()를 제너레이터 객체에 호출하면 함수 본체에 있는 다음 yield로 진행하며, next()는 함수 바디가 중단된 곳에서 생성된 값을 평가합니다. 마지막으로 함수 바디가 반환될 때 이 함수를 포함하고 있는 제너레이터 객체는 Iterator 프로토콜에 따라 StopIteration 예외를 발생시킵니다.

제너레이터에서 가져온 결과에 대해 이야기할 때는 조금 더 명확히 하는 것이 좋은데, 제너레이터는 값을 생성합니다. 하지만 제너레이터가 값을 '반환'한다고 하면 조금 혼란스럽습니다. 함수는 값을 반환합니다. 제너레이터 함수를 호출하면 제너레이터 객체가 반환됩니다. 그리고 제너레이터 객체는 값을 생성합니다. 제너레이터 객체는 일반적인 방식으로 값을 '반환'하지 않습니다. 제너레이터 함수 안에 있는 return 문은 제너레이터 객체가 StopIteration 예외를 발생하게 만듭니다.

 

다음 예제 코드는 for 루프와 함수 바디 간의 상호작용을 좀 더 명확히 보여줍니다.

 

이제 세 번째 버전의 Sentence.__iter__()가 어떻게 동작하는지 조금 명확해졌을 것이라고 생각됩니다. __iter__()는 제너레이터 함수로서, 호출되면 이터레이터 인터페이스를 구현하는 제너레이터 객체를 생성합니다. 따라서 SentenceIterator 클래스가 더 이상 필요하지 않습니다.

 

세 번째 버전의 Sentence 버전의 이전 버전들보다 훨씬 짧지만, 데이터들을 지연시켜 생성하는 것은 아닙니다. 따라서 데이터를 지연시키도록 구현할 수 있습니다. 이러한 지연은 적어도 프로그래밍 언어와 API에서만큼은 좋은 성질이라고 여겨지며, 이렇게 지연되는 것은 가능한 한 최후의 순간까지 값 생산을 연기합니다. 이렇게 함으로써 메모리를 줄일 수 있을 뿐만 아니라 불필요한 처리도 피할 수 있습니다.

 


Lazy Sentences

마지막 버전의 Sentence는 re 모듈로부터 지연 함수(lazy function)의 이점을 가지도록 구현하는 것입니다.

 

Sentence Version #4: Lazy Generator

Iterator 인터페이스는 지연되도록 디자인되어 있습니다. next(my_iterator)는 한 번에 하나의 항목만 생성합니다. lazy evaluation의 반대는 eager evaluation이며, 둘 다 프로그래밍 언어 이론에서 사용되는 용어입니다.

 

지금까지 구현한 Sentence 버전은 Lazy 버전이 아니었습니다. __init__()에서 텍스트 안에 있는 단어들의 리스트를 eager하게 생성해서 self.words 속성에 바인딩하기 때문입니다. 그러므로 전체 텍스트를 처리해야 하며, 리스트는 거의 텍스트와 맞먹는 양의 메모리를 소비합니다. 사용자가 처음 몇 단어만 반복한다면, 이런 연산의 대부분은 필요 없을 것입니다.

 

파이썬 3으로 프로그래밍할 때, '이것을 lazy한 방법으로 처리할 수 없을까?' 라는 질문에는 '그렇다'라고 대답하는 경우가 종종 있습니다.

 

re.finditer() 함수는 re.findall()의 lazy 버전으로, 리스트 대신 필요에 따라 re.MatchObject 객체를 생성하는 제너레이터를 반환합니다. 매칭되는 항목이 많으면 re.finditer()가 메모리를 많이 절약해줍니다. re.finditer()를 사용하는 네 번째 버전의 Sentence 클래스는 필요할 때만 다음 단어를 생성하기 때문에 lazy하게 처리합니다. 구현은 다음과 같습니다.

import re
import reprlib

RE_WORD = re.compile('\w+')

class Sentence:
    def __init__(self, text):
        self.text = text # 단어 리스트를 미리 만들지 않는다

    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)
    
    def __iter__(self):
        # finditer()는 self.text에서 RE_WORD에 대응되는 단어들의 이터레이터인 MatchObject 객체를 생성
        for match in RE_WORD.finditer(self.text):
            # match.group() 메소드는 MatchObject 객체에서 매칭되는 텍스트를 추출한다
            yield match.group()

이처럼 제너레이터 함수는 멋진 방법입니다. 하지만, 제너레이터 표현식을 사용하면 코드를 훨씬 더 짧게 만들 수 있습니다.

 

Sentence Version #5: Lazy Generator Expression

위에서 구현한 Sentence 클래스의 간단한 제너레이터 함수는 제너레이터 표현식으로 바꿀 수 있습니다.

 

제너레이터 표현식은 리스트 컴프리헨션의 lazy 버전이라고 생각할 수 있습니다. 처음부터 전체 리스트를 생성하는 대신, 필요에 따라 항목을 그때그때 생성하는 제너레이터를 반환하기 때문입니다. 즉, 리스트 컴프리헨션이 리스트 팩토리라면 제너레이터 표현식은 제너레이터 팩토리라고 생각할 수 있습니다.

 

다음 코드는 제너레이터 표현식을 리스트 컴프리헨션과 간단히 비교합니다.

결국 제너레이터 표현식은 제너레이터를 생성하고, 제너레이터 표현식을 사용하면 Sentence 클래스의 코드를 더 짧게 만들 수 있습니다.

import re
import reprlib

RE_WORD = re.compile('\w+')

class Sentence:
    def __init__(self, text):
        self.text = text

    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)
    
    def __iter__(self):
        return (match.group() for match in RE_WORD.finditer(self.text))

네 번째 버전과 __iter__() 메소드만 다릅니다. 여기서는 제너레이터 함수가 아니며(yield 키워드가 없음), 제너레이터를 생성해서 반환하는 제너레이터 표현식을 사용합니다. 실행 결과는 네 번째 버전과 마찬가지로 __iter__() 메소드의 호출자가 제너레이터 객체를 받습니다.

 

제너레이터 표현식은 편리 구문(syntactic sugar)으로서, 제너레이터 함수를 대체할 수 있지만 더 편리한 경우도 종종 있습니다. 아래에서는 제너레이터 표현식을 사용하는 방법에 대해 알아보겠습니다.

 


Generator Expressions: When to Use Them

from array import array
import reprlib
import math
import operator
import functools
import itertools
from collections import abc

class Vector:
    # ... 나머지 메소드 생략
    typecode = 'd'

    def __init__(self, components):
        # 'protected' 객체 속성 self._components는 벡터 요소를 배열로 저장
        self._components = array(self.typecode, components)

    def __iter__(self):
        # 반복할 수 있도록 self._components의 반복자를 반환
        return iter(self._components)

    def __repr__(self):
        # self._components를 제한된 길이로 표현하기 위해 reprlib.repr()를 사용
        # array('d', [0.0, 1.0, ...]) 형태로 출력
        components = reprlib.repr(self._components)
        # 문자열을 Vector 생성자에 전달할 수 있도록 문자열 "array('d',"와
        # 마지막 괄호를 제거
        components = components[components.find('['):-1]
        return f'Vector({components})'

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(self._components))  # self._components에서 바로 bytes 객체 생성

    def __eq__(self, other):
        # other 피연산자가 Vector나 Vector의 서브클래스의 객체면 기존과 동일하게 비교
        if isinstance(other, Vector):
            return (len(self) == len(other) and
                    all(a == b for a, b in zip(self, other)))
        else:
            # 그렇지 않으면 NotImplemented 반환
            return NotImplemented
    
    def __hash__(self):
        # 각 요소의 해시를 계산하기 위한 제너레이터 표현식을 생성
        hashes = (hash(x) for x in self._components)
        # xor 함수와 hashes를 전달하여 reduce() 함수 호출, 세 번째 인수 0은 초기값
        return functools.reduce(operator.xor, hashes, 0)

    def __abs__(self):
        # 파이썬 3.8부터 math.hypot은 n차원 포인트를 받을 수 있음, 파이썬 3.8 이전이라면
        # math.sqrt(sum(x * x for x in self)) 로 작성
        return math.hypot(*self)
    
    def __neg__(self):
        # -v를 계산하기 위해 새로운 Vector 객체를 만들고 self의 모든 요소를 반대값으로 채움
        return Vector(-x for x in self)
    
    def __pos__(self):
        # +v를 계산하기 위해 새로운 Vector 객체를 만들고, self의 모든 요소로 채움
        return Vector(self)

    def __bool__(self):
        return bool(abs(self))
    
    def __len__(self):
        return len(self._components)
    
    def __getitem__(self, key):
        if isinstance(key, slice): # if index's type is slice
            # 객체의 클래스(Vector)를 가져옴
            cls = type(self)
            # _components 배열의 슬라이스로부터 Vector 객체 생성
            return cls(self._components[key])
        # if index's type is Integral
        index = operator.index(key)
        # _components에서 해당 항목을 가져와서 반환
        return self._components[index]
    
    # __getattr__에 의해 지원되는 dynamic attributes의 매칭 패턴
    __match_args__ = ('x', 'y', 'z', 't')

    def __getattr__(self, name):
        # Vector 클래스를 가져옴
        cls = type(self)
        try:
            # __match_args__에서 name의 위치를 가져옴
            pos = cls.__match_args__.index(name)
        # name을 찾지 못했을 때 .index(name)는 ValueError를 발생시킴
        except ValueError:
            # name을 찾지 못하면 pos를 -1로 설정
            pos = -1
        # pos가 유효한 인덱스라면 해당 항목 반환
        if 0 <= pos < len(self._components):
            return self._components[pos]
        # 여기까지 도달하면 문제가 발생했다는 것이고, AttributeError를 발생시킴
        msg = f'{cls.__name__!r} object has no attirbute {name!r}'
        raise AttributeError(msg)
    
    def __setattr__(self, name, value):
        cls = type(self)
        if len(name) == 1:
            if name in cls.__match_args__:
                error = 'readonly attribute {attr_name!r}'
            elif name.islower():
                error = "can't set attributes 'a' to 'z' in {cls_name!r}"
            else:
                error = ''
            if error:
                msg = error.format(cls_name=cls.__name__, attr_name=name)
                raise AttributeError(msg)
        super().__setattr__(name, value)

    # 특정 좌표에 대한 각좌표를 계산
    def angle(self, n):
        r = math.hypot(*self[n:])
        a = math.atan2(r, self[n-1])
        if (n == len(self) - 1) and (self[-1] < 0):
            return math.pi * 2 - a
        else:
            return a

    # 모든 각좌표를 계산하는 제너레이터 표현식을 생성
    def angles(self):
        return (self.angle(n) for n in range(1, len(self)))

    def __format__(self, fmt_spec=''):
        if fmt_spec.endwidth('h'):
            fmt_spec = fmt_spec[:-1]
            # itertools.chain() 함수를 이용해서 크기와 각좌표를 차례로 반복하는
            # 제너레이터 표현식 생성
            coords = itertools.chain([abs(self)],
                                    self.angles())
            outer_fmt = '<{}>' # 구면좌표는 꺽쇠괄호를 이용하여 출력
        else:
            coords = self
            outer_fmt = '({})' # 직교좌표는 괄호를 이용하여 출력
        # 좌표의 각 항목을 요구사항에 따라 포맷하는 제너레이터 표현식 생성
        components = (format(c, fmt_spec) for c in coords)
        # 포맷된 요소들을 콤마로 분리하여 반환
        return outer_fmt.format(', '.join(components))

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(memv)  # *를 이용해서 언패킹할 필요없음
    
    def __add__(self, other):
        try:
            # pairs는 self에서 a를, other에서 b를 가져와서 (a, b)를 생성하는 제너레이터
            # self와 other의 길이가 다른 경우에는 짧은 쪽의 빠진 값들을 fillvalue로 채운다
            pairs = itertools.zip_longest(self, other, fillvalue=0.0)
            # pairs 양쪽 항목의 합을 생성하는 제너레이터 표현식을 이용해서 새로운 Vector 생성
            return Vector(a + b for a, b in pairs)
        except TypeError:
            return NotImplemented
    
    def __radd__(self, other):
        # 단지 __add__() 메소드에 처리를 위임한다
        return self + other
    
    def __mul__(self, scalar):
        try:
            factor = float(scalar)
        except TypeError: # 스칼라가 float로 변환되지 않는다면
            return NotImplemented # NotImplemented 반환
        return Vector(n * factor for n in self)
    
    def __rmul__(self, scalar):
        return self * scalar

    def __matmul__(self, other):
        # 두 피연산자는 모두 __len__()과 __iter__()를 구현해야함
        if (isinstance(other, abc.Sized) and
            isinstance(other, abc.Iterable)):
            if len(self) == len(other): 
                # 길이가 같을 때 sum, zip, 제너레이터 표현식으로 내적 계산
                return sum(a * b for a, b in zip(self, other))
            else:
                raise ValueError('@ reuiqres vectors of equal length.')
        else:
            return NotImplemented
    
    def __rmatmul__(self, other):
        return self @ other

위와 같은 Vector 클래스를 구현할 때 제너레이터 표현식을 여러 번 사용했습니다. __eq__(), __hash__(), __abs__(), angle(), angles(), format(), __add__(), __mul__() 메소드가 각각 제너레이터 표현식을 사용합니다. 이들 메소드에서 리스트 컴프리헨션을 사용해도 제대로 작동하겠지만, 그러면 중간 리스트 값들을 저장하기 위해 메모리를 더 많이 사용합니다.

 

마지막 버전의 Sentence 클래스에서 보듯이 제너레이터 표현식은 함수를 정의하고 호출할 필요없이 제너레이터를 생성하는 편리한 구문입니다. 반면 제너레이터 함수는 유연성이 더 높습니다. 여러 문장으로 구성된 복잡한 로직을 구현할 수 있고, 심지어 코루틴(coroutines)으로 사용할 수도 있습니다.

 

로직이 간단한 경우에는 제너레이터 표현식으로도 충분하며, Vector 클래스처럼 한 눈에 보기에도 더 쉽습니다.

 

이 두 가지 방식 중에 어떤 것을 선택할 것인가에 대한 저자의 규칙은 간단합니다. 제너레이터 표현식이 여러 줄에 걸쳐 있을 때는 가독성을 위해 제너레이터 함수를 사용합니다. 게다가 제너레이터 함수는 이름을 가지고 있으므로 재사용할 수도 있습니다.

 

지금까지 본 Sentence 예제는 전통적인 이터레이터의 역할을 하는 제너레이터의 예제입니다. 즉 컬렉션에서 항목들을 꺼내오는 역할을 합니다. 그러나 제너레이터는 데이터 출처에 무관하게 값을 생성하기 위해 사용할 수도 있습니다. 아래에서는 이런 사례들에 대해 살펴보겠습니다.

 


Arithmetic Progression Generator

전통적인 이터레이터 패턴은 모두 데이터 구조체를 뒤져서 항목들을 나열하기 위한 것입니다. 그러나 수열에서 다음 항목을 가져오는 메소드에 기반한 표준 인터페이스는 컬렉션에서 항목을 가져오는 대신 실행 도중에 항목을 생성하는 경우에도 유용하게 사용할 수 있습니다. 예를 들어 내장 함수 range()는 정수로 구성된 유한 등차수열을 생성하며, itertools.count() 함수는 무한 등차수열을 생성합니다.

 

특정 자료형의 숫자로 구성된 유한 등차수열을 생성하려면 어떻게 해야 할까요?

 

아래 예제 코드는 잠시 뒤에 살펴볼 ArithmeticProgression 클래스를 콘솔에서 테스트한 결과를 보여줍니다. 이 예제에서 사용한 생성자 시그니처는 ArithmeticProgression(begin, step[, end]) 입니다. range() 함수가 여기에서 사용한 ArithmeticProgression 클래스와 비슷하지만, range() 함수의 전체 시그니처는 range(start, stop[, step]) 입니다. 등차수열에서는 step이 필수고, end가 선택지이므로 여기서는 다른 시그니처를 사용했습니다. 아래 예제 코드는 테스트할 때마다 생성된 값들을 조사하기 위해 반환된 결과에 list() 생성자를 적용했습니다.

등차수열로 생성된 숫자들의 자료형이 파이썬 산술의 수치형 강제 변환 규칙에 의해 begin이나 step의 자료형을 따름에 주의합니다. 위 예제에서는 int, float, Fraction, Decimal 타입 숫자들의 리스트를 보여주고 있습니다.

 

다음 코드는 위의 동작을 수행하는 ArithmeticProgression 클래스를 구현한 것입니다.

class ArithmeticProgression:
    def __init__(self, begin, step, end=None):
        self.begin = begin
        self.step = step
        self.end = end # None이면 무한수열
    
    def __iter__(self):
        # self.begin과 같은 값이 되지만, 이후에 더할 값에 맞춰 자료형을 강제 변환
        result = type(self.begin + self.step)(self.begin)
        forever = self.end is None
        index = 0
        while forever or result < self.end:
            yield result # 현재 result를 생성
            index += 1
            result = self.begin + self.step * index # 다음에 가져올 result를 미리 계산

위 코드의 마지막 줄에서는 실수로 작업할 때의 오차 누적을 줄이기 위해 단순히 result 값에 self.step만큼 증가시키는 대신 index 값을 self.step에 곱해서 self.begin에 더했습니다. 이렇게 구현한 ArithmeticProgression 클래스는 원하는 대로 동작하며, __iter__() 스페셜 메소드를 구현하는 제너레이터 함수를 사용하는 예를 잘 보여줍니다. 그러나 이 클래스의 목적이 __iter__()를 구현함으로써 제너레이터를 생성하는 것이었다면, 클래스를 단지 하나의 제너레이터 함수로 만들 수도 있었을 것입니다. 결국 제너레이터 함수도 일종의 제너레이터 팩토리이기 때문입니다.

 

다음 코드는 더 짧은 코드로 ArithmeticProgression 클래스와 동일한 작업을 수행하는 aritprog_gen() 이라는 제너레이터 함수를 구현합니다. ArithmeticProgression() 대신 aritprog_gen()을 호출하면 위의 테스트 코드를 모두 통과합니다.

def aritprog_gen(begin, step, end=None):
    result = type(begin + step)(begin)
    forever = end is None
    index = 0
    while forever or result < end:
        yield result
        index += 1
        result = begin + step * index

위 코드도 상당히 괜찮지만, 표준 라이브러리에는 바로 사용할 수 있는 제너레이터가 아주 많습니다. 아래에서는 itertools 모듈을 이용해서 훨씬 더 멋지게 구현하느 방법에 대해서 알아보겠습니다.

 

 

Arithmetic Progression with itertools

(파이썬 3.9 기준) itertools 모듈에는 다양하고 재미있게 조합할 수 있는 19개의 제너레이터 함수가 있습니다.

 

예를 들어, itertools.count() 함수는 숫자를 생성하는 제너레이터를 반환합니다. 인수를 지젛아지 않으면 0에서 시작하는 수열을 생성합니다. 그러나 start와 stop 인수를 지정하면 앞에서 구현한 aritprog_gen() 함수와 아주 비슷한 결과를 낼 수 있습니다.

그러나 itertools.count()는 끝이 없습니다. 따라서 list(count())를 실행하면, 파이썬 인터프리터는 사용할 수 있는 메모리보다 더 큰 리스트를 만드려고 시도하면서 잠시 뒤 실패합니다.

 

그리고 itertools.takewhile() 이라는 함수도 있습니다. 이 함수는 다른 제너레이터를 소비하면서 주어진 조건식(predicate)이 False가 되면 중단되는 제너레이터를 생성합니다. 이 두 개의 제너레이터를 결합해서 다음과 같이 구현할 수 있습니다.

다음 예제 코드는 takewhile()과 count()를 활용해서 위에서 구현한 aritprog_gen()을 더 짧고 멋지게 구현합니다.

import itertools

def aritprog_gen(begin, step, end=None):
    first = type(begin+step)(begin)
    ap_gen = itertools.count(first, step)
    if end is not None:
        ap_gen = itertools.takewhile(lambda n: n < end, ap_gen)
    return ap_gen

위의 aritprog_gen()은 바디 내부에서 yield 문이 없으므로 제너레이터 함수는 아닙니다. 그러나 제너레이터를 반환하므로 다른 제너레이터 함수와 마찬가지로 일종의 제너레이터 팩토리처럼 동작합니다.

 

이 예제를 통해서 말하고자 하는 것은 제너레이터를 구현할 때 표준 라이브러리에서 어떤 것이 제공되고 있는지 확인하라는 것입니다. 표준 라이브러리를 확인하지 않으면 기존에 구현된 것을 다시 구현하게 될 수도 있습니다. 

 


Generator Functions in the Standard Library

텍스트 파일 객체에서 텍스트를 한 줄씩 반복할 수 있게 해주는 것부터 for 루프만큼 간단한 재귀적인 파일 검색을 쉽게 구현하여 디렉토리 트리를 탐색해 파일들의 이름들을 생성하는 os.walk() 함수에 이르기까지 표준 라이브러리는 많은 제너레이터를 제공합니다.

 

Filtering Generator Functions

아래 표는 필터링 제너레이터 함수들을 나열하고 있습니다. 이 함수들은 반복형을 그대로 사용해서 생성된 항목들의 일부를 생성합니다. 위에서 itertools.takewhile()을 사용했었습니다. takewhile()과 마찬가지로 나열된 대부분의 함수는 조건식을 받습니다. 이 조건식은 인수를 하나 받는 boolean 함수로서 입력된 반복형 항목마다 적용해서 출력할 항목을 결정합니다.

Module Function Description
itertools compress(it, selector_it) 두 개의 반복형을 병렬로 소비한다. selector_it의 해당 항목이 참일 때마다 it에서 항목을 생성한다.
itertools dropwhile(predicate, it) predicate가 True인 값들을 스킵하다가, 처음 False인 값부터 추가 검사없이 남아 있는 항목들을 생성
(built-in) filter(predicate, it) predicate를 it의 각 항목에 적용해서 predicate(it)이 True라면 각 항목을 생성. predicate가 None이면 True인 값들을 모두 생성
itertools filterfalse(predicate, it) filter()와 같지만 반대의 로직을 적용. predicate로 False인 값들을 모두 생성한다.
itertools islice(it, stop)
or islice(it, start, stop, step=1)
s[:stop]이나 s[start:stop:step]과 비슷하게, 반복할 수 있는 모든 객체에 lazy 연산을 적용해서 it의 슬라이스 항목을 생성
itertools takewhile(predicate, it) predicate가 True로 계산되는 동안 모든 항목을 생성하고, False를 만나면 멈춘다.

다음 예제 코드는 위의 함수들의 사용 예시를 보여줍니다.

 

Mapping Generator Functions

다음 표에 나열된 함수들은 매핑 제너레이터로서, 입력한 반복형에 들어 있는 각 항목에 연산을 수행한 결과를 생성합니다. 아래 제너레이터 함수들은 입력된 반복형 안에 항목 하나마다 값 하나를 생성하고, 두 개 이상의 반복형을 받는 경우 반복형 중 하나라도 소진되면 바로 출력을 중단합니다.

Module Function Description
itertools accumulate(it, [func]) 누적된 합계를 구한다. func을 제공하면, 처음 두 개의 항목에 func을 적용한 결과를 첫 번째 값으로 생성하며 it을 반복한다.
(built-in) enumerate(it, start=0) (인덱스, 항목) 형태의 튜플을 생성한다. 인덱스는 start부터 시작한다.
(built-in) map(func, it1, [it2, ..., itN]) func을 각 it에 적용해서 결과를 생성한다. N개의 반복형이 주어지는 경우, func은 N개의 인수를 받아야 하며, N개의 반복형을 병렬로 소모한다.
itertools startmap(func, it) it의 각 항목에 func을 적용해서 결과를 생성한다. 입력된 it는 iit을 생성하고, func은 func(*iit) 형태로 호출된다.

아래 예제 코드는 itertools.accumulate()의 사용 예시를 보여줍니다.

다음은 나머지 함수 예제입니다.

 

Merging Generator Functions

다음 표는 머지 제너레이터를 나열하고 있습니다. 여기에 속한 함수는 여러 반복형을 입력받아서 항목을 생성합니다. chain()과 chain.from_iterable() 제너레이터는 입력받은 반복형을 순차적으로 소비하는 반면, product(), zip(), zip_longest() 제너레이터는 입력받은 반복형을 병렬로 소비합니다.

Module Function Description
itertools chain(it1, ..., itN) it1의 모든 항목을 생성한 후, 나머지 반복형을의 항목을 차례대로 생성
itertools chain.from_iterable(it) it에서 생성된 반복형 객체의 모든 항목을 생성한다. it이 생성한 항목은 반복할 수 있어야 한다 (ex, 반복형의 리스트)
itertools product(it1, ..., itN, repeat=1) 데카르트 곱을 계산한다. 각 it의 항목을 이용해서 중첩된 for 루프가 생성하듯이 N-튜플을 생성한다. repeat는 it이 두 번 이상 소비되도록 허용한다.
(built-in) zip(it1, ..., itN) 각 it의 항목을 병렬로 소비해서 N-튜플을 생성한다. 어느 하나의 it이 소모되면 중단한다.
itertools zip_longest(it1, ..., itN, fillvalue=None) 각 it의 항목을 병렬로 소비해서 N-튜플을 생성한다. 가장 긴 it 기준으로 항목을 소모하며, 빈 값들은 fillvalue로 채워가며 생성한다.

다음 예제 코드는 itertools.chain()과 zip() 및 이와 유사한 제너레이터의 사용 예를 보여줍니다. zip()은 압축 알고리즘과 상관이 없습니다.

itertools.product()는 데카르트 곱을 지연해서 계산합니다. 여러 for 구문을 사용한 제너레이터 표현식도 데카르트 곱을 지연시켜 계산할 수 있습니다. 아래 예제 코드는 itertools.product()의 사용 예를 보여줍니다.

 

Expansion Generator Functions

입력된 항목 하나마다 하나 이상의 값을 생성하는 제너레이터 함수도 있습니다. 다음 표에 이러한 동작을 수행하는 함수들이 나열되어 있습니다.

Module Function Description
itertools combinations(it, out_len) it으로 생성된 항목에서 out_len개의 조합을 생성
itertools combinations_with_replacement(it, out_len) 반복된 항목들의 포함을 포함해서, it로 생성된 항목에서 out_len개의 조합을 생성
itertools count(start=0, step=1) start에서 시작해서 step만큼 증가시키며 숫자를 무한히 생성
itertools cycle(it) 각 항목의 사본을 저장한 후, 항목을 무한히 반복
itertools permutations(it, out_len=None) it으로 생성된 항목에서 out_len개의 항목의 조합을 생성. 기본적으로 out_len은 len(list(it))이다.
itertools repeat(item, [times] times를 지정하면 times만큼, 아니면 주어진 item을 무한히 반복해서 생성

itertools의 count()와 repeat() 함수는 반복형없이 항목을 만들어내는 생성자를 반환합니다. 둘 다 반복형을 입력받지 않습니다. 특히 itertools.count()는 위에서 등차수열을 생성할 때 살펴봤었습니다. cycle() 제너레이터는 입력받은 반복형의 사본을 저장해서 항목을 무한히 반복합니다. 다음 예제 코드는 count(), repeat(), cycle()의 사용 예를 보여줍니다.

 

combinations(), combinations_with_replacement(), permutations() 제너레이터 함수(product()와 함께)는 itertools 문서 페이지에서 순열 조합 제너레이터(combinatoric generator)라고 부릅니다. 아래 예제 코드에서 보는 것처럼 itertools.product()와 나머지 순열 조합 함수는 밀접히 연관되어 있습니다.

 

Rearranging Generator Functions

아래 표에 나열된 제너레이터 함수들은 입력받은 반복형 안의 항목의 순서를 변경해서 모든 항목을 생성합니다. itertools.groupby()와 itertools.tee() 함수는 여러 개의 제너레이터를 생성합니다. 이 그룹에서 reversed() 내장 함수는 이 절에서 설명하는 제너레이터 중 유일하게 반복형이 아니라 시퀀스만 받습니다. reversed()가 뒤에서부터 항목을 생성하므로 길이를 알 수 있는 시퀀스에 대해서만 동작할 수 있기 때문입니다. 그러나 필요에 따라 항목을 생성할 수 있으므로 cost를 피하기 위해 reverse 시퀀스를 생성하지 않습니다.

Module Function Description
itertools groupby(it, key=None) (<key>, <group>)의 튜플을 생성한다. 이때 key는 그룹화 기준, group은 그룹 안의 항목을 생성하는 제너레이터이다.
(built-in) reversed(seq) seq안의 항목을 뒤에서부터 역순으로 생성한다. seq는 시퀀스이거나 __reversed__ 스페셜 메소드를 구현해야 한다.
itertools tee(it, n=2) n개의 제너레이터로 구성된 튜플을 하나 생성한다. 각 제너레이터는 입력된 it을 독립적으로 생성한다.

아래 예제 코드는 itertools.groupby()와 내장 함수 reversed()의 사용 예를 보여줍니다. itertools.groupby()는 입력받은 반복형이 그룹화 기준에 따라 정렬되어 있거나, 정렬되어 있지 않더라도 군집화되어 있다고 가정합니다.

 

iterator.tee()는 조금 독특하게 동작합니다. iterator.tee()는 입력된 하나의 반복형에 대한 여러 제너레이터를 생성하고, 각 제너레이터는 독립적으로 항목들을 반복합니다. 아래 코드에서처럼 각 제너레이터는 독립적으로 소비할 수 있습니다.

 


Subgenerators with yield from

yield from 문법은 Python 3.3에서 도입되었으며, 제너레이터가 서브제너레이터의 작업을 위임할 수 있도록 해줍니다.

다음 예제 코드를 살펴보겠습니다.

예제서 보듯이, gen은 delegating generator이고 sub_gen은 subgenerator입니다. yield from은 gen을 멈추고, sub_gen을 실행시킵니다. sub_gen에서 생성된 값을 gen을 통해 for 루프로 전달됩니다. 반면 gen은 정지되어 있고, 이 값이 지나가는 것을 보지 못합니다. sub_gen이 끝나면, gen은 다시 실행됩니다.

 

표현식 안에서 사용될 때, yield from의 값을 서브제너레이터의 리턴값입니다. 다음 예제 코드를 살펴보겠습니다.

sub_gen() 실행을 마치고 리턴된 값이 result에 할당됩니다.

 

Reinventing chain

yield from가 도입되기 전에, 다른 제너레이터로부터 생성된 값을 제너레이터가 생성해야 할 때는 중첩된 for 루프를 사용하는 방법 뿐이었습니다.

예제 코드는 다음과 같습니다. itertools 모듈은 여러 반복형으로부터 항목을 생성하는 chain 제너레이터가 있는데, 이 함수를 중첩된 for 루프를 사용하여 수작업으로 구현한 것입니다.

위의 chain() 제너레이터 함수는 입력받은 각각의 반복형 it에 차례로 위임되고, 내부 for 루프에서 각 it이 반복됩니다. 내부 루프는 다음과 같이 yield from 표현식으로 대체될 수 있습니다.

이 예제에서 yield from의 사용을 잘 보여주고, 코드의 가독성도 증가했습니다. 하지만 단순히 편의 구문으로만 보일 수 있습니다. 아래에서는 조금 더 흥미로운 예제를 살펴보겠습니다.

 

Traversing a tree

이번에는 yield from을 스크립트에서 사용해서 tree 구조를 탐색하는 것을 살펴보겠습니다.

 

예제로 살펴볼 트리 구조는 파이썬의 계층구조(link) 입니다. 하지만, 이를 디렉토리 트리나 다른 구조에도 쉽게 적용할 수 있습니다.

 

0레벨의 BaseException부터 시작해서 예외 계층구조는 5-level까지 내려옵니다. 먼저 0-level 계층을 보도록 하겠습니다. 아래 코드에서 tree 제너레이터는 예외의 이름을 생성(yield)하고 멈춥니다.

# tree.py

def tree(cls):
    yield cls.__name__

def display(cls):
    for cls_name in tree(cls):
        print(cls_name)

if __name__ == '__main__':
    display(BaseException)

이 스크립트를 실행하면 'BaseException'을 출력합니다.

 

다음으로 1-level 계층을 출력해보도록 하겠습니다. tree 제너레이터는 루트 클래스의 이름과 바로 아래 서브클래스의 이름을 출력할 것입니다. 

def tree(cls):
    # 들여쓰기를 적용하기 위해, 클래스 이름과 계층 레벨을 생성
    yield cls.__name__, 0
    # __subclasses__() 스페셜 메소드를 사용해 서브클래스의 리스트를 얻음
    for sub_cls in cls.__subclasses__():
        # 1 레벨 계층의 클래스 이름 생성
        yield sub_cls.__name__, 1

def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')

if __name__ == '__main__':
    display(BaseException)

출력 결과는 다음과 같습니다.

 

방금 살펴본 코드를 리팩토링하여 루트 클래스를 특별한 케이스로 서브클래스와 분리하도록 하겠습니다. 서브클래스는 이제 sub_tree 제너레이터를 통해 처리되며, yield from을 사용하여 tree 제너레이터를 정지시키고, sub_tree가 계속해서 값을 생성하도록 합니다.

def tree(cls):
    yield cls.__name__, 0
    # sub_tree에게 위임
    yield from sub_tree(cls)

def sub_tree(cls):
    for sub_cls in cls.__subclasses__():
        # 서브클래스의 이름과 레벨 1을 생성
        yield sub_cls.__name__, 1   

def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')

if __name__ == '__main__':
    display(BaseException)

 

이제 2 레벨 계층까지 출력하도록 하겠습니다. 이는 sub_tree에서 중첩된 for 루프를 사용하여 출력하도록 할 수 있습니다.

def tree(cls):
    yield cls.__name__, 0
    # sub_tree에게 위임
    yield from sub_tree(cls)

def sub_tree(cls):
    for sub_cls in cls.__subclasses__():
        yield sub_cls.__name__, 1
        for sub_sub_cls in sub_cls.__subclasses__():
            yield sub_sub_cls.__name__, 2

def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')

if __name__ == '__main__':
    display(BaseException)

출력 결과는 다음과 같습니다.

이렇게 중첩 for 루프로 하위 서브클래스들을 출력할 수 있지만, yield from을 사용하면 더욱 간결하게 코드를 작성할 수 있습니다. sub_tree가 level 파라미터를 받고, yield from으로 sub_tree를 재귀적으로 호출하도록 다음과 같이 작성하면 됩니다.

def tree(cls):
    yield cls.__name__, 0
    yield from sub_tree(cls, 1)

def sub_tree(cls, level):
    for sub_cls in cls.__subclasses__():
        yield sub_cls.__name__, level
        yield from sub_tree(sub_cls, level+1)

def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')

if __name__ == '__main__':
    display(BaseException)

이제 파이썬의 재귀 호출 제한이 걸리지만 않는다면 어떤 depth까지 모두 탐색할 수 있습니다. 기본적으로 파이썬은 1000 pending 함수까지 허용합니다.

 

위의 코드로도 충분하지만 조금 더 코드를 간략하게 만들 여지가 있습니다. 바로 sub_tree를 삭제하고 그 내용을 tree에 적용하면 됩니다. 다시 리팩토링하면 다음과 같이 작성할 수 있습니다.

def tree(cls, level=0):
    yield cls.__name__, level
    for sub_cls in cls.__subclasses__():
        yield from tree(sub_cls, level+1)

def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')

if __name__ == '__main__':
    display(BaseException)

위 코드를 실행하면 다음과 같이 출력됩니다.

 


Iterable Reducing Functions

아래의 표에 나열된 함수들은 모두 반복형을 입력받아 하나의 값을 반환합니다. 이 함수는 흔히 'reducing', 'folding', 또는 'accumulating' 함수라고 합니다. 사실 여기에 나열된 함수는 모두 functools.reduce() 함수로 구현할 수 있지만, 자주 발생하는 특정 문제를 쉽게 처리하기 때문에 별도의 내장형 함수로 존재합니다. 그리고 all()과 any()는 단락 평가(short-circuit evaluation) 함수로서, reduce()로 최적화할 수 없습니다. 단락 평가를 하는 경우에는 결과가 확정되는 즉시 이터레이터 소비를 중단합니다.

 

Module Function Description
(built-in) all(it) it의 모든 항목이 True면 True를, 아니면 False를 반환.
all([])은 True를 반환한다.
(built-in) any(it) it의 항목들 중 하나라도 True라면 True를, 아니면 False를 반환.
any([])는 False를 반환한다.
(built-in) max(it, [key=,] [default=]) it의 항목들 중 최댓값을 반환한다. key는 sorted()에서 사용하는 정렬 함수와 동일한 함수며, it이 비어 있을 때는 default로 반환한다. max(arg1, arg2,..., [key=]) 형태로도 호출 가능. 이때는 인수들 중 최댓값이 반환된다.
(built-in)
min(it, [key=,] [default=]) max와 동일하면 최솟값을 반환한다.
functools reduce(func, it, [initial]) 처움 두 개의 항목에 func을 적용하고, 그 결과와 세 번째 항목에 또 func을 적용하는 과정을 반복한 결과를 반환한다. initial이 주어지면 initial과 첫 항목에 func을 적용하면서 시작한다.
(built-in)
sum(it, start=0) it 항목의 합계에 선택적인 start 값을 더한 값을 반환한다. 실수형의 경우 math.fsum()을 사용하면 정밀도가 향상된다.

 

아래 예제 코드는 all()과 any()의 사용 예를 보여줍니다.

 

sorted()도 반복형을 입력받아서 정렬된 것을 반환하는 내장 함수입니다. sorted()는 제너레이터 함수인 reversed()와 달리 실제 리스트를 반들어서 반환합니다. 어쨋든 입력된 반복형의 항목을 모두 읽어야 정렬할 수 있고, 정렬이 리스트 안에서 발생하므로, sorted()는 정렬을 완료한 후 그 리스트를 바로 반환합니다.

 

물론 sorted()와 리듀스 함수는 유한 반복형에만 사용할 수 있으며, 그렇지 않으면 항목을 계속 수집하며 결과를 반환하지 못합니다.

 


A Closer Look at the iter Function

지금까지 본 것처럼 파이썬은 어떤 객체 x를 반복해야 할 때 iter(x)를 호출합니다.

 

그러나 이 함수는 일반 함수나 콜러블 객체로부터 이터레이터를 생성하기 위해 두 개의 인수를 전달해서 호출할 수도 있습니다. 이렇게 사용하려면, 첫 번째 인수는 값을 생성하기 위해 인수없이 반복적으로 호출되는 콜러블이어야 하며, 두 번째 인수는 구분 표시(sentinel)로서, 콜러블에서 이 값이 반환되면 이터레이터가 StopIteration 예외를 발생시키도록 만듭니다.

 

다음 예제는 1이 나올 때까지 육면체 주사위를 굴리기 위해 iter() 함수를 사용하는 방법을 보여줍니다.

여기서 iter() 함수가 callable_iterator 객체를 반환함에 주의해야 합니다. 예제 안에 있는 for 루프는 아무리 오래 실행하더라도 결고 1을 출력하지 않습니다. 1이 구분 표시이기 때문입니다. 이터레이터와 마찬가지로 d6_iter 객체는 일단 소모하고 난 후에는 쓸모가 없어집니다. 다시 시작하려면 iter() 함수를 한 번 더 호출해서 이터레이터를 다시 만들어야 합니다.

 

iter()의 문서(link)에서 유용한 예제를 볼 수 있습니다. 다음 코드는 파일에서 빈 줄을 발견하거나 파일의 끝에 도달할 때까지 한 줄씩 읽어서 처리합니다.

from functools import partial
with open('mydata.db', 'rb') as f:
    for block in iter(partial(f.read, 64), b''):
        process_block(block)

 

댓글