[Python] Iterables vs. Iterators vs. Generators

파이썬의 기본 컨셉 중 빈번히 헷갈리는 Iterables, Iterators, Generators를 잘 정리한 글이 있어 번역 및 나름대로 정리를 해 보았습니다. 번역이 이상하거나 틀린 부분이 있으면 댓글 부탁드립니다.



Python에는 아래의 컨셉이 있고 그 차이에 대해 빈번히 헷갈리곤 합니다.

  • a container
  • an iterable
  • an iterator
  • a generator
  • a generator expression
  • a {list, set, dict} comprehension

이 글에서는 위의 컨셉에 대해 정리해 보려고합니다.




Containers

Container는 여러 원소들을 가지고 있는 자료구조로서 Membership test를 지원합니다. 이 자료구조는 메모리에 상주하며 그 값 또한 메모리에 상주합니다.

Python에서 잘 알려진 Container는 아래와 같습니다.

  • list, deque, …
  • set, frozensets, ...
  • dict, defaultdict, OrderedDict, Counter, …
  • tuple, named tuple, …
  • str

Container는 박스나 집, 화물과 같은 실제 세계에 존재하는 Container와 같기 때문에 이해하기 쉽습니다.

기술적으로, 특정 element를 포함하고 있는지 확인 할 수 있으면 그 Object는 container입니다. 우리는 아래와 같이 위의 자료구조에 대해 membership test를 수행할 수 있습니다.

>>> assert 1 in [1, 2, 3] # lists
>>> assert 4 not in [1, 2, 3]
>>> assert 1 in {1, 2, 3} # sets
>>> assert 4 not in {1, 2, 3}
>>> assert 1 in (1, 2, 3) # tuples
>>> assert 4 not in (1, 2, 3)

Dict의 경우에도 Key들에 대해 membership test를 수행할 수 있습니다.

>>> d = {1: 'foo', 2: 'bar', 3: 'qux'}
>>> assert 1 in d
>>> assert 4 not in d
>>> assert 'foo' not in d # 'foo' is not a _key_ in the dict

마지막으로 String또한 membership test를 수행 할 수 있는데, 이는 substring에 대해 테스트를 수행 합니다.

>>> s = 'foobar'
>>> assert 'b' in s
>>> assert 'x' not in s
>>> assert 'foo' in s # a string "contains" all its substrings

마지막 예시의 경우에는 조금 이상하지만, container interface가 object를 opaque하게 렌더한다는 것을 볼 수 있습니다. string은 모든 substring의 복사본을 메모리에 저장하지 않지만, 저 방식으로 실행할 수 있습니다.

NOTE:
대부분의 container들이 자신이 가진 모든 원소를 produce할 수 있는 방법을 제공하지만, 이 기능은 container의 기능이 아닌 iterable의 기능입니다.

모든 container들은 iterable하지 않습니다. 그 예로 Bloom filter가 있습니다. 이 같은 확률적인 자료구조는 특정 원소를 가지고 있는지 확인 할 수 있지만, 각각의 개별 element는 반환하지 못합니다.


Iterables

앞서 말했듯이, 대다수의 container는 iterable합니다. 그러나 이 뿐만 아니라 iterable한 더 많은 것들이 있습니다. 그 예로 "open files"나 “open sockets"가 있습니다. container가 유한할 경우에도, iterable은 무한한 data source로 나타낼 수 있습니다.


Iterable은 모든 element를 반환할 목적으로 iterator를 반환할 수 있는 object입니다. 꼭 자료구조일 필요는 없습니다. 이상한 소리 같지만, 이 것은 iterable과 iterator의 중요한 차이점 입니다.

x는 iterable입니다. 반면 y와z는 iterable x로 부터 값을 produce하는 두개의 독립적인 iterator
instance입니다. y와 z는 예에서 볼 수 있듯 state를 가지고 있십니다. 위 예에서 x는 자료구조지만(list) 앞서 말했듯이 꼭 자료구조일 필요는 없습니다.

NOTE:
자주 실용적인 이유로, iterable 클래스들은 __iter__()와 __next__() 메소드를 같은 클래스 안에 구현하며, self를 반환하는 __iter__() 메소드를 갖는데 이는 그 자신을 iterable하게 만들어줌과 동시에 그 자신의 iterator이도록 만들어줍니다. 물론 iterator로서 다른 object를 반환해도 좋습니다.

마지막으로 만약에 아래와 같이 코드를 쓴다면


아래의 그림은 위의 위의 코드가 실행 될 때에 벌어지는 일을 도식화한 그림입니다.



파이썬 코드를 disassemble 해보면 GET_ITER 함수를 호출하는 것을 볼 수있는데 이 것은 본질적으로 iter 메소드를 invoke하는 것과 같습니다. FOR_INTER 명령행의 경우 next()를 모든 element를 가져오기 위해 반복적으로 호출하는 것과 같은데, 하지만 이것은 인터프리터의 속도최적화를 위해 바이트 코드 instruction에서는 보이지 않습니다.



Iterator

그렇다면 Iterator는 무엇일까요? Iterator는 stateful한 helper object이며, 이것은 next() 메소드를 호출 할 때 다음 값을 produce합니다. __next__() 메소드를 가지고 있는 object는 iterator입니다. 어떻게 값을 생성하는지는 무관합니다.

그러므로, iterator는 value factory입니다. iterator는 next value를 요청할 때마다, 어떻게 next value를 계산할 것인지 알고있습니다. 왜나혀면 iterator는 internal state를 가지고 있기 때문입니다.

iterator의 예시는 셀 수 없이 있습니다. itertools의 모든 함수는 iterator를 반환합니다.

일부는 무한한 sequence를 반환합니다.

>>> from itertools import count
>>> counter = count(start=13)
>>> next(counter)
13
>>> next(counter)
14

일부는 유한한 sequence에서 무한한sequence를 반환합니다.


일부는 무한한 sequence에서 유한한 sequence를 반환합니다.


iterator를 좀 더 이해하기 위해서 피보나치 수를 생성하는 iterator를 만들어 보겠습니다.


이 클래스는 __iter__() 메소드를 가지고 있음으로 iterable함과 동시에 __next__() 메소드를 가지고 있음으로 그 자신의 iterator입니다.

iterator안의 state는 prev 와 curr instance 변수를 통해 완전히 보존됩니다. 그리고 이 변수들은 iterator의 이따르는 호출을 위해 사용됩니다. 모든 next 메서드 호출은 두가지 중요한 점이 있습니다.

  1. 다음 next() 메소드 호출을 위해 자신의 state를 변경한다.
  2. 현재 호출을 위해 값을 생성한다.

Central idea: a lazy factory
겉에서 보자면 iterator는 lazy factory 같아 보입니다. lazy factory는 값을 요청할 때까지 idle state입니다. 이것은 값을 생성하고, 다시 idle 상태로 돌아갑니다.


Generator

Generator는 파이썬의 우아한 버젼의 iterator입니다.

Generator는 __iter__(), __next__() 메소드를 쓰지 않는 대신 조금 더 우아한 버전의 피보나치 iterator를 만들 수 있도록 해줍니다.

정리하자면,

  • 모든 Generator는 iterator 입니다. (역은 성립하지 않습니다.)
  • 모든 Generator는 그러므로 factory이며 게으르게(lazy) 값을 생성합니다.

아래의 예시는 generator로 쓰여진 피보나치 factory입니다.


이렇게 우아한 코드는 아래의 명령을 통해 만들어집니다.

yield

위의 코드가 어떻게 실행되는지 알아봅시다. 첫째로 fib 함수는 평범한 파이썬 함수입니다. 하지만, 이 함수에는 return 키워드가 없습니다. 함수의 반환값은 generator입니다. (iterator이고, factory이며, stateful helper function입니다.)

f = fib() 가 실행 될때 generator는 인스턴스화되고 반환되어집니다. 아무런 코드가 실행되지 않으며 generator는 idle 상태로 시작됩니다. prev, curr = 0, 1 역시 아직 수행되지 않았습니다.

그리고 generator가 islice() 로 래핑이 됩니다. 이 자체로 iterator로 idle상태입니다. 아직까지는 아무일도 벌어지지 않습니다.

그리고 iterator가 list로 래핑이 되면서, 이것은 모든 arguments를 소비하며 list를 만들어냅니다. 이 것을 수행하기 위해 list는 islice()를 호출하기 시작하고 islice()는 f instance에서 next() 메소드를 호출하기 시작합니다.

조금 더 찬찬히 보자면, 처음 호출에서 prev, curr = 0, 1이 수행됩니다. 그리고 loop문으로 들어가게 되며, yield curr문을 만나게 됩니다. 이 것은 현재의 curr 값을 생산하며 idle 상태로 들어갑니다. 이값은 islice() 메소드로 넘겨지며 list에 1이 추가됩니다.

그 다음 list는 islice()에 다음 값을 요청하고, islice()는 f에 다음 값을 요청합니다. 그 f 함수는 정지상태를 벗어나 prev, curr = curr + prev문 부터 수행됩니다. 그러므로 다시 loop문에 들어가고 앞 과 같이 다음 curr 값을 생성합니다.

이는 10개의 원소가 가질 때까지 계속되며 list()가 열한번째 값을 요청하는 순간 islice() 는 StopIteration exception을 발생시킵니다. 그리고 이 list는 결과를 반환합니다. generator는 11번째 next() 호출을 받지 않으며, 다시 사용되어 지지 않고, garbage collected 됩니다.


Types of Generator

Generator에는 2가지 타입이 있습니다. generator functions 과 generator expressions입니다. generator function은 앞서 말씀드린 예와 같이 함수 안에 yleid 키워드를 가지고 있는 함수입니다. 다른 타입은 list comprehension과 같은데, 이 문법은 한정된 use case에 대해 굉장히 우아합니다.

만약 제곱 수를 만드는 코드를 쓴다면

>>> numbers = [1, 2, 3, 4, 5, 6]
>>> [x * x for x in numbers]
[1, 4, 9, 16, 25, 36]

당연히 set 으로도 같은 일을 할 수 있습니다.

>>> {x * x for x in numbers}
{1, 4, 36, 9, 16, 25}

dict도 가능하구요

>>> {x: x * x for x in numbers}
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36}

generator expression으로도 쓸 수 있습니다.

>>> lazy_squares = (x * x for x in numbers)
>>> lazy_squares
<generator object <genexpr> at 0x10d1f5510>
>>> next(lazy_squares)
1
>>> list(lazy_squares)
[4, 9, 16, 25, 36]

다시한번 강조하자면, lazy_squares의 첫번째 값은 next() 메소드에 의해 읽혀졌기 때문에 list()를 통해 얻은 값은 2의 제곱부터 시작됩니다.


Summary

Generator는 강력한 프로그래밍 구조입니다. Generator는 좀 더 적은 변수와 데이터 구조로 streaming code를 쓰도록 도와줍니다. 게다가 이는 적은 메모리를 사용하고 CPU 효율성을 챙길 수 있으며 더 짧은 code로 같은 일을 할 수 있습니다.

아래는 generator를 시작하기 위한 tip입니다.


감사합니다.