Home [Python] 이터러블(Iterable), 이터레이터(Iterator), 제너레이터(Generator)
Post
Cancel

[Python] 이터러블(Iterable), 이터레이터(Iterator), 제너레이터(Generator)

TL;DR 📌

[📢] Iterable vs. Iterators vs. Generators


1. 개요

파이썬에서는 이터레이션(iteration)을 통해 데이터 시리즈(data series)에 연산을 적용할 수 있으며, 이터레이터(iterator)를 통해 데이터가 메모리에 수용 가능하지 않은 경우, 필요에 따라 아이템을 lazy 하게 fetch 할 수 있다.

파이썬의 모든 표준 컬렉션(standard collection)이터러블(iterable)인데, 이때 이터러블은 이터레이터를 제공하는 객체로 다음과 같은 연산을 제공한다.

  • for loop
  • list/dict/set comprehensions
  • unpacking assignments
  • construction of collection instances


2. 이터러블(Iterable)

2-1. 이터레이터와 이터러블의 관계 (+ iter() 함수)

파이썬이 어떤 객체 x에 대해 이터레이트(iterate) 할 때, 자동으로 iter(x)를 호출하여 이터레이터(iterator)를 얻는다. 이러한 iter() 함수는 built-in 함수이며, 다음과 같이 동작한다.

  1. 해당 객체가 __iter__ 메서드를 구현했는지 확인 후 호출하고, 그 결과로 이터레이터를 얻는다.
  2. __iter__ 메서드는 구현되지 않았으나 __getitem__ 메서드는 구현된 경우, iter()는 0부터 시작하는 인덱스를 통해 아이템을 fetch 하는 이터레이터를 생성한다.

    따라서 파이썬의 시퀀스(sequence)는 모두 시퀀스 프로토콜을 따르므로 __getitem__을 구현하기 때문에 이터러블이다. 또한, 표준 시퀀스는 __iter__도 구현한다.

    시퀀스 프로토콜 (sequence protocol)

    __len__ 메서드와 __getitem__ 메서드를 가지는 클래스는 시퀀스 프로토콜을 따른다고 할 수 있다.

  3. 모두 실패하면 TypeError를 발생시킨다.


이때, 이러한 iter() built-in 함수에 전달했을 때 이터레이터를 생성할 수 있는 객체이터러블(iterable)이라 한다. 즉, 이터러블은 다음의 두 조건 중 하나를 만족하는 객체를 의미한다.

  1. __iter__ method를 구현하고 있어 iterator를 반환할 수 있다.
  2. 0-based index를 허용하는 __getitem__ method를 구현하고 있다.

    따라서 파이썬의 시퀀스는 항상 이터러블이다.


파이썬은 이터러블(iterable)로부터 이터레이터(iterator)를 얻는다!

이터레이터는 다음 아이템에서 다뤄본다.


2-2. 이터러블의 타입 체킹

덕 타이핑(duck typing)의 관점에서, 어떠한 객체는 다음의 두 경우 중 하나에 해당하면 이터러블로 간주된다.

  1. __iter__를 구현한 경우
  2. __getitem__을 구현한 경우

    어떤 class가 __getitem__을 제공하면, iter() built-in은 class의 instance를 iterable로 받아 iterator를 반환한다. 이때, __getitem__은 index 0부터 호출되며, 더이상 item이 없으면 IndexError를 발생시킨다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
     class Spam:
         def __getitem__(self, i):
             print('->', i)
             raise IndexError()
        
     spam_can = Spam()
     iter(spam_can)
     # <iterator at 0x106800640>
        
     list(spam_can)
     # -> 0
     # []
    


하지만 구스 타이핑(goose typing)의 관점에서는 __iter__ 메서드를 구현한 경우에만 이터러블로 간주된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from collections import abc

# === __getitem__를 구현한 Spam === #
isinstance(spam_can, abc.Iterable)
# False

# === __iter__를 구현한 GooseSpam === #
class GooseSpam:
    def __iter__(self):
        pass

issubclass(GooseSpam, abc.Iterable)
# True

goose_spam_can = GooseSpam()
isinstance(goose_spam_can, abc.Iterable)
# True

위 예제에서 __getitem__을 구현한 spam_can은 이터러블이지만, isinstance로는 abc.Iterable로 인식되지 않는다.

구스 타이핑 관점에서는 __iter__를 구현해야만 이터러블로 간주되기 때문이다. 이때, abc.Iterable__subclasshook__을 구현하므로, subclassing이나 registration이 필요하지 않다.


파이썬의 런타임에서는 덕 타이핑이 적용되므로, 파이썬에서 어떤 객체 x가 이터러블인지 확인하는 가장 정확한 방법iter(x)를 호출하고 TypeError exception을 처리하는 것이다.

  • isinstance(x, abc.Iterable)을 사용하면 구스 타이핑으로 인해 __getitem__을 구현한 경우를 이터러블로 판단할 수 없기 때문이다.
  • 어떤 객체를 이터러블인지 확인한 후 바로 iterate 하려는 경우, 굳이 명시적으로 따로 확인할 필요 없이 iterate 하는 코드에서 try/except block을 통해 TypeError exception 처리를 하면 된다. 명시적으로 타입을 확인하는 것은 나중에 해당 객체를 iterate 하고 싶은 경우에, 미리 에러를 catch 할 수 있다는 점에서 적합하다.


2-3. iter()를 Callable과 함께 사용하기

iter()에 다음과 같은 두 인자를 제공함으로써 함수 혹은 callable 객체로부터 이터레이터를 만들 수 있다.

  1. 첫 번째 인자: 반복해서 호출되며 값을 생성하는 callable (인자 없이!)
  2. 두 번째 인자: 해당 값을 callable이 생성하는 경우 StopIteration을 raise 하도록 하는 sentinel


다음의 예시 코드에서 callable_iteratord6_itersentinel value로 설정된 값인 1을 반환하지 않는다. 또한, 한 번 exhausted 된 후 다시 해당 이터레이터를 사용하려면 이터레이터를 rebuild 해야 한다.

1
2
3
4
5
6
from random import randint

def d6():
    return randint(1, 6)

d6_iter = iter(d6, 1)
1
2
3
4
5
6
7
8
9
10
d6_iter
# <callable_iterator at 0x103ccb040>

for roll in d6_iter:
    print(roll)
# 2
# 6
# 4
# 5
# 3


만약 첫 번째 인자로 넘길 callable에 인자가 필요한 경우, partial() 함수를 사용할 수도 있다.

다음은 iter()를 통해 block-reader를 구현한 예제이다. sentinel value로 설정된 empty bytes object가 등장하면 더이상 읽을 byte가 없다는 것이므로 동작을 멈춘다.

1
2
3
4
5
6
from functools import partial

with open('mydata.db', 'rb') as f:
    read64 = partial(f.read, 64)
    for block in iter(read64, b''): # -- empty bytes object is the sentinel
        process_block(block)


2-4. for 루프의 원리

파이썬에서 for 루프는 이터러블로부터 이터레이터를 얻어 동작한다.

다음은 for 루프를 통해 str(시퀀스, 즉 이터러블)을 iterate 하는 예시이다.

1
2
3
4
5
6
s = 'ABC'
for char in s:
    print(char)
# A
# B
# C

이를 for 루프 없이 직접 구현해보면 다음과 같다.

1
2
3
4
5
6
7
8
s = 'ABC'
it = iter(s)    # -- (1) iterable로부터 iterator를 얻는다.
while True:
    try:
        print(next(it))     # -- (2) iterator에서 next를 호출함으로써 다음 item을 얻는다.
    except StopIteration:   # -- (3) iterator가 exhausted 되면 StopIteration이 발생한다.
        del it      # -- (4) StopIteration이 발생하면 iterator object를 discard 한다.
        break       # -- (5) while loop를 빠져나온다.

이러한 내부 동작들은 for 루프 뿐만 아니라 list comprehension, iterable unpacking 등 다른 iteration context의 로직에 구현되어 있다.


3. 이터레이터(Iterator)

그렇다면, 이터러블에서 얻을 수 있다는 이터레이터란 무엇일까?

3-1. 이터레이터 인터페이스: abc.Iterator

이터레이터의 파이썬 표준 인터페이스는 다음의 두 가지 method를 가진다.

  1. __next__ 메서드: 시리즈에서 다음 아이템을 반환하며, 다음 아이템이 없다면 StopIteration을 raise 한다.
  2. __iter__ 메서드: self(= 자기자신)를 반환하여 이터러블이 예상되는 곳에서 이터레이터가 사용될 수 있도록 한다.


이러한 인터페이스는 collections.abc.Iterator ABC에 나타나있다. 이는 __next__ abstract method를 선언하고, __iter__ abstract method가 선언되어 있는 Iterable을 상속받는다.


실제로 abc.Iterator의 코드를 살펴보면 다음과 같다.


앞서 언급했듯, 파이썬에서 어떤 객체 x이터러블인지 확인하는 가장 정확한 방법은 iter(x)를 호출하고 TypeError exception을 처리하는 것이었다.

isinstance(x, abc.Iterable)을 사용한다면 구스 타이핑 관점에서 확인하므로, __iter__ 대신 __getitem__을 구현한 경우를 이터러블로 판단할 수 없기 때문이다.

반면, 어떤 객체 x이터레이터인지 확인하는 가장 정확한 방법은 isinstance(x, abc.Iterator)를 호출하는 것이다.

Iterator.__subclasshook__에서 __next__ 메서드와 __iter__ 메서드가 구현되어 있는지 확인하기 때문이다.


3-2. 이터레이터의 특징

코드에 등장하는 Sentence 클래스는 다음 섹션에서 다루는 “예제용 Sequence 클래스”이다. 이는 시퀀스 프로토콜을 따르므로 이터러블에 해당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
s3 = Sentence('Life of Brian')
it = iter(s3) # -- build an iterator!
it
# <iterator at 0x1067bdb10>

next(it)
# 'Life'

next(it)
# 'of'

next(it)
# 'Brian'

next(it)
# StopIteration 발생

list(it)
# []

list(iter(s3)) # -- rebuild the iterator!
# ['Life', 'of', 'Brian']
  1. 이터레이터 it에서 next(it)로 아이템을 fetch 하다가 더이상 아이템이 없으면 StopIteration을 raise 한다. 이렇게 되면 해당 “이터레이터가 exhausted 되었다”고 표현하며, 이 상태의 이터레이터는 비어있다.

  2. 다시 이터레이션을 수행하고 싶다면 iter(iterable)을 통해 이터레이터를 rebuild 해야 한다. (새로 rebuild 하지 않는 이상 reset은 불가능하다!)

    Iterator.__iter__self를 반환하므로 iter(iterator)는 도움이 되지 않는다.

  3. 이터레이터가 필수로 가지는 메서드는 __next____iter__ 뿐이므로, StopIteration이 발생될 때까지 next()를 호출해야 이터레이터에 아이템이 남아있는지 여부를 확인할 수 있다.


4. 표준 이터러블 프로토콜(Standard Iterable Protocol)

표준 이터러블 프로토콜을 구현하는 방법에 대해서 예제 코드를 통해 알아보도록 하자.

[1] 예제용 Sequence 클래스

다음과 같이 단순히 파이썬의 시퀀스 프로토콜(sequence protocol)을 만족하는 Sentence 클래스를 생각해보자.

시퀀스 프로토콜 (sequence protocol)

__len__ 메서드와 __getitem__ 메서드를 가지는 클래스는 시퀀스 프로토콜을 따른다고 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import re
import reprlib

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

class Sentence:
    
    def __init__(self, text) -> None:
        self.text = text
        self.words = RE_WORD.findall(text)  # -- all nonoverlapping matches of the regex
    
    def __getitem__(self, index):
        return self.words[index]
    
    def __len__(self):  # -- iterable을 만드는 데에는 필요 없으나 sequence protocol에 필요
        return len(self.words)
    
    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text) # -- abbreviated string representation

이러한 Sentence 클래스를 실제로 사용하는 예시는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
s = Sentence('"The time has come," the Walrus said.')
print(s)
# Sentence('"The time ha... Walrus said.')

for word in s:
    print(word)
# The
# time
# has
# come
# the
# Walrus
# said

print(list(s))
# ['The', 'time', 'has', 'come', 'the', 'Walrus', 'said']


[2] 이터러블 프로토콜을 적용한 Sentence 클래스

우리는 지금까지 “어떤 객체 x에 대해, iter(x)를 호출함으로써 이터레이터를 반환받을 수 있는 객체 x를 이터러블이라 한다”는 것을 알게 되었다.

그리고 [1]번에서 다룬 Sentence 클래스는 시퀀스 프로토콜을 따르므로 __getitem__을 구현하기 때문에 이터러블에 해당한다.

따라서 다음과 같이 Sentence 클래스를 이터레이터 디자인 패턴(iterator design pattern)을 적용하여 표준 이터러블 프로토콜(standard iterable protocol)을 구현함으로써 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import re
import reprlib

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

class Sentence:
    
    def __init__(self, text):
        self.text = text
        self.words = RE_WORD.findall(text)
    
    def __repr__(self):
        return f'Sentence({reprlib.repr(self.text)})'
    
    def __iter__(self):
        return SentenceIterator(self.words)

class SentenceIterator:
    
    def __init__(self, words):
        self.words = words
        self.index = 0
    
    def __next__(self):
        try:
            word = self.words[self.index]
        except IndexError:
            raise StopIteration()
        self.index += 1
        return word
    
    def __iter__(self):
        return self

위의 코드에서 Sentence 클래스의 __iter__ 메서드에서 (1) 이터레이터인 SentenceIterator를 instantiate 한 후 (2) return 하므로 이터러블 프로토콜을 만족한다고 할 수 있다.

이때, SentenceIteratorabc.Iterator를 상속 받아 만든다면 __iter__ 메서드는 구현할 필요가 없다.


하지만 이터러블 혹은 이터레이터를 구현할 때, 다음의 것들에 주의해야 한다.

메서드주의할 점
이터러블의 __iter__매번 새로운 이터레이터를 instantiate 해야 한다!
(같은 객체를 계속 반환하면 X)
이터레이터의 __next__개별적인 아이템을 반환해야 한다.
이터레이터의 __iter__self를 반환해야 한다.

즉, (1) 하나의 이터러블 객체로부터 여러 개의 독립적인 이터레이터를 얻을 수 있어야(ex. iter(my_iterable)) 하며, (2) 각 이터레이터는 자신만의 internal state를 가져야 한다.

이러한 이유로 Sentence 클래스에 __next__를 구현하지 않고, SentenceIterator 클래스를 따로 생성하여 Sentence 클래스의 __iter__에서는 이 이터레이터를 새롭게 생성하도록 구현하는 것이다.


[3] 제너레이터 함수를 통해 이터러블 프로토콜을 구현한 Sentence 클래스

[2]번에서 구현한 것과 같이 이터러블 프로토콜을 구현할 수도 있으나, yield 키워드가 포함된 제너레이터 함수(generator function)를 통해 SentenceIterator 없이도 이터러블 프로토콜을 구현할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import re
import reprlib

RE_WORD = re.compile(r'\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:
            yield word

위의 코드에서 살펴볼 수 있는 특징은 다음과 같다.

  • __iter__에서 return 이 필요하지 않으며, StopIteration 또한 발생하지 않는다.
  • Sentence.__iter__제너레이터 함수(generator function)이므로, 호출 시 Iterator 인터페이스를 구현하는 제너레이터 객체(generator object)를 생성한다. 따라서 SentenceIterator가 더 이상 필요하지 않다.

이렇게 제너레이터를 통해 구현한 방식에서 lazy 버전, 제너레이터 식을 통해 구현한 버전을 추가로 더 살펴볼 예정이다.


그렇다면, 제너레이터(generator)란 무엇일까?


5. 제너레이터(Generator)

제너레이터(generator)는 간단하게 말하자면 파이썬 컴파일러를 통해 생성된 이터레이터이다. 제너레이터 객체를 생성하려면 두 가지 방법을 사용할 수 있다.

  1. yield 키워드를 사용하여 제너레이터 함수(generator function) 만들기
  2. 제너레이터 식(generator expression) 작성하기

이러한 제너레이터 객체는 __next__ 메서드를 제공하기 때문에 이터레이터라고 볼 수 있다.


5-1. 제너레이터 함수(Generator Function)

우선, 제너레이터 함수와 제너레이터 객체에 대해 정리하면 다음과 같다.

TermDescriptionAction
제너레이터 함수
(generator function)
내부에 yield 키워드를 가지고 있는 파이썬 함수
(즉, 제너레이터 팩토리)
제너레이터를 반환한다.
제너레이터 객체
(generator object)
제너레이터 함수 혹은 제너레이터 식으로부터 생성되는 객체값을 yield 한다.


이어서 코드 예시를 살펴보도록 하자.

다음의 코드에서 for 루프의 동작(1) g = iter(gen_AB())로 제너레이터 객체를 얻은 후, (2) 각 iteration에서 next(g)를 호출하는 것과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def gen_123():
    yield 1
    yield 2
    yield 3

gen_123
# <function __main__.gen_123()>

gen_123()
# <generator object gen_123 at 0x103d3f610>

for i in gen_123():
    print(i)
# 1
# 2
# 3

g = gen_123()
next(g)
next(g)
next(g)
next(g)
# StopIteration


다음과 같이 제너레이터 함수 안의 yield 사이에 print 문이 섞여있는 경우도 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def gen_AB():
    print('start')
    yield 'A'
    print('continue')
    yield 'B'
    print('end.')

for c in gen_AB():
    print('-->', c)

# start
# --> A
# continue
# --> B
# end.

위 코드에서는 세 번째 next() 호출 시 'end.'가 출력되는데, 이때의 동작은 다음과 같다.

  1. 제너레이터 함수 body맨 마지막에 도달한다.
  2. 제너레이터 객체StopIteration을 raise **한다.
  3. for 루프 machinery는 exception을 catch하여 루프를 끝낸다.


5-2. 지연 제너레이터(Lazy Generator)

lazy implementation은 가능한 마지막 순간까지 값을 생성하는 것을 미룬다.

eager implementation의 경우, 전체 데이터를 처리해야 하므로 많은 양의 메모리가 요구된다.

하지만 re.findall의 lazy versionre.finditer를 사용하면, re.MatchObejct 객체를 on demand로 yield 하는 generator를 얻을 수 있다. 이때, 많은 matches가 존재한다면, re.finditer많은 양의 메모리를 아낄 수 있다.

따라서 Sentence 클래스를 다음과 같이 lazy 버전으로 수정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import re
import reprlib

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

class Sentence:
    
    def __init__(self, text):
        self.text = text    # -- words list를 가질 필요가 없다.
    
    def __repr__(self):
        return f'Sentence({reprlib.repr(self.text)})'
    
    def __iter__(self):
        for match in RE_WORD.finditer(self.text):
            yield match.group()     # -- MatchObject instance로부터 matched text를 추출한다.


5-3. 제너레이터 식(Generator Expression)

간단한 제너레이터 함수는 제너레이터 식으로 교체(→ syntactic sugar)할 수 있기 때문에, 제너레이터 식 또한 마찬가지로 제너레이터 객체를 생성한다.


다음은 리스트 컴프리헨션을 제너레이터 식으로 변경하여 lazily iterate 하는 예시이다.

  • eagerly iterate 하는 예제: 리스트 컴프리헨션

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
      res1 = [x*3 for x in gen_AB()]  # -- eagerly iterates (list comprehension)
      # start
      # continue
      # end.
    
      for i in res1:
          print('-->', i)
      # --> AAA
      # --> BBB
    
  • lazily iterate 하는 예제: 제너레이터 식

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
      res2 = (x*3 for x in gen_AB())  # -- generator is not consumed here!
    
      for i in res2:
          print('-->', i)
      # start
      # --> AAA
      # continue
      # --> BBB
      # end.
    


이러한 제너레이터 식을 통해 Sentence 클래스의 __iter__ 메서드를 다음과 같이 변경할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import re
import reprlib

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

class Sentence:
    
    def __init__(self, text):
        self.text = text    # -- words list를 가질 필요가 없다.
    
    def __repr__(self):
        return f'Sentence({reprlib.repr(self.text)})'
    
    def __iter__(self):
        # -- generator function이 아닌 generator expression을 이용한다! (no yield)
        return (match.group() for match in RE_WORD.finditer(self.text))
  • yield가 없으므로 __iter__은 제너레이터 함수는 아니지만, 제너레이터 식을 사용한다.
  • __iter__의 caller는 제너레이터 객체를 얻게 된다.


제너레이터 함수 vs. 제너레이터 식

  • 제너레이터 함수: 더 유연하다.
    • complex logic w/ multiple statements
    • can be used as coroutines
  • 제너레이터 식: 간단한 경우에 대해 가독성이 더 좋다.
    • 제너레이터 식으로 2줄 이상 작성해야 한다면 제너레이터 함수를 사용하자.


5-4. 서브 제너레이터(Sub-Generator) (w/ yield from)

관련 포스팅: [Better Way #33] yield from을 사용해 여러 제너레이터를 합성하라


[📢] Iterable vs. Iterators vs. Generators

지금까지 다룬 내용을 간단히 정리하면 다음과 같다.

  • 이터러블(iterable): iter() built-in 함수에 전달했을 때 이터레이터(iterator)를 생성할 수 있는 객체
    • 다음의 두 조건 중 하나를 만족하면 이터러블이다.
      1. 이터레이터를 반환하는 __iter__ 메서드를 구현한다.
      2. 0-based index를 허용하는 __getitem__ 메서드를 구현한다.
    • 파이썬은 이터러블로부터 이터레이터를 얻는다.
  • 이터레이터(iterator): __iter__, __next__ 메서드를 구현한 객체
    • 클라이언트 코드에서 소비되는 데이터를 생성하도록 설계되었다.
    • 파이썬의 대부분의 이터레이터는 제너레이터이다.
  • 제너레이터(generator): 파이썬 컴파일러를 통해 생성된 이터레이터
    • 제너레이터 객체를 생성하는 방법은 다음의 두 가지이다.
      1. yield 키워드를 통해 제너레이터 함수를 만든다.
      2. 간단한 경우라면 제너레이터 식을 작성한다.
    • 제너레이터 객체는 __next__를 제공하므로, 일종의 이터레이터이다.


References

  • “Fluent Python (2nd Edition)”, Ch17. Iterators, Generators, and Classic Coroutines
This post is licensed under CC BY 4.0 by the author.

[Network] 3. 네트워크 계층

[Network] 4. 트랜스포트 계층