Code Metaphor

Programming, Writing, Reading, Thoughts…

이 블로그는 더이상 운영되고 있지 않습니다. 단지 예전 URL을 유지하는 용도로만 남아 있습니다. 새 블로그의 주소는 blog.dahlia.kr입니다.

Python 제너레이터+반복자의 마법

내가 무수히 많은 언어 가운데 Python을 유독 좋아하는 이유는, 들여쓰기로 블럭을 다루는 문법 때문이 아니라(사실 난 이거 안 좋아한다) 일반화된 인터페이스를 잘 정해놓았기 때문이다. 크게 두 가지가 있는데, 하나는 함수 객체(callable object라고 부른다)이고, 다른 하나는 어느 언어에나 다 있는 반복자이다.

함수 객체는 Java나 Ruby 같은 언어에는 없고 C++ 같은 언어에는 있는 개념인데, 람다 같은 것을 말하는 것은 아니다. 그냥 어떤 객체든 호출 연산자 ()를 정의할 수 있고, 그렇게 정의된 객체는 함수처럼 호출 가능하다는 것인데, 반대로 얘기하면 일반 함수들도 사실 그냥 호출 연산자만 정의한 객체라고 볼 수도 있다. 실제로 Python 클래스는 함수 객체다. 처음 접하는 사람은 그냥 new 키워드를 안쓰는구나 하고 말지만, 조금 써보고 나면 Python 클래스 객체가 호출 연산자를 인스턴스를 생성하도록 정의한 것뿐이라는 사실을 알게 된다.

Python의 반복자는 단순히 next 메서드를 정의한 객체를 뜻한다. 이 메서드는 뭔가 계속 값을 반환하다가 더이상 반환할 게 없다면 StopIteration 예외를 낸다. None을 반환하는 대신 예외를 내는 이유는 None 자체도 반복 대상이 될 수 있기 때문이다. 만약 길이가 무한하다면 StopIteration 예외가 영원히 던져지지 않을 수도 있다. 그런 반복자는 보통 itertools.takewhile 같은 것을 써서 필요한 만큼만 쓴다. 뭐 이런 개념은 Haskell 같은 언어를 조금만 접했어도 이해하기 쉬울 것이다. 내가 생각할 때 다른 언어의 반복자는 어떨지 몰라도 Python의 반복자는 단순한 추상화가 아니라 효율과 성능을 유지하기 위한 추상화이다. 이것은 C++랑 비슷한 면이 있다(나만 그렇게 생각하나?).

(이하 “반복자”는 Python에서의 iterator를 뜻한다. 모든 프로그래밍 언어의 얘기가 아니다.)

반복자의 가장 좋은 활용은 지연 평가(lazy evaluation)에 있다고 생각한다. 예를 들면 강성훈 님이 만든 vlaah-python 같은 것이 있다. 여기서는 의견의 목록을 단순히 하나의 거대한 리스트로 다루게 하고 있다. 적어도 보기에는 거대한 리스트 같다. 그렇지만 내부적으로는 필요한 만큼만 조금씩 요청하고 로딩한다. 만약 그 거대해 보이는 리스트에서 맨 처음 의견 하나만 사용한다면, 실제로 vlaah-python은 내부적으로 단 한 번의 요청만 하고 끝낸다. 무식하게 모든 의견을 다 가져오지 않는다는 뜻이다.

Python이 반복자 인터페이스를 잘 정의했다는 뜻은, 단순히 “next 메서드 있는 모든 객체는 다 반복자다”라는 심플함을 말하는 것이 아니다. Python의 모든 표준 라이브러리가, 단순히 리스트를 받는 대신 아무 반복자나 받기 때문에 좋은 인터페이스라고 생각한다. 예를 들어 내가 당장 리스트의 모든 합을 구하는 함수를 만든다고 생각해보자.

def sum(numbers):
    total = 0
    for number in numbers:
        total += number
    return total

저 함수를 작성하면서 내가 반복자 인터페이스를 염두한 부분은 정말 하나도 없다. 그렇지만 Python의 for문은 반복자를 받기 때문에 이미 저 함수는 내 의도 이상으로 반복자를 완벽하게 지원하게 되었다. numbers에는 리스트 뿐만 아니라 모든 반복 가능한 객체를 넣을 수 있는 것이다. 단순히 for문만 그런 것이 아니다. 완벽하게 절차적으로 작성했던 저 함수를 다르게 고쳐보자.

import operator

def sum(numbers):
    return reduce(operator.add, numbers, 0)

이 함수 역시 난 신경쓰지 않았는데 결국에는 반복자를 완벽하게 지원하게 되었다. 왜냐면 reduce 내장 함수 역시 반복자를 받기 때문이다. 이와 같이 Python의 거의 모든 표준 라이브러리가 반복자를 당연하듯 지원하고 있기 때문에, 그것을 쌓아서 올리는 거의 모든 사용자 정의 함수들 역시 반복자를 당연하게 지원하게 된다. 이러한 점 때문에, Python 프로그래머는 반복자만 구현하면 그것이 어떤 곳에서라도 사용 가능하다는 확신을 가지게 되고, 그런 점이 반복자를 더 많이 쓰도록 돕는다.

거기에 제너레이터(generator)제너레이터 표현식(generator expression) 같은 것까지 생각해보면, 확실히 Python은 반복자를 잘 활용해야 그때부터 제대로 쓰는 셈이다. 다행히 Python은—앞서 말했듯—의식하지 않더라도 반복자를 잘 활용할 수 있게끔 만들어진 언어이다.

items = {}
for key in keys:
    items[key] = get_value(key)

위 코드는 items 딕셔너리를 만드는 코드인데, dict 클래스의 생성자를 이용하면 아래와 같이 바꿀 수도 있다.

pairs = map(get_value, keys)
items = dict(zip(keys, pairs))

이것으로도 충분히 간결하긴 하지만, 제일 개간지가 나는 것은 아래와 같은 코드라고 생각한다.

items = dict((key, get_value(key)) for key in keys)

생성자 안에 쓰인 것이 제너레이터 표현식이고, 저것은 반복자를 반환하는 코드이다. 당연히 공간 효율도 이전의 map 함수를 쓰는 코드보다 좋다. 만약 keys의 길이가 굉장히 길다면 이전 코드는 map이 매우 긴 리스트를 만드느라 시간과 공간을 낭비했을 것이다. 제너레이터 표현식은 제너레이터 객체를 만들어내는 문법인데, 제너레이터 객체는 실제로 어떤 데이터를 가지고 있지는 않고, 어떤 데이터로부터 어떤 데이터를 뽑아내거나(map) 걸러내라(filter)는 지시만을 가지고 있다. 실제로 데이터가 생성되는 것은 dict 생성자 안쪽에서다. 제너레이터 표현식 말고 제너레이터를 쓰면 훨씬 복잡한 순차열(심지어 길이의 끝이 정의되지 않는 Haskell의 무한 리스트와 같은 것조차)을 절차적으로 간단하게 작성할 수 있다.

import math

def pager(length, selection=1, step=9):
    length = int(length)
    selection = int(selection)
    step = int(step)
    half = math.floor(step / 2)
    if length > step and selection > half + 2:
        yield "first", 1
        i = length - step + 1 \
            if selection + half >= length \
            else selection - half
    else:
        i = 1
    to = min(i + step, 1 + length)
    for i in xrange(i, to):
        yield "selected" if i == selection else i, i
    if max(selection, to) + 1 < length:
        yield "last", length

위 함수는 제너레이터 객체를 반환하는데, 게시판 아래쪽에서 쓰이는 페이저(pager)를 만들어낸다. 만약 Ruby를 썼다면 블럭으로 했을텐데, 짐작할 수도 있겠지만 Python 제너레이터는 Ruby의 블럭 역할을 대체하는 용도로 쓰이기도 한다. 하지만 이것은 단순히 반복자 인터페이스를 재활용했을 뿐이라는 점에서 더 재미있다.

for klass, page in pager(length, page):
    print """
        <li class="%s">
          <a href="?page=%d">%d</a>
        </li>
    """ % (klass if isinstance(klass, basestring) else "", page, page)

Ruby라면 이렇게 쓸 수 있도록 블럭을 받는 메서드로 구현했을 것이다.

page(length, page) do |klass, page|
  puts %{
    <li class="#{klass.is_a?(Symbol) ? klass : ''}">
      <a href="?page=#{page}">#{page}</a>
    </li>
  }
end

Ruby에서 블럭 메서드로 구현했을 때와 다르게, 단순히 반복자이므로 순차열 자체로도 이용 가능하다.

>>> pager(123, 12)
<generator object pager at 0xb7dda98c>
>>> list(pager(123, 12))
[('first', 1), (8, 8), (9, 9), (10, 10), (11, 11), ('selected', 12), (13, 13), (14, 14), (15, 15), (16, 16), ('last', 123)]

Python은 Haskell처럼 모든 인자가 지연 평가되는 언어는 아니지만 워낙 모든 함수가 반복자를 주고 받게 되어 있다보니 반복자의 연쇄가 깊게 이뤄진다. 반복자의 연쇄가 깊다는 것은 함수 호출 스택이 깊어도 맨 끝에서 아래까지 “의도”1가 잘 전달된다는 뜻이다. 예를 들어 아래의 코드를 보자.

odd_numbers_to_10 = itertools.takewhile(lambda i: i <= 10, (x for x in xrange(1000) if x % 2))

너무나 작위적인 예제지만, 의도가 깊게 관통하는 코드의 예로서는 읽을만 하다. 결국 최종적으로는 “0 이상 10 이하의 홀수 목록”을 원하는 건데, 최초로 제공되는 소스인 xrange(1000)은 10을 초과하는 숫자는 생성하지 않는다. 의도가 잘 전달된다는 것은 이러한 뜻이다. 의도가 전달되지 않는 예를 만드려면 xrangerange로 바꾸면 된다.2

odd_numbers_to_10 = itertools.takewhile(lambda i: i <= 10, (x for x in range(1000) if x % 2))

이 코드는 최종적으로 얻고자 하는 값이 결국 10 이하의 숫자들뿐임에도 range(1000)이 1000개의 수가 담긴 큰 리스트를 만들어내는도록 냅둔다. 공간도 낭비고 시간도 낭비다. 만약 우리가 xrange(1000)range(1000) 자리에 무한개의 숫자를 만들어내는 함수를 넣는다면 결정적인 차이가 발생한다. 만약 그 함수가 반복자를 반환한다면 우리가 처음 xrange(1000)을 썼던 코드와 효율에 차이가 없겠지만, 리스트를 만들어낸다면 리스트를 만들다가 메모리가 꽉 차서 뻗고 말 것이다.

이런게 실제로 프로그래밍할 때는 별로 중요하지 않을 것 같아도 의외로 큰 차이를 만드는 경우도 많다. 예를 들어 RoR의 ActiveRecord를 쓰면 find 메서드가 정말 배열을 반환한다. 그러니까 뷰로 넘기기 전에 미리 DB에 접근해서 데이터를 가져온다는 뜻이다. 그리고 기본 템플릿 엔진인 ERB는 전체 HTML 문자열을 다 생성해낸 다음 그것을 웹 서버로 전달한다. 이렇게 되면 가져오는 데이터가 클 경우 사용자 입장에서는 로딩하는 동안 브라우저의 흰 화면을 계속 보고 있어야 한다. 데이터를 가져오고 전체 complete HTML을 만들어내기 전까지는 응답을 할 수 없기 때문이다. 그러다가 로딩이 끝나면 한번에 퍽 하고 모든 화면이 렌더링된다. 아웃풋 버퍼링이 속도에 도움을 줄 때도 많지만, 내 경험상 웹 개발에서의 모델 컨트롤러 뷰 서버 스택에서는 버퍼링 없이 모든 데이터가 스트리밍되는 게 체감 속도가 훨씬 좋다.

요즘에는 Elixir(SQLAlchemy) + Tornado + Jinja2를 쓰고 있는데 일단 SQLAlchemy의 ORM 쿼리 객체가 기본적으로 반복자고, Jinja2 역시 부분적으로 HTML을 조금씩 만들어내게끔 반복자를 반환하는 기능이 있다. 이렇게 되면 HTML을 생성하는 와중에 필요한 데이터를 DB로부터 조금씩 가져오고, 또 금방 만들어진 HTML을 바로바로 서버를 통해 유저 에이전트로 전달하므로 브라우저에는 처음부터 데이터가 쌓이듯이 렌더링되게 된다. 당연히 사용자 입장에서 피드백이 바로 나오므로 더 쾌적하게 느껴진다.

글이 정리하기 힘들게 길어졌지만 얘기하고 싶은 바는 하나다. 지연 평가는 생각보다 훨씬 유용하며, Python 반복자와 제너레이터는 그것을 쉽게 할 수 있도록 도와준다는 것.


  1. 지연 평가란 그 목적이나 효과에 있어서 결국에는 “값”을 대신해 “원하는 값에 대한 의도”를 전달한다는 것과 같다. 그래서 지연 평가를 call by need라고도 이야기한다. 의도에 의한 호출이라는 것은 정말 좋은 표현이라고 생각한다. 

  2. xrange는 반복자를 반환하고 range는 리스트를 반환한다. range(100)list(xrange(100))과 같다. 

This entry was posted on September 15, 2009 at 1:29 PM. You can follow any responses to this entry through the RSS 2.0 feed. Both comments and pings are currently closed.

4 Responses to “Python 제너레이터+반복자의 마법”

  1. dahlia's me2DAY Says:

    홍민희의 생각…

    Python 제너레이터+반복자의 마법…

  2. sub's me2DAY Says:

    이흥섭의 생각…

    Python 제너레이터+반복자의 마법…

  3. ㄴㅇㄱ Says:

    음. 이거는 루비 1.9에서도 잘 될꺼입니다. 증명은 다른 분이 하실거임 ㅋㅋㅋㅋㅋㅋㅋㅋㅋ

  4. yy Says:

    우왕 잘 봤어요.

Powered by WordPress. Styled by Hong, MinHee. XML Feed, Comments XML Feed.