기능적 프로그래밍 하우투

Version 0.2원문 위치
작성 : 쿠클링
한글판 johnsonj 2006.11.02

(이 문서는 초안이다. 논평/에러 보고/제안등은 amk@amk.ca로 보내 주시기를 바란다. 이 URL은 문서의 최종 위치가 아닐 수도 있다. 링크하는데 주의하시기를 바란다 -- 면책 조항을 추가하셔도 좋다.)

이 문서에서는 기능적 스타일로 프로그램을 구현하는데 적합한 파이썬의 특징을 살펴보겠다. 기능적 프로그래밍의 개념을 소개한 후에 반복자(iterators)와 발생자(generators) 같은 언어의 특징 그리고 itertoolsfunctools 같은 관련 라이브러리 모듈을 살펴 보겠다.

들어가는 말

이 섹션에서는 기능적 프로그래밍의 기본 개념을 설명한다; 그냥 파이썬 언어의 특징을 배우는데 관심이 있을 뿐이라면, 다음 섹션으로 넘어가시라.

프로그래밍 언어는 여러가지 방식으로 문제를 분해하는 것을 지원한다:

컴퓨터 언어의 디자이너들은 앞서 강조한 프로그래밍 접근법중의 하나를 선택한다. 이 때문에 다른 접근법을 사용하는 프로그램을 작성하기가 어려운 경우가 많다. 다른 언어들은 멀티-패러다임 언어로서 여러 접근법을 지원한다. Lisp, C++, 그리고 파이썬이 멀티-패러다임 언어이다; 이 모든 언어에서 절차적으로, 객체-지향적으로 또는 기능적으로 프로그램이나 라이브러리를 작성할 수 있다. 거대한 프로그램에서, 부분마다 다른 접근법을 사용하여 작성할 수도 있다; 예를 들면, GUI는 객체 지향적으로 만들고 처리 로직은 절차적이나 기능적일 수 있다.

기능적 프로그램에서, 입력은 함수 집합 사이를 따라 흐른다. 각 함수는 입력을 처리해 출력을 생산한다. 기능적 스타일은 부작용이 있는 함수를 거부한다. 부작용이란 내부 상태를 변경하거나 심지어 그 함수의 반환 값에서조차 볼 수 없는 기타 변화를 만드는 것이다. 전혀 부작용이 없는 함수를 순수하게 기능적이라고 부른다. 부작용을 피한다는 말은 프로그램이 실행될 때 업데이트되는 데이터 구조를 사용하지 않는다는 뜻이다; 모든 함수의 출력은 오직 그의 입력에 따라 결정되어야 한다.

어떤 언어는 순수함에 대하여 아주 엄격하며 심지어 a=3 또는 c = a + b와 같은 할당문조차도 없다. 그러나 모든 부작용을 피하기는 쉽지 않다. 예를 들어, 화면에 인쇄하거나 디스크 파일에 쓰는 것은 부작용이다. 파이썬에서 print 서술문이나 time.sleep(1)은 모두 쓸모있는 값을 돌려주지 않는다; 오직 텍스트를 화면에 보내거나 잠시 실행을 멈추려는 부작용을 위해 호출될 뿐이다.

기능적 스타일로 작성된 파이썬 프로그램은 보통 극단적으로 모든 I/O 또는 모든 할당을 회피할 정도는 아니다; 대신에, 기능적으로-보이는 인터페이스를 제공한다. 하지만 내부적으로는 비-기능적 특징을 사용한다. 예를 들어, 함수의 구현은 여전히 지역 변수에 할당을 하지만, 전역 변수를 변경하거나 기타 부작용이 없다.

기능적 프로그래밍은 객체-지향형 프로그래밍의 반대라고 생각해도 된다. 객체는 작은 캡슐로서 안에 내부 상태가 있고 이와 함께 메쏘드 호출이 있어서 이 상태를 변경할 수 있으며 프로그램은 상태 변화의 올바른 집합을 만드는 것으로 구성된다. 기능적 프로그래밍은 상태 변화를 되도록 회피해야 하며 함수 사이의 데이터 흐름으로 작업해야 한다. 파이썬에서는 두 가지 접근법을 조합하여 객체를 대표하는 실체를 취하고 돌려주는 함수로 어플리케이션을 작성해도 된다 (전자우편, 메시지, 트랜잭션, 등등).

기능적 디자인은 일하기에 기묘한 제약처럼 보일 수도 있다. 왜 객체와 부작용을 피해야 하는가? 기능적 스타일에는 이론적이고 실용적인 장점이 있다:

형식 증명(Formal provability)

이론적인 혜택은 기능적 프로그램이 올바르다고 수학적으로 더 쉽게 증명할 수 있다는 것이다.

오랜 동안 연구자들은 수학적으로 프로그램이 올바른지 증명하는 방법을 찾는데 관심이 있었다. 이는 프로그램을 테스트하는 것과 다르다. 다양한 입력에 대하여 프로그램을 테스트해서 출력이 보통 올바르다고 결론을 내리는 것과는 다르다. 또는 프로그램 소스 코드를 읽어서 올바른 것 같아 보인다고 결론을 내리는 것도 아니다; 대신, 그 목표는 프로그램이 모든 가능한 입력에 대하여 올바를 결과를 산출한다고 정확하게 증명하는 것이다.

프로그램이 올바른지 증명하는 테크닉은 상수(invariants)를 평가하는 것이다. 이는 입력 데이터의 특성이자 프로그램에서 언제나 참인 변수의 특성이다. 각 코드 줄에 대하여, 그 줄이 실행되기 전에 상수 X와 Y가 참이라면, 그 줄이 실행된 후에 약간 다른 상수 X'와 Y'도 참임을 보여준다. 이것은 프로그램이 끝날 때 까지 계속된다. 끝날 시점에서 상수들은 프로그램의 출력에 대하여 바람직한 상태와 일치되어야 한다.

기능적 프로그램에서는 할당을 회피해야 한다는 문제가 일어난다. 왜냐하면 할당은 이런 테크닉을 처리하기가 어렵기 때문이다; 할당을 하고 나면 할당 전에는 참인 상수가 부서질 가능성이 있다. 앞쪽으로 전달될 수 있는 새로운 상수를 전혀 생산하지 않고서 말이다.

불행하게도, 프로그램이 정확한지 증명하는 것은 보통 실용적이지 않으며 파이썬 소프트웨어에 적절하지 않다. 사소한 프로그램조차도 여러 페이지에 걸친 증명을 요구한다; 프로그램이 약간만 복잡해도 올바름을 증명하는 것은 벅찬 일이다. 매일 사용하는 프로그램 어느 것도 (파이썬, XML 해석기, 웹 브라우저) 올바름을 증명할 수 없다. 증명을 해 냈다고 할지라도, 그 증명을 확증하는 문제가 여전히 존재한다; 증명에 에러가 있을 수 있으며, 그리하여 프로그램이 올바름을 증명했다고 엉뚱하게 믿을 수도 있는 것이다.

모듈화(Modularity)

기능적 프로그래밍에서 얻는 좀 실용적인 혜택은 문제가 작은 조각으로 분해된다는 것이다. 프로그램은 결과적으로 좀 더 모듈화된다. 복잡한 변환을 수행하는 방대한 함수 하나 보다는 한 가지 일을 하는 작은 함수를 지정해서 작성하는 것이 더 쉽다. 작은 함수는 또한 읽기도 쉽고 에러를 점검하기도 쉽다.

편리한 디버깅과 테스트

기능적-스타일의 프로그램을 테스트하고 디버깅하는 일은 더 쉽다.

디버깅은 간단해진다. 함수는 일반적으로 소형이며 명료하게 지정되기 때문이다. 프로그램이 작동하지 않으면, 각 함수는 인터페이스 지점이 되어 거기에서 데이터가 올바른지 점검할 수 있다. 중간 입력과 출력을 살펴 보면 즉시 버그에 책임이 있는 함수를 격리할 수 있다.

테스트는 더 쉽다. 각 함수는 유닛 테스트에서 유력한 대상이기 때문이다. 함수는 시스템 상태에 의존하지 않는다. 시스템은 테스트를 실행하기 전에 복제될 필요가 있다; 대신에 함수는 올바른 입력을 합성하고 다음 그 출력이 예상과 일치하는지 점검하기만 하면 된다.

합성능력(Composability)

기능적-스타일로 프로그래밍을 하다 보면, 다양한 입력과 출력을 가진 수 많은 함수들을 작성하게 될 것이다. 이런 함수중 어떤 것은 어쩔 수 없이 특정한 프로그램을 위해 전문화되겠지만, 다른 것들은 대부분 광범위한 프로그램에 유용할 것이다. 예를 들어, 디렉토리 경로를 받아 그 디렉토리 안에 있는 XML 파일을 모두 돌려주는 함수라든가 또는 파일이름을 받아 그의 내용을 돌려주는 함수등은 다른 많은 상황에도 응용이 가능하다.

시간이 지나면 개인 유틸리티 라이브러리를 갖게 될 것이다. 종종 기존의 함수를 새로운 구성에 맞추어 정비하고 목전의 과업에 전문화된 새로운 함수를 작성하여 새로운 프로그램을 조립하게 될 것이다.

반복자(Iterators)

먼저 기능적-스타일의 프로그램을 작성하는데 중요한 기반이 되는 파이썬 언어의 특징을 살펴보겠다: 반복자(iterators)를 말이다.

반복자는 데이터 스트림을 대표하는 객체이다; 이 객체는 데이터를 한 번에 한 요소씩 돌려준다. 파이썬의 반복자는 next()라고 부르는 메쏘드를 반드시 지원해야 한다. 이 메쏘드는 아무 인자도 받지 않으며 언제나 스트림에서 다음 요소를 돌려준다. 스트림에 더 이상 요소가 없다면, next() 메쏘드는 StopIteration 예외를 일으킨다. 그렇지만, 반복자는 유한일 필요는 없다; 무한의 데이터 스트림을 생산하는 반복자를 작성하는 것도 완전히 가능하다.

내장 iter() 함수는 임의의 객체를 받아 그 객체의 내용이나 요소를 돌려주는 반복자를 돌려준다. 그 객체가 반복을 지원하지 않으면, TypeError를 일으킨다. 파이썬의 내장 데이터 유형 중에는 반복을 지원하는 것이 있는데, 그 대부분은 리스트와 사전이다. 객체가 반복자를 얻을 수 있으면, 반복가능한(iterable) 객체라고 불리운다.

손수 반복 인터페이스를 실험해 볼 수 있다:

>>> L = [1,2,3]
>>> it = iter(L)
>>> print it
<iterator object at 0x8116870>
>>> it.next()
1
>>> it.next()
2
>>> it.next()
3
>>> it.next()
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
StopIteration
>>>

파이썬은 여러 다른 상황에서 반복가능한 객체를 예상한다. 가장 중요한 것은 for 서술문이다. 다음 for X in Y 서술문에서, Y는 반드시 반복자이거나 iter() 함수로 반복자를 만들 수 있는 그런 객체이다. 다음 두 서술문은 동등하다:

for i in iter(obj):
    print i

for i in obj:
    print i

반복자는 리스트나 터플처럼 list()tuple() 구성자를 사용하여 구현된다:

>>> L = [1,2,3]
>>> iterator = iter(L)
>>> t = tuple(iterator)
>>> t
(1, 2, 3)

연속열 풀기도 반복자를 지원한다: 반복자가 N개의 요소를 돌려준다는 사실을 알고 있다면, N-터플로 풀면 된다:

>>> L = [1,2,3]
>>> iterator = iter(L)
>>> a,b,c = iterator
>>> a,b,c
(1, 2, 3)

max()min() 같은 내장 함수들은 한 개의 반복자 인수를 취할 수 있고 가장 작은 또는 가장 큰 요소를 돌려 줄 수 있다. "in""not in" 연산자들도 반복자를 지원한다: X in iterator는 반복자가 돌려주는 스트림에서 X가 발견되면 참이다. 반복자가 무한이라면 확실히 문제에 봉착할 것이다; max(), min(), 그리고 "not in"은 반환되지 않는다. X 요소가 스트림에 나타나지 않으면, "in" 연산자 역시 반환되지 않는다.

반복자에서는 전진만 가능하다는 사실에 주의하자; 앞의 요소를 얻을 방법은 없다. 반복자를 재설정하거나, 사본을 만드는 것도 불가능하다. 반복자 객체는 선택적으로 이런 추가 능력을 제공하지만, 반복자 프로토콜에는 next() 메쏘드만 지정되어 있을 뿐이다. 그러므로 함수는 반복자의 출력을 모두 소비할 수도 있으며, 같은 스트림으로 무언가 다른 일을 하려고 한다면 새로 반복자를 만들면 된다.

반복자를 지원하는 데이터 유형

리스트와 터플이 어떻게 반복자를 지원하는지 살펴보았다. 사실, 문자열 같이 어떤 파이썬 연속열 유형도 자동으로 반복자의 생성을 지원한다.

사전에 iter()를 호출하면 그 사전의 키를 회돌이하는 반복자를 돌려준다:

>>> m = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
...      'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12}
>>> for key in m:
...     print key, m[key]
Mar 3
Feb 2
Aug 8
Sep 9
May 5
Jun 6
Jul 7
Jan 1
Apr 4
Nov 11
Dec 12
Oct 10

순서는 본질적으로 제멋대로이니 주의하자. 사전에서 객체의 순서를 해쉬처리하고 있기 때문이다.

iter()를 사전에 적용하면 언제나 그 키를 회돌이하지만, 사전에는 다른 반복자를 돌려주는 메쏘드가 있다. 키, 값, 또는 키/값 쌍을 반복하고자 한다면, 명시적으로 iterkeys(), itervalues(), 또는 iteritems() 메쏘드를 호출하여 적당한 반복자를 얻으면 된다.

dict() 구성자는 유한 흐름의 (key, value) 터플을 돌려주는 반복자를 받을 수 있다:

>>> L = [('Italy', 'Rome'), ('France', 'Paris'), ('US', 'Washington DC')]
>>> dict(iter(L))
{'Italy': 'Rome', 'US': 'Washington DC', 'France': 'Paris'}

파일 역시 반복을 지원한다. 파일에서 더 이상 줄이 없을 때까지 readline() 메쏘드를 호출하면 된다. 이는 곧 다음과 같이 파일의 각 줄을 읽을 수 있다는 뜻이다:

for line in file:
    # 각 줄에 작업
    ...

집합은 반복가능 객체로부터 자신의 구성원들을 취할 수 있으며 그 집합의 요소를 반복할 수 있다:

S = set((2, 3, 5, 7, 11, 13))
for i in S:
    print i

발생자 표현식과 리스트 통합

스트림에 대한 두가지 일반적인 연산은 1) 각 요소에 대하여 연산을 수행하는 것, 2) 일정 조건을 만족하는 요소들로 구성된 부분집합을 선택하는 것이다. 예를 들어, 문자열 리스트가 있다면, 각 줄에서 마지막 공백문자를 걷어내고 주어진 부분문자열을 포함하는 문자열을 모두 추출하고 싶을 것이다.

리스트 통합(List comprehensions)과 발생자 표현식(generator expressions) (줄인 형태로: "listcomps"와 "genexps")은 그런 연산에 대한 간결한 표현식으로서 기능적 프로그래밍 언어인 하스켈(Haskell (http://www.haskell.org/))에서 차용한 것이다. 다음 코드는 문자열 스트림으로부터 모든 공백을 제거할 수 있다:

line_list = ['  line 1\n', 'line 2  \n', ...]

# 발생자 표현식 -- 반복자를 돌려준다
stripped_iter = (line.strip() for line in line_list)

# 리스트 통합 -- 리스트를 돌려준다
stripped_list = [line.strip() for line in line_list]

특정한 요소만을 선택하려면 "if" 조건을 추가하면 된다:

stripped_list = [line.strip() for line in line_list
                 if line != ""]

리스트 통합으로, 파이썬 리스트를 돌려받는다; stripped_list는 결과 줄을 담고 있는 리스트이지, 반복자가 아니다. 발생자 표현식은 필요할 때마다 값을 계산해 주는 반복자를 돌려주기 때문에, 한 번에 값들을 모두 실체화할 필요가 없다. 이는 곧 무한 스트림을 돌려주는 반복자와 작업하거나 아주 방대한 양의 데이터와 작업할 경우 리스트 통합이 쓸모 없다는 뜻이다. 발생자 표현식은 이런 상황에 선호된다.

발생자 표현식은 반괄호로 둘러싸여져 ("()") 있고 리스트 통합은 각괄호 ("[]")로 둘러 싸인다. 발생자 표현식은 형태가 다음과 같다:

( expression for expr in sequence1
             if condition1
             for expr2 in sequence2
             if condition2
             for expr3 in sequence3 ...
             if condition3
             for exprN in sequenceN
             if conditionN )

여기에서도, 리스트 통합에 대하여 바깥 각괄호만 다르다(반괄호가 아니라 각 괄호임).

생성된 출력 요소들은 표현식(expression)으로 구성된 연속 값들이 될 것이다. if 절은 모두 선택적이다; 만약 있다면, expression 만 평가되고 조건(condition)이 참이라면 결과에 추가된다.

발생자 표현식은 언제나 반괄호 안에 작성되어야 하지만, 함수호출을 시작하는 반괄호도 고려되어야 한다. 즉시 함수에 건네지는 반복자를 만들고 싶다면 다음과 같이 작성하면 된다:

obj_total = sum(obj.count for obj in list_all_objects())

for...in 절에는 반복될 연속열이 담긴다. 연속열이 길이가 같을 필요는 없는데, 그 이유는 평행적으로가 아니라 왼쪽에서 오른쪽으로 반복되기 때문이다. sequence1에 있는 각 요소에 대하여, sequence2는 처음부터 회돌이 된다. 다음 sequence1sequence2로 부터의 각 결과 요소 쌍에 대하여 sequence3가 회돌이 된다.

다시 말해, 리스트 통합이나 발생자 표현식은 다음 파이썬 코드와 동등하다:

for expr1 in sequence1:
    if not (condition1):
        continue   # 이 요소는 건너뛴다
    for expr2 in sequence2:
        if not (condition2):
            continue    # 이 요소는 건너뛴다
        ...
        for exprN in sequenceN:
             if not (conditionN):
                 continue   # 이 요소는 건너뛴다

             # 표현식의 값을
             # 출력한다.

이것은 곧 여러 개의 for...in 절이 있지만 if 절이 없으면, 결과 출력의 길이는 모든 연속열의 길이의 곱과 같게 될 것이다. 길이가 3인 리스트가 2개 있다면, 출력 리스트는 길이가 9 요소이다:

seq1 = 'abc'
seq2 = (1,2,3)
>>> [ (x,y) for x in seq1 for y in seq2]
[('a', 1), ('a', 2), ('a', 3),
 ('b', 1), ('b', 2), ('b', 3),
 ('c', 1), ('c', 2), ('c', 3)]

파이썬 문법에 모호함을 초래하는 것을 방지하기 위하여, 표현식(expression)에 터플이 있으려면, 반드시 반괄호로 둘러 싸야 한다. 아래에서 첫번째 리스트 통합은 구문 에러이지만, 두 번째는 올바르다:

# 구문 에러
[ x,y for x in seq1 for y in seq2]
# 올바르다
[ (x,y) for x in seq1 for y in seq2]

발생자(Generators)

발생자는 반복자를 작성하는 작업을 단순화 시켜주는 함수들이 담긴 특별한 클래스이다. 보통의 함수는 값을 계산해서 그것을 돌려주지만, 발생자는 값의 흐름을 돌려주는 반복자를 돌려준다.

파이썬이나 C에서 어떻게 함수 호출이 이루어지는지 잘 아시리라 믿는다. 함수를 호출하면, 함수는 사적 이름공간을 획득하고 거기에 지역 변수들을 생성한다. 함수가 return 서술문에 도달하면, 지역 변수들은 파괴되고, 그 값이 호출자에게 반환된다. 같은 기능을 나중에 호출하면 또 새로운 사적 이름공간이 만들어지고 새롭게 지역 변수 집합이 생성된다. 그러나, 함수를 떠날 때 지역변수가 파괴되지 않는다면 어떻게 될까? 나중에 그 함수를 떠났던 바로 그곳에서 재개할 수 있다면 어떨까? 이것이 바로 발생자가 제공하는 것이다; 다시 말해 재개가능한 함수로 생각하면 된다.

다음은 발생자 함수의 가장 단순한 예이다:

def generate_ints(N):
    for i in range(N):
        yield i

어떤 함수도 yield 키워드가 있으면 발생자 함수이다; 이것은 파이썬의 바이트 코드 컴파일러에서 탐지되며 그 함수를 특별하게 컴파일한다.

발생자 함수를 호출하면, 값을 돌려주지 않는다; 대신에 반복자 프로토콜을 지원하는 발생자 객체를 돌려준다. yield 표현식을 실행할 때, 발생자는 i의 값을 출력하는데, return 서술문과 비슷한다. yield 서술문과 return 서술문 사이의 큰 차이점은 yield에 도달할 때 발생자의 실행 상태가 보류되고 지역 변수들은 보관된다는 것이다. 그 발생자의 .next() 메쏘드를 다음에 호출하면, 그 함수는 실행을 재개한다.

다음은 generate_ints() 발생자의 사용 예이다:

>>> gen = generate_ints(3)
>>> gen
<generator object at 0x8117f90>
>>> gen.next()
0
>>> gen.next()
1
>>> gen.next()
2
>>> gen.next()
Traceback (most recent call last):
  File "stdin", line 1, in ?
  File "stdin", line 2, in generate_ints
StopIteration

똑같이 동등하게 for i in generate_ints(5), 또는 a,b,c = generate_ints(3)와 같이 작성해도 된다.

발생자 함수 안에서, return 서술문은 값이 없을 경우에만 사용할 수 있으며, 값을 처리하는 과정이 종료되었음을 알려준다; return 서술문을 실행하고 나면 발생자는 더 이상 값을 돌려줄 수 없다. 값이 있는 return 문, 예를 들어 return 5와 같은 것은 발생자 함수 안에서는 구문 에러이다. 발생자에서 결과 값의 끝을 알리려면 StopIteration을 수작업으로 일으키거나, 또는 그냥 실행 흐름을 그 함수의 바닥에 이르도록 놓아두면 된다.

수작업으로 발생자의 효과를 얻을 수 있는데 독자적으로 클래스를 작성해서 발생자의 모든 지역 변수들을 실체 변수로 저장하면 된다. 예를 들어, 정수 리스트를 돌려주는 일은 self.count에 0을 설정하고, next() 메쏘드가 self.count 만큼 증가하도록 해서, 그것을 돌려주도록 만들면 된다. 그렇지만, 상당히 복잡한 발생자라라면, 그에 상응하는 클래스는 만드는 일은 대단히 난삽할 수 있다.

파이썬 라이브러리에 포함된 테스트 모둠인 test_generators.py에는 수 많은 흥미로운 예제들이 담겨 있다. 다음은 발생자를 재귀적으로 사용하여 트리의 중위 순회를 구현한 발생자이다.

# 트리 노드를 중위 순서로 생성하는 재귀적 발생자.
def inorder(t):
    if t:
        for x in inorder(t.left):
            yield x

        yield t.label

        for x in inorder(t.right):
            yield x

test_generators.py의 다른 두 예제에서는 N-Queens 문제의 해답과 (퀸끼리 서로 위협하지 않도록 N명의 퀸을 NxN 체스판에 배치하는 것) 기사의 여행 (한방을 두 번 방문하지 않고 NxN 체스판의 모든 방으로 기사를 인도하는 길을 찾는 것)의 해답을 제공한다.

발생자에 값 건네기

파이썬 2.4 이전에서, 발생자는 오직 출력만 산출했다. 일단 발생자 코드에 반복자를 생성하도록 요청하면, 그의 실행이 재개될 때 그 함수에 새로운 어떤 정보도 건넬 방법이 없었다. 이 능력을 임시 방편으로 처리하려면 발생자가 전역 변수를 찾도록 만들거나, 변경가능한 객체를 건네서 호출자가 변경하면 되었지만, 이런 방법은 난삽하다.

파이썬 2.5는 값을 발생자에게 건네는 간단한 방법이 있다. yield 는 표현식이 되었고, 값을 반환하여 주므로 그 값으로 변수에 할당하거나 처리할 수 있게 되었다:

val = (yield i)

항상 괄호를 yield 표현식 둘레에 두기를 권장한다. 위의 예제와 같이, 반환된 값으로 무언가를 하려고 한다면 말이다. 괄호는 반드시 필수는 아니지만, 필요할 때 기억하는 것보다 언제나 추가해 두는 편이 더 쉽다.

(PEP 342에서 그 정확한 규칙을 설명한다. 그 규칙에 의하면 yield-표현식은 언제나 반괄호로 둘러 싸야 한다. 단 최상위 표현식에서 할당이 오른쪽으로 일어날 때를 제외하고 말이다. 다시 말해 val = yield i와 같이 쓸 수 있지만, 연산이 존재한다면 괄호를 사용해야 한다는 뜻이다. 다음 val = (yield i) + 12.)과 같이 말이다.

값들을 발생자 안으로 전송하려면 send(value}) 메쏘드를 호출하면 된다. 이 메쏘드는 발생자의 코드를 재개하며 yield 표현식은 지정된 값을 반환한다. 보통의 next() 메쏘드가 호출되면, yield 문은 None을 반환한다.

다음은 간단한 계수기로서 1씩 증가하며 내부 값의 변경을 허용한다.

def counter (maximum):
    i = 0
    while i < maximum:
        val = (yield i)
        # If value provided, change counter
        if val is not None:
            i = val
        else:
            i += 1

그리고 다음은 그 계수기를 변경하는 예이다:

>>> it = counter(10)
>>> print it.next()
0
>>> print it.next()
1
>>> print it.send(8)
8
>>> print it.next()
9
>>> print it.next()
Traceback (most recent call last):
  File ``t.py'', line 15, in ?
    print it.next()
StopIteration

yield 문은 종종 None을 돌려주기 때문에, 항상 이 경우를 대비해 두어야 한다. 그 값을 표현식에서 바로 사용하지 말자. send() 메쏘드가 발생자 함수를 재개하는데 사용될 유일한 메쏘드가 될 것이라고 확신하지 않는 한 말이다.

send() 메쏘드 외에도, 발생자에는 두가지 새로운 메쏘드가 더 있다:

  • throw(type, value=None, traceback=None) 메쏘드는 발생자 안에서 예외를 일으키는데 사용된다; 예외는 yield 표현식에 의하여 일어나며 그곳에서 발생자의 실행이 멈춘다.

  • close() 메쏘드는 발생자 안에서 GeneratorExit 예외를 일으켜서 반복을 종료한다. 이 예외를 받으면, 발생자 코드는 반드시 GeneratorExit 또는 StopIteration을 일으켜야 한다; 이 예외를 잡고 다른 무언가를 하는 것은 불법이며 RuntimeError가 촉발될 것이다. 발생자가 쓰레기 수집되면 파이썬의 쓰레기 수집기가 close() 메쏘드도 호출한다.

    GeneratorExit이 발생할 때 청소 코드가 필요하다면, GeneratorExit을 잡는 대신에 try: ... finally: 모둠을 사용하자.

이렇게 하나하나 변한 효과로 발생자는 일-방향 정보 생산자에서 생산자이자 소비자로 변모하였다.

발생자는 코루틴(coroutines)이 될 수도 있다. 이는 보다 일반화된 형태의 서브루틴들이다. 서브루틴은 한 지점에서 진입해서 또다른 지점에서 탈출한다 (함수의 최상부, 그리고 return 서술문에서 말이다). 그러나 코루틴은 여러 지점에서 진입과 탈출 그리고 재개가 가능하다 ( yield 서술문).

내장 함수

반복자와 자주 사용되는 내장 함수들을 보다 자세하게 들여다 보자.

파이썬의 두 내장 함수인, map() 함수와 filter() 함수는 폐기된 셈이다; 즉 리스트 통합이라는 특징이 중복될 뿐만 아니라 반복자가 아니라 실제 리스트롤 돌려주기 때문이다.

map(f, iterA, iterB, ...)f(iterA[0], iterB[0]), f(iterA[1], iterB[1]), f(iterA[2], iterB[2]), ...가 담긴 리스트를 돌려준다.

def upper(s):
    return s.upper()
map(upper, ['sentence', 'fragment']) =>
  ['SENTENCE', 'FRAGMENT']

[upper(s) for s in ['sentence', 'fragment']] =>
  ['SENTENCE', 'FRAGMENT']

위에 보여준 바와 같이, 똑 같은 효과를 리스트 통합으로 얻을 수 있다. itertools.imap() 함수는 같은 일을 하지만 무한 반복자를 처리할 수 있다; 이 주제는 itertools 모듈 섹션에서 다루겠다.

filter(predicate, iter)는 특정한 조건을 만족하는 요소들을 모두 담은 리스트를 돌려주며, 리스트 통합과 비슷하다. predicate는 어떤 조건의 진리 값을 돌려주는 함수이다; filter()와 함께 사용하려면, 진위함수(predicate)은 값을 하나만 취해야 한다.

def is_even(x):
    return (x % 2) == 0

filter(is_even, range(10)) =>
  [0, 2, 4, 6, 8]

이것 역시 다음과 같은 리스트 통합으로 작성할 수 있다:

>>> [x for x in range(10) if is_even(x)]
[0, 2, 4, 6, 8]

filter()도 역시 itertools 모듈에 동등한 상대자가 있는데, itertools.ifilter()라는 것으로서 반복자를 돌려주며 그러므로 itertools.imap()이 그런 것처럼 무한 연속열을 다룰 수 있다.

reduce(func, iter, [initial_value])itertools 모듈에 상대자가 없는데 반복자의 모든 요소에 대하여 연산을 누적시켜 수행하며 그러므로 무한 요소에는 적용이 불가능하기 때문이다. func는 요소를 두 개 취해 하나의 값을 돌려주는 함수이다. reduce()는 반복자가 돌려주는 첫 두 요소 A와 B를 취해 func(A, B)를 계산한다. 다음 세 번째 요소 C를 요청해, func(func(A, B), C)를 계산하고, 이 결과를 반환된 네 번째 요소와 결합하고, 반복자가 소진할 때까지 계속한다. 반복자가 값을 돌려주지 않으면, TypeError 예외가 일어난다. 초기 값이 공급되면, 그 값이 시작점으로 사용되고 func(initial_value, A)이 첫 번째로 계산된다.

import operator
reduce(operator.concat, ['A', 'BB', 'C']) =>
  'ABBC'
reduce(operator.concat, []) =>
  TypeError: reduce() of empty sequence with no initial value
reduce(operator.mul, [1,2,3], 1) =>
  6
reduce(operator.mul, [], 1) =>
  1

operator.addreduce()와 함께 사용하면, 반복자의 모든 요소들을 합할 수 있다. 이런 경우는 아주 일반적이어서 합을 구하기 위해 특별히 sum()이라는 내장 함수가 있을 정도이다:

reduce(operator.add, [1,2,3,4], 0) =>
  10
sum([1,2,3,4]) =>
  10
sum([]) =>
  0

그렇지만 reduce()를 많이 사용한다면, 그냥 확실하게 for 회돌이를 작성하는 것이 더 명료하다:

# 다음과 같이 하는 대신에:
product = reduce(operator.mul, [1,2,3], 1)

# 이렇게 작성하는 것이 좋다:
product = 1
for i in [1,2,3]:
    product *= i

enumerate(iter)는 반복자의 요소에 번호를 붙여서, 번호와 값으로 구성된 2-터플을 돌려준다.

enumerate(['subject', 'verb', 'object']) =>
  (0, 'subject'), (1, 'verb'), (2, 'object')

enumerate()가 종종 사용되는 때는 리스트를 회돌이하여 특정 조건을 만족하는 지표를 기록할 때이다:

f = open('data.txt', 'r')
for i, line in enumerate(f):
    if line.strip() == '':
        print 'Blank line at line #%i' % i

sorted(iterable, [cmp=None], [key=None], [reverse=False)는 반복자의 요소들을 모두 모아 리스트로 만들고, 그 리스트를 정렬하여, 그 결과를 돌려준다. cmp, key, 그리고 reverse 인자들은 구성된 리스트의 .sort() 메쏘드에 건네진다.

import random
# [0, 10000) 사이에서 무작위로 수 8개를 만든다
rand_list = random.sample(range(10000), 8)
rand_list =>
  [769, 7953, 9828, 6431, 8442, 9878, 6213, 2207]
sorted(rand_list) =>
  [769, 2207, 6213, 6431, 7953, 8442, 9828, 9878]
sorted(rand_list, reverse=True) =>
  [9878, 9828, 8442, 7953, 6431, 6213, 2207, 769]

(정렬에 관하여 좀 더 자세한 설명은 파이썬 위키에서 정렬 미니-하우투를 참조하자 http://wiki.python.org/moin/HowTo/Sorting.)

any(iter)all(iter) 내장 함수는 반복자의 내용물에 대한 진리 값을 찾는다. any()는 반복자의 요소중 하나라도 참 값이면 참을 돌려준다. all()은 요소들이 모두 참 값이라면 참을 돌려준다:

any([0,1,0]) =>
  True
any([0,0,0]) =>
  False
any([1,1,1]) =>
  True
all([0,1,0]) =>
  False
all([0,0,0]) =>
  False
all([1,1,1]) =>
  True

작은 함수들과 람다(lambda) 서술문

기능적-스타일의 프로그램을 작성할 때, 종종 작은 함수들이 필요할 경우가 있다. 명령어처럼 행동하거나 어떤 방식으로 요소들을 조합하는 그런 함수들 말이다.

파이썬 내장함수 또는 모듈에 적당한 것이 있다면, 새로 함수를 정의할 필요가 전혀 없다:

stripped_lines = [line.strip() for line in lines]
existing_files = filter(os.path.exists, file_list)

필요한 함수가 없다면, 작성할 필요가 있다. 작은 함수를 작성하는 한가지 방법은 lambda 서술문을 사용하는 것이다. lambda 는 여러 매개변수와 이 매개변수들을 조합한 표현식을 취해서, 그 표현식의 값을 돌려주는 작은 함수를 하나 만든다:

lowercase = lambda x: x.lower()

print_assign = lambda name, value: name + '=' + str(value)

adder = lambda x, y: x+y

한가지 대안은 그냥 def 서술문을 사용해서 평상시대로 함수를 하나 정의하는 것이다:

def lowercase(x):
    return x.lower()

def print_assign(name, value):
    return name + '=' + str(value)

def adder(x,y):
    return x + y

어떤 대안이 더 좋을까? 그건 스타일의 문제이다; 나의 견해는 lambda 사용을 자제하는 것이다.

lambda는 정의할 수 있는 함수가 지극히 제한되어 있다. 그 결과는 단 하나의 표현식으로 계산이 가능해야 하는데, 이것은 다시 말해 다방면의 if... elif... else 비교 또는 try... except 서술문을 사용할 수 없다는 뜻이다. lambda 서술문 안에서 너무 많은 일을 하려고 하면, 결국 읽기 어려운 과도하게 복잡한 표현식으로 끝나고 말 것이다. 자, 다음 코드는 무엇을 하는가?

freq = reduce(lambda a, b: (0, a[1] + b[1]), items)[1]

짐작하시겠지만, 표현식을 풀어서 무슨 일이 일어나는지 가늠하려면 시간이 걸린다. 짧은 내포 def 서술문을 사용하는 편이 일이 훨씬 더 편하다:

def combine (a, b):
    return 0, a[1] + b[1]

return reduce(combine, items)[1]

그러나 그냥 간단하게 for 회돌이를 사용할 수 있다면 무엇보다도 좋을 것이다.:

total = 0
for a, b in items:
    total += b

for 회돌이에서 작성할 때는 reduce()를 많이 사용하는 것이 더 확실하다

프레드릭 룬트(Fredrik Lundh)는 언제인가 lambda의 사용을 재요소화하는데 다음 규칙을 제안했었다:

  1. 람다 함수를 작성한다.
  2. 람다가 도데체 무슨 일을 하는지 설명하는 주석을 단다.
  3. 주석을 잠시 동안 연구하고, 그 주석의 본질을 대표하는 이름을 생각한다.
  4. 람다를 def 서술문으로 변환한다. 그 이름을 사용해서 말이다.
  5. 주석을 제거한다.

정말 이런 규칙들을 좋아하지만, 이런 스타일이 더 좋다는 것을 거부해도 좋다.

itertools 모듈

itertools 모듈에는 일상적으로 사용되는 수 많은 반복자가 담겨 있을 뿐만 아니라 여러 반복자를 결합하는데 사용되는 함수들도 담겨 있다. 이 섹션에서는 이 모듈의 내용을 소개 하겠다. 작은 예제들을 보여주면서 말이다.

itertools.count(n)는 무한 정수 스트림을 돌려준다. 정수는 한 번에 1씩 증가한다. 선택적으로 시작 숫자를 공급할 수 있으며, 기본 값은 0이다:

itertools.count() =>
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...
itertools.count(10) =>
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...

itertools.cycle(iter)는 제공된 반복요소의 내용을 사본에 저장하고 새로운 반복자를 돌려준다. 이 반복자는 요소를 처음부터 마지막까지 돌려주는데, 무한 반복할 것이다.

itertools.cycle([1,2,3,4,5]) =>
  1, 2, 3, 4, 5, 1, 2, 3, 4, 5, ...

itertools.repeat(elem, [n])은 제공된 요소를 n 회 돌려주거나, 또는 n이 제공되지 않을 경우 그 요소를 끝없이 돌려준다.

itertools.repeat('abc') =>
  abc, abc, abc, abc, abc, abc, abc, abc, abc, abc, ...
itertools.repeat('abc', 5) =>
  abc, abc, abc, abc, abc

itertools.chain(iterA, iterB, ...)는 임의 개수의 반복자를 입력으로 취하고, 그 첫 반복자의 요소들을 모두 돌려주며, 다음으로 두 번째의 요소들을, 순서대로 돌려준다. 모든 반복자가 소진될 때까지 말이다.

itertools.chain(['a', 'b', 'c'], (1, 2, 3)) =>
  a, b, c, 1, 2, 3

itertools.izip(iterA, iterB, ...)는 한 요소를 각 반복자로부터 취해서 그것들을 터플 형태로 돌려준다:

itertools.izip(['a', 'b', 'c'], (1, 2, 3)) =>
  ('a', 1), ('b', 2), ('c', 3)

이 반복자는 반복 요소들이 모두 길이가 같은 경우에 사용되도록 만들어졌다. 반복요소가 길이가 다르다면, 결과 스트림은 가장 짧은 반복요소와 길이가 같을 것이다.

itertools.izip(['a', 'b'], (1, 2, 3)) =>
  ('a', 1), ('b', 2)

그렇지만, 이는 피하는 것이 좋다. 왜냐하면 더 긴 반복자로부터 요소가 선택되어졌다가 버려질 수 있기 때문이다. 이는 더 이상 그 반복자를 사용할 수 없다는 뜻이다. 사용하려면 버려진 요소를 건너뛰어야 하는 위험을 감수해야 하기 때문이다.

itertools.islice(iter, [start], stop, [step])는 반복자의 조각인 스트림을 돌려준다. 이 함수는 첫 stop 요소들을 돌려줄 수 있다. 시작 지표를 공급한다면 stop-start 요소를 얻을 것이고, 값을 step`에 공급하면 그에 맞게 요소들이 생략될 것이다.  파이썬의 string list 조각썰기와는 다르게, 음의 값을 startstop 또는 step에 사용할 수 없다.

itertools.islice(range(10), 8) =>
  0, 1, 2, 3, 4, 5, 6, 7
itertools.islice(range(10), 2, 8) =>
  2, 3, 4, 5, 6, 7
itertools.islice(range(10), 2, 8, 2) =>
  2, 4, 6

itertools.tee(iter, [n])는 반복자를 복제한다; 즉 n개의 독립적인 반복자를 돌려주는데, 각 반복자는 소스 반복자의 내용을 모두 돌려줄 것이다. n에 값을 제공하지 않으면, 기본값은 2이다. 반복자를 복제하려면 소스 반복자의 내용을 저장할 필요가 있는데, 이 때문에 그 반복자가 방대하다면 그리고 그 새로운 반복자중의 하나가 다른 반복자들보다 더 소비된다면 엄청나게 메모리를 소비할 수도 있다.

itertools.tee( itertools.count() ) =>
   iterA, iterB

where iterA ->
   0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...

and   iterB ->
   0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...

반복요소의 내용에 대하여 다른 함수를 호출하는데 두 함수가 사용되었다.

itertools.imap(f, iterA, iterB, ...)이 돌려주는 스트림에는 f(iterA[0], iterB[0]), f(iterA[1], iterB[1]), f(iterA[2], iterB[2]), ...이 담겨 있다:

itertools.imap(operator.add, [5, 6, 5], [1, 2, 3]) =>
  6, 8, 8

operator 모듈에는 파이썬 연산자에 상응하는 함수 집합이 담겨 있다. 몇가지 예를 들면 operator.add(a, b) (두 값을 더함), operator.ne(a, b) ( 다음과 동일 a!=b), 그리고 operator.attrgetter('id') ("id" 속성을 가져오는 호출가능 객체를 돌려줌).

itertools.starmap(func, iter)은 반복요소가 터플 스트림을 돌려줄 것이라고 간주하고, 이 터플들을 인자로 사용하여 f()를 호출한다.:

itertools.starmap(os.path.join,
                  [('/usr', 'bin', 'java'), ('/bin', 'python'),
                   ('/usr', 'bin', 'perl'),('/usr', 'bin', 'ruby')])
=>
  /usr/bin/java, /bin/python, /usr/bin/perl, /usr/bin/ruby

또다른 함수 그룹은 반복자의 요소들로 구성된 부분집합을 진위함수(predicate)에 근거하여 구한다.

itertools.ifilter(predicate, iter)는 진위함수(predicate)가 참이라고 반환하는 요소들을 모두 돌려준다:

def is_even(x):
    return (x % 2) == 0

itertools.ifilter(is_even, itertools.count()) =>
  0, 2, 4, 6, 8, 10, 12, 14, ...

itertools.ifilterfalse(predicate, iter)는 그 반대로서, 진위함수(predicate)가 거짓으로 반환하는 요소들을 모두 돌려준다:

itertools.ifilterfalse(is_even, itertools.count()) =>
  1, 3, 5, 7, 9, 11, 13, 15, ...

itertools.takewhile(predicate, iter)는 진위함수(predicate)가 참을 돌려주는 한 요소들을 돌려준다. 진위함수(predicate)가 거짓을 반환하면, 반복자는 그 결과가 끝이 났다고 알릴 것이다.

def less_than_10(x):
    return (x < 10)

itertools.takewhile(less_than_10, itertools.count()) =>
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9

itertools.takewhile(is_even, itertools.count()) =>
  0

itertools.dropwhile(predicate, iter)는 진위함수(predicate)가 참을 반환하는 한 요소들을 돌려주며, 반복요소의 나머지 결과를 돌려준다.

itertools.dropwhile(less_than_10, itertools.count()) =>
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...

itertools.dropwhile(is_even, itertools.count()) =>
  1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...

마지막으로 살펴 볼 함수는 itertools.groupby(iter, key_func=None)인데, 이 함수는 아주 복잡하다. key_func(elem)는 반복요소가 반환하는 각 요소에 대하여 키 값을 계산할 수 있는 함수이다. 키 함수를 제공하지 않으면, 그 키는 그냥 각 요소 그 자체가 된다.

groupby()는 같은 키 값을 가지는 아래의 반복요소로부터 모든 연속 요소들을 채집하여, 그 키를 가진 요소에 대하여 키 값과 반복자가 담긴 2-터플 스트림을 돌려준다.

city_list = [('Decatur', 'AL'), ('Huntsville', 'AL'), ('Selma', 'AL'),
             ('Anchorage', 'AK'), ('Nome', 'AK'),
             ('Flagstaff', 'AZ'), ('Phoenix', 'AZ'), ('Tucson', 'AZ'),
             ...
            ]

def get_state ((city, state)):
    return state

itertools.groupby(city_list, get_state) =>
  ('AL', iterator-1),
  ('AK', iterator-2),
  ('AZ', iterator-3), ...

where
iterator-1 =>
  ('Decatur', 'AL'), ('Huntsville', 'AL'), ('Selma', 'AL')
iterator-2 =>
  ('Anchorage', 'AK'), ('Nome', 'AK')
iterator-3 =>
  ('Flagstaff', 'AZ'), ('Phoenix', 'AZ'), ('Tucson', 'AZ')

groupby()는 아래의 반복요소의 내용이 이미 키를 기반으로 정렬되어 있다고 가정한다. 반환된 반복자도 아래의 반복요소를 사용한다는 것을 주의하자. 그래서 반복자-2와 그에 상응하는 키를 요구하기 전에 먼저 반복자-1의 결과를 소비해야 한다.

functools 모듈

파이썬 2.5의 functools 모듈에는 고차 함수들이 담겨 있다. 고차 함수(higher-order function)는 함수를 입력으로 받아 새로운 함수를 돌려준다. 이 모듈에서 가장 유용한 도구는 partial() 함수이다.

기능적 스타일로 작성된 프로그램에 대하여, 종종 기존의 함수를 변형하여 매개변수중 일부를 미리 채워 놓은 함수를 구성하고 싶을 때가 있다. 다음 파이썬 함수 f(a, b, c)를 생각해 보자; g(b, c) 새로운 함수를 만들고 싶을 때가 있다. f(1, b, c)와 동등한 함수를 말이다. 이를 일컬어 "부분적 함수 응용(partial function application)"이라고 부른다.

partial의 구성자는 다음과 같이 (function, arg1, arg2, ... kwarg1=value1, kwarg2=value2) 인자들을 받는다. 결과 객체는 호출이 가능하다. 그래서 그냥 호출하기만 하면 이미 채워진 인자와 함께 함수(function)를 요청할 수 있다.

다음은 작지만 실제적인 예이다:

import functools

def log (message, subsystem):
    "Write the contents of 'message' to the specified subsystem."
    print '%s: %s' % (subsystem, message)
    ...

server_log = functools.partial(log, subsystem='server')
server_log('Unable to open socket')

제-삼자 모듈도 있다. 예를 들어, 콜린 윈터(Collin Winter)의 기능적 꾸러미(functional package)는 기능적-스타일의 프로그램에 사용하기 위하여 만들어졌다.

개정 이력과 감사의 말

저자는 이 글의 다양한 초안에 제안과 교정 그리고 도움을 주신 여러분에게 감사드리는 바이다: Ian Bicking, Nick Coghlan, Nick Efford, Raymond Hettinger, Jim Jewett, Mike Krell, Leandro Lameiro, Collin Winter, Blake Winton.

Version 0.1: posted June 30 2006.

Version 0.11: posted July 1 2006. Typo fixes.

Version 0.2: posted July 10 2006. Merged genexp and listcomp sections into one. Typo fixes.

참조

일반

컴퓨터 프로그램의 구조와 이해, 해롤드 아벨슨(Harold Abelson)과 수스만(Sussman) 부부 저. 텍스트는 http://mitpress.mit.edu/sicp/에서 보실 것. 컴퓨터 공학에 관한 이 고전 텍스트에서, 제 2장과 3장을 보면 데이터 흐름을 프로그램 안에서 조직하기 위하여 연속열과 스트림을 이용하는 법을 다룬다. 이 책에서는 예제에 스킴(Scheme) 언어를 사용하지만, 이 장들에서 기술된 디자인 접근법 대다수는 기능적-스타일의 파이썬 코드에도 응용이 가능하다.

http://en.wikipedia.org/wiki/Functional_programming: 기능적 프로그래밍을 기술한 일반 위키 미디어 진입점.

http://en.wikipedia.org/wiki/Coroutine: 코루틴을 다룬 진입점.

파이썬 문서

http://docs.python.org/lib/module-itertools.html: itertools 모듈에 대한 문서.

http://docs.python.org/lib/module-operator.html:operator 모듈에 대한 문서.

http://www.python.org/dev/peps/pep-0289/: PEP 289: "발생자 표현식"

http://www.python.org/dev/peps/pep-0342/ PEP 342: "개선된 발생자를 통한 코루틴"에서 파이썬 2.5에 새로 선보인 발생자의 특징을 설명한다.