PEP 255 – 단순 제너레이터
- Author:
- Neil Schemenauer <nas at arctrix.com>, Tim Peters <tim.peters at gmail.com>, Magnus Lie Hetland <magnus at hetland.org>
- Status:
- Final
- Type:
- Standards Track
- Requires:
- 234
- Created:
- 18-May-2001
- Python-Version:
- 2.2
- Post-History:
- 14-Jun-2001, 23-Jun-2001
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 Python에 제너레이터 개념을 도입하고, 이와 함께 사용하는 새로운 문인 yield 문을 도입합니다.
동기
생산자 함수가 생성한 값들 사이에서 상태를 유지해야 할 만큼 까다로운 작업을 수행할 때, 대부분의 프로그래밍 언어는 생산자의 인자 목록에 콜백 함수를 추가하여 값이 생성될 때마다 이를 호출하게 하는 것 외에는 편리하고 효율적인 해법을 제공하지 않습니다.
예를 들어, 표준 라이브러리의 tokenize.py가 이 방식을 사용합니다. 호출자는 tokenize()에 tokeneater 함수를 전달해야 하며, 이 함수는 tokenize()가 다음 토큰을 찾을 때마다 호출됩니다. 이 방식 덕분에 tokenize 자체는 자연스럽게 작성할 수 있지만, tokenize를 호출하는 프로그램은 마지막으로 어떤 토큰을 보았는지를 콜백 사이에서 기억해야 하므로 대개 복잡하게 뒤얽히게 됩니다. tabnanny.py의 tokeneater 함수가 좋은 예입니다. 이 함수는 이미 무엇을 보았고 다음에 무엇이 나오기를 기대하는지를 콜백 사이에서 기억하기 위해 전역 변수에 상태 기계를 유지합니다. 이 코드는 올바르게 동작하도록 만들기가 어려웠고, 지금도 사람들이 이해하기 어렵습니다. 안타깝게도 이는 이 방식에서 흔히 나타나는 일입니다.
한 가지 대안은 tokenize가 Python 프로그램 전체를 한 번에 파싱하여 그 결과를 커다란 리스트로 만들어 내는 것이었을 것입니다. 그러면 tokenize 클라이언트는 지역 변수와 지역 제어 흐름(루프나 중첩된 if 문 등)을 사용하여 자신의 상태를 추적하는 자연스러운 방식으로 작성될 수 있었을 것입니다. 하지만 이 방법은 실용적이지 않습니다. 프로그램은 매우 클 수 있으므로 전체 구문 분석 결과를 구체화하는 데 필요한 메모리에 사전 상한을 둘 수 없습니다. 또한 일부 tokenize 클라이언트는 프로그램 앞부분에 특정한 무언가(예를 들어 future 문이나, IDLE에서 하는 것처럼 첫 번째 들여쓰기 된 문)가 나타나는지만 확인하려 하는데, 이때 프로그램 전체를 먼저 구문 분석하는 것은 심각한 시간 낭비입니다.
또 다른 대안은 tokenize를 이터레이터로 만들어, .next() 메서드가 호출될 때마다 다음 토큰을 전달하게 하는 것입니다. 이 방법은 커다란 결과 리스트와 마찬가지로 호출자에게 편리하지만, 메모리 문제나 “일찍 빠져나가고 싶다면 어떻게 하는가?”라는 단점은 없습니다. 하지만 이렇게 하면 .next() 호출 사이에서 자신의 상태를 기억해야 하는 부담이 tokenize로 옮겨 갑니다. tokenize.tokenize_loop()만 잠깐 보아도 그것이 얼마나 끔찍하게 번거로운 일인지 알 수 있습니다. 또는 일반적인 트리 구조의 노드를 만들어 내는 재귀 알고리즘을 떠올려 보십시오. 이를 이터레이터 프레임워크로 바꾸려면 재귀를 직접 제거하고 순회 상태를 손수 관리해야 합니다.
네 번째 선택지는 생산자와 소비자를 별도의 스레드에서 실행하는 것입니다. 이렇게 하면 양쪽 모두 자연스러운 방식으로 상태를 유지할 수 있으므로 양쪽 모두에게 편리합니다. 실제로 Python 소스 배포판의 Demo/threads/Generator.py는 이 작업을 일반적인 방식으로 수행하는 데 쓸 수 있는 동기화 통신 클래스를 제공합니다. 하지만 이 방법은 스레드가 없는 플랫폼에서는 동작하지 않으며, 스레드가 있는 플랫폼에서도 (스레드 없이 달성할 수 있는 것과 비교하면) 매우 느립니다.
마지막 선택지는 대신 경량 코루틴을 지원하는 Python의 변형 구현인 Stackless [1] (PEP 219)를 사용하는 것입니다. 이 방법은 스레드 방식과 거의 같은 프로그래밍상의 이점이 있으면서도 훨씬 더 효율적입니다. 그러나 Stackless는 Python 코어를 논란의 여지가 있는 방식으로 재설계한 것이며, Jython이 같은 의미를 구현하지 못할 수도 있습니다. 이 PEP는 그 문제를 논의할 자리가 아니므로, 여기서는 제너레이터가 Stackless 기능의 유용한 부분집합을 현재 CPython 구현에 쉽게 들어맞는 방식으로 제공하며, 다른 파이썬 구현에서도 비교적 간단하게 구현할 수 있을 것으로 여겨진다는 점만 말해 두겠습니다.
현재의 대안은 이것이 전부입니다. 다른 일부 고수준 언어는 편리한 해법을 제공합니다. 대표적인 예로 CLU의 이터레이터에서 영감을 받은 Sather [2]의 이터레이터와, 모든 표현식이 제너레이터인 참신한 언어 Icon [3]의 제너레이터가 있습니다. 이들 사이에는 차이점이 있지만 기본 아이디어는 같습니다. 호출자에게 중간 결과(“다음 값”)를 반환하면서도 함수의 지역 상태를 유지하여, 함수가 중단했던 바로 그 지점에서 다시 재개될 수 있게 하는 일종의 함수를 제공하는 것입니다. 아주 간단한 예:
def fib():
a, b = 0, 1
while 1:
yield b
a, b = b, a+b
fib()가 처음 호출되면 a를 0으로, b를 1로 설정한 다음 b를 호출자에게 yield 합니다. 호출자는 1을 받습니다. fib가 재개될 때 fib의 관점에서 보면 yield 문은 사실상 이를테면 print 문과 다를 바 없습니다. fib는 모든 지역 상태가 그대로 유지된 채 yield 다음부터 실행을 계속합니다. 이어서 a와 b는 1과 1이 되고, fib는 루프를 돌아 yield로 돌아가서 호출자에게 1을 yield 합니다. 이런 식으로 계속됩니다. fib의 관점에서는 마치 콜백을 통하듯이 일련의 결과를 전달하고 있을 뿐입니다. 하지만 호출자의 관점에서 fib 호출은 원할 때마다 재개할 수 있는 이터러블 객체입니다. 스레드 방식과 마찬가지로 이 방식은 양쪽 모두를 가장 자연스러운 방식으로 작성할 수 있게 해 줍니다. 하지만 스레드 방식과 달리 이 방식은 효율적이며 모든 플랫폼에서 사용할 수 있습니다. 실제로 제너레이터를 재개하는 비용은 함수 호출 비용보다 크지 않아야 합니다.
같은 종류의 방식을 여러 생산자/소비자 함수에 적용할 수 있습니다. 예를 들어 tokenize.py는 다음 토큰을 인자로 하여 콜백 함수를 호출하는 대신 그 토큰을 yield할 수 있으며, tokenize 클라이언트는 자연스러운 방식으로 토큰을 순회할 수 있습니다. Python 제너레이터는 Python 이터레이터의 일종이지만, 특히 강력한 종류입니다.
명세: Yield
새로운 문이 도입됩니다:
yield_stmt: "yield" expression_list
yield는 새로운 키워드이므로 이를 단계적으로 도입하려면 future 문(PEP 236)이 필요합니다. 첫 릴리스에서는 제너레이터를 사용하려는 모듈이 다음 줄을:
from __future__ import generators
모듈 상단 근처에 포함해야 합니다(자세한 내용은 PEP 236을 참조하십시오). future 문 없이 yield라는 식별자를 사용하는 모듈에서는 경고가 발생합니다. 그다음 릴리스에서는 yield가 언어 키워드가 되며 future 문은 더 이상 필요하지 않습니다.
yield 문은 함수 안에서만 사용할 수 있습니다. yield 문을 포함하는 함수를 제너레이터 함수라고 합니다. 제너레이터 함수는 모든 면에서 일반 함수 객체와 같지만, 코드 객체의 co_flags 멤버에 새로운 CO_GENERATOR 플래그가 설정되어 있습니다.
제너레이터 함수가 호출되면 실제 인자는 평소와 같이 함수 지역 형식 인자 이름에 바인딩되지만, 함수 본문의 코드는 전혀 실행되지 않습니다. 대신 제너레이터-이터레이터 객체가 반환됩니다. 이 객체는 이터레이터 프로토콜을 따르므로, 특히 for 루프에서 자연스럽게 사용할 수 있습니다. 문맥상 의도가 분명할 때는 수식어 없이 “제너레이터”라는 이름으로 제너레이터 함수와 제너레이터-이터레이터 중 어느 쪽이든 가리킬 수 있다는 점에 유의하십시오.
제너레이터-이터레이터의 .next() 메서드가 호출될 때마다 제너레이터 함수 본문의 코드가 yield 문이나 return 문(아래 참조)을 만나거나 본문의 끝에 도달할 때까지 실행됩니다.
yield 문을 만나면 함수의 상태가 동결되고, expression_list의 값이 .next()의 호출자에게 반환됩니다. 여기서 “동결”이란 지역 변수의 현재 바인딩, 명령어 포인터, 내부 평가 스택을 포함한 모든 지역 상태가 유지된다는 뜻입니다. 충분한 정보가 저장되므로 다음에 .next()가 호출될 때 함수는 마치 yield 문이 또 하나의 외부 호출에 불과했던 것처럼 그대로 실행을 이어 갈 수 있습니다.
제약: try/finally 구문의 try 절 안에서는 yield 문을 사용할 수 없습니다. 문제는 제너레이터가 언젠가 재개된다는 보장이 없고, 따라서 finally 블록이 언젠가 실행된다는 보장도 없다는 점입니다. 이는 finally의 목적을 용납할 수 없을 만큼 크게 위배합니다.
제약: 제너레이터는 실행 중인 동안에는 재개될 수 없습니다:
>>> def g():
... i = me.next()
... yield i
>>> me = g()
>>> me.next()
Traceback (most recent call last):
...
File "<string>", line 2, in g
ValueError: generator already executing
명세: Return
제너레이터 함수는 다음 형식의 return 문도 포함할 수 있습니다:
return
제너레이터 본문의 return 문에는 expression_list를 사용할 수 없다는 점에 유의하십시오(물론 제너레이터 안에 중첩된, 제너레이터가 아닌 함수의 본문에는 사용할 수 있습니다).
return 문을 만나면 다른 함수가 반환할 때와 마찬가지로 제어가 진행되며, 해당하는 finally 절이 (있다면) 실행됩니다. 그런 다음 StopIteration 예외가 발생하여 이터레이터가 소진되었음을 알립니다. 명시적인 return 없이 제어가 제너레이터의 끝을 벗어나는 경우에도 StopIteration 예외가 발생합니다.
제너레이터 함수이든 아니든 return은 “할 일을 마쳤고, 반환할 만한 흥미로운 것이 없음”을 뜻한다는 점에 유의하십시오.
return이 StopIteration을 발생시키는 것과 항상 같지는 않다는 점에 유의하십시오. 차이는 바깥의 try/except 구문을 어떻게 처리하는지에 있습니다. 예를 들어,:
>>> def f1():
... try:
... return
... except:
... yield 1
>>> print list(f1())
[]
이는 다른 함수에서와 마찬가지로 return은 단순히 빠져나가기 때문입니다. 그러나:
>>> def f2():
... try:
... raise StopIteration
... except:
... yield 42
>>> print list(f2())
[42]
이는 다른 모든 예외와 마찬가지로 StopIteration도 예외를 지정하지 않은 except에 잡히기 때문입니다.
명세: 제너레이터와 예외 전파
처리되지 않은 예외(StopIteration을 포함하지만 이에 국한되지 않음)가 제너레이터 함수에서 발생하거나 제너레이터 함수를 거쳐 전달되면, 그 예외는 일반적인 방식으로 호출자에게 전달되며, 이후 제너레이터 함수를 재개하려고 하면 StopIteration이 발생합니다. 다시 말해, 처리되지 않은 예외가 발생하면 제너레이터는 더 이상 쓸모가 없습니다.
예시 (관용적인 코드는 아니지만 요점을 보여 주기 위한 것입니다):
>>> def f():
... return 1/0
>>> def g():
... yield f() # the zero division exception propagates
... yield 42 # and we'll never get here
>>> k = g()
>>> k.next()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
File "<stdin>", line 2, in g
File "<stdin>", line 2, in f
ZeroDivisionError: integer division or modulo by zero
>>> k.next() # and the generator cannot be resumed
Traceback (most recent call last):
File "<stdin>", line 1, in ?
StopIteration
>>>
명세: Try/Except/Finally
앞서 언급했듯이, try/finally 구문의 try 절에서는 yield를 사용할 수 없습니다. 그 결과, 제너레이터는 중요한 자원을 할당할 때 각별히 주의해야 합니다. 그 밖에 finally 절, except 절, try/except 구문의 try 절에 yield가 나타나는 것에는 아무런 제한이 없습니다:
>>> def f():
... try:
... yield 1
... try:
... yield 2
... 1/0
... yield 3 # never get here
... except ZeroDivisionError:
... yield 4
... yield 5
... raise
... except:
... yield 6
... yield 7 # the "raise" above stops this
... except:
... yield 8
... yield 9
... try:
... x = 12
... finally:
... yield 10
... yield 11
>>> print list(f())
[1, 2, 4, 5, 8, 9, 10, 11]
>>>
예시
# A binary tree class.
class Tree:
def __init__(self, label, left=None, right=None):
self.label = label
self.left = left
self.right = right
def __repr__(self, level=0, indent=" "):
s = level*indent + `self.label`
if self.left:
s = s + "\n" + self.left.__repr__(level+1, indent)
if self.right:
s = s + "\n" + self.right.__repr__(level+1, indent)
return s
def __iter__(self):
return inorder(self)
# Create a Tree from a list.
def tree(list):
n = len(list)
if n == 0:
return []
i = n / 2
return Tree(list[i], tree(list[:i]), tree(list[i+1:]))
# A recursive generator that generates Tree labels in in-order.
def inorder(t):
if t:
for x in inorder(t.left):
yield x
yield t.label
for x in inorder(t.right):
yield x
# Show it off: create a tree.
t = tree("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
# Print the nodes of the tree in in-order.
for x in t:
print x,
print
# A non-recursive generator.
def inorder(node):
stack = []
while node:
while node.left:
stack.append(node)
node = node.left
yield node.label
while not node.right:
try:
node = stack.pop()
except IndexError:
return
yield node.label
node = node.right
# Exercise the non-recursive generator.
for x in t:
print x,
print
두 출력 블록 모두 다음을 표시합니다:
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
질문과 답변
def를 재사용하는 대신 새 키워드를 도입하지 않는 이유는 무엇입니까?
아래의 BDFL 선언 절을 참고하십시오.
yield에 새 키워드를 쓰는 이유는 무엇입니까? 대신 내장 함수를 쓰지 않는 이유는 무엇입니까?
Python에서는 제어 흐름을 키워드로 표현하는 편이 훨씬 낫고, yield는 제어 구조입니다. 또한 Jython에서 효율적으로 구현하려면 컴파일러가 컴파일 시점에 잠재적인 일시 중단 지점을 판별할 수 있어야 한다고 여겨지며, 새 키워드를 쓰면 이를 쉽게 할 수 있습니다. CPython 참조 구현도 어떤 함수가 제너레이터 함수인지 감지하는 데 이를 적극적으로 활용합니다(def 대신 새 키워드를 쓰면 CPython에서는 이 문제가 해결되겠지만, “왜 새 키워드를 씁니까?”라고 묻는 사람들은 어떤 새 키워드도 원하지 않습니다).
그렇다면 새 키워드 없이 다른 특수 문법을 사용하면 어떻습니까?
예를 들어, yield 3 대신 다음 중 하나를 쓰는 방식입니다:
return 3 and continue
return and continue 3
return generating 3
continue return 3
return >> , 3
from generator return 3
return >> 3
return << 3
>> 3
<< 3
* 3
제가 빠뜨린 것이 있습니까 <wink>? 수백 개의 메시지 가운데 이러한 대안을 제안한 메시지는 세 개였으며, 위의 예들은 거기에서 뽑은 것입니다. 새 키워드가 필요 없다면 좋겠지만, yield를 아주 명확하게 드러내는 편이 더 좋습니다. 그전까지는 무의미하던 키워드나 연산자의 나열을 해석해서 yield가 일어나고 있음을 추론해야 하는 상황은 원하지 않습니다. 그렇더라도 이 방안이 충분한 관심을 끈다면, 지지자들은 합의된 하나의 제안으로 의견을 모아야 하며, 그러면 Guido가 이에 대해 선언을 내릴 것입니다.
애초에 return을 허용하는 이유는 무엇입니까? 종료를 raise StopIteration으로만 표기하도록 강제하지 않는 이유는 무엇입니까?
StopIteration의 작동 방식은 Python 2.1에서 IndexError의 작동 방식과 마찬가지로 저수준의 세부 사항입니다. 구현은 내부적으로 잘 정의된 무언가를 해야 하며, Python은 고급 사용자를 위해 이러한 메커니즘을 노출합니다. 하지만 그것이 모든 사람에게 그 수준에서 작업하도록 강요해야 한다는 근거(인자)가 되지는 않습니다. return은 어떤 종류의 함수에서든 “나는 끝났다”를 의미하며, 설명하기도 사용하기도 쉽습니다. 또한 try/except 구조 안에서 return이 항상 raise StopIteration과 동등한 것도 아니라는 점에 유의하십시오(“명세: Return” 절 참조).
그렇다면 return에 표현식도 허용하지 않는 이유는 무엇입니까?
언젠가는 허용할 수도 있습니다. Icon에서 return expr은 “이제 끝났습니다”라는 뜻과 “하지만 마지막으로 반환할 유용한 값이 하나 더 있으며, 그것이 바로 이것입니다”라는 뜻을 모두 가집니다. 처음 시작하는 단계이고 return expr을 꼭 써야 할 만한 용도도 없는 상황에서는, 값을 전달하는 데 yield만 사용하는 편이 그저 더 깔끔합니다.
BDFL 선언
쟁점
제너레이터 함수와 제너레이터가 아닌 함수를 구별하기 위해 def 대신 또 다른 새 키워드(예를 들어 gen 또는 generator)를 도입하거나, 다른 방식으로 문법을 변경합니다.
반대
실제로는(제너레이터를 어떻게 생각하는지의 관점에서) 제너레이터는 재개할 수 있다는 점이 다를 뿐 엄연히 함수입니다. 제너레이터가 설정되는 방식은 비교적 사소한 기술적 문제이며, 새 키워드를 도입하면 제너레이터가 시작되는 방식(제너레이터의 생애에서 필수적이지만 아주 작은 부분)을 쓸데없이 지나치게 강조하게 됩니다.
찬성
실제로는(제너레이터를 어떻게 생각하는지의 관점에서) 제너레이터 함수는 마치 마법처럼 제너레이터-이터레이터를 만들어 내는 팩토리 함수입니다. 이 점에서 제너레이터 함수는 제너레이터가 아닌 함수와 근본적으로 다르며, 함수라기보다는 생성자에 가깝게 동작하므로 def를 재사용하는 것은 좋게 봐도 혼란스럽습니다. 본문 속에 묻혀 있는 yield 문만으로는 의미가 이토록 다르다는 사실을 충분히 경고할 수 없습니다.
BDFL
def로 유지합니다. 어느 쪽 주장(인자)도 완전히 설득력 있지는 않으므로, 저는 언어 설계자로서의 직관에 물어보았습니다. 직관은 PEP에서 제안한 문법이 딱 알맞다고 말해 줍니다. 너무 뜨겁지도, 너무 차갑지도 않습니다. 하지만 그리스 신화의 델포이 신탁처럼 그 직관은 이유를 알려 주지 않으므로, 저는 PEP 문법에 반대하는 주장(인자)에 대한 반박을 가지고 있지 않습니다. (이미 제시된 반박에 동의하는 것 외에) 제가 생각해 낼 수 있는 최선은 “FUD”입니다. 만약 이것이 처음부터 언어의 일부였다면, Andrew Kuchling의 “Python Warts” 페이지에 올랐을 것이라고는 전혀 생각하지 않습니다.
참조 구현
현재 구현은 초기 상태(문서는 없지만 충분히 테스트되었고 견고함)이며, Python의 CVS 개발 트리 [5]에 포함되어 있습니다. 이를 사용하려면 Python을 소스에서 빌드해야 합니다.
이 구현은 Neil Schemenauer의 이전 패치 [4]에서 파생되었습니다.
각주 및 참고 문헌
Copyright
This document has been placed in the public domain.