Code Metaphor

Programming, Writing, Reading, Thoughts…

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

Archive for September, 2009

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

Tuesday, September 15th, 2009

내가 무수히 많은 언어 가운데 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))과 같다. 

야크 털깎기

Friday, September 11th, 2009

아래는 LangDev IRC 채널에서 7월 15일에 했던 대화. (중간에 껴있는 다른 주제의 이야기는 제외.)

11:49 <@sanxiyn> 또다시 yak shaving의 신비한 세계
11:51 <@sanxiyn> yak shaving이 뭔지 다 아시죠?
11:51 <디토군> 방금 찾아보고 왔음
11:51 <@mana> (조용히 설명을 기대중)
11:51 <@sanxiyn> 나무를 베려고 하는데
11:52 <@sanxiyn> 도끼질을 하다가
11:52 <@sanxiyn> 도끼가 더 잘 들면 나무를 쉽게 벨텐데 해서
11:52 <@sanxiyn> 도끼 날을 세우다가
11:52 <@sanxiyn> 도끼 가는 돌이 더 좋으면 도끼 날을 더 빨리 세울텐데 해서
11:52 <@sanxiyn> 좋은 숫돌이 있는 곳을 수소문해 보니
11:52 <@mana> …
11:52 <&홍민희> 그거 전형적인 제 행동이네요
11:52 <@sanxiyn> 저 멀이 어디에 세계 최고의 숫돌이 난다고
11:52 <@sanxiyn> 거기까지 야크를 타고 가려다가
11:52 <@mana> 항상하던 짓이라서 타이핑을 할 수 없었습니다
11:52 <@sanxiyn> 야크 털을 깎아서…
11:52 <@sanxiyn> etc.
11:52 <@mana> …
11:53 <@sanxiyn> Jargon File에는 자세한 내용은 안 나오네요.
11:53 <@sanxiyn> http://www.catb.org/~esr/jargon/html/Y/yak-shaving.html
11:55 <@sanxiyn> 근데 저도 현재 yak shaving 중
11:55 <@sanxiyn> 리커전이 너무 깊어서 도저히 이런 공개적인 자리에서 말할 수가 없다는;
11:55 <@sanxiyn> 창피;
11:58 <디토군> 흠 나도 yak shaving을 하고 있던가
11:58 <@sanxiyn> 디토군: 너무 깊지 않으면 소개좀~
11:58 <@mana> 다들 살다보면 그런경우 꽤 많지 않나요?
11:58 <디토군> 딱히 그런 것 같지는…
11:58 <@mana> 나만 그런가
11:58 <@mana> …
11:58 <@sanxiyn> mana: 프로그래머들만 그래요
11:58 <디토군> 그래도 일단 Django에 붙은 걸로 yak shaving은 해결된 듯
11:58 <@sanxiyn> 뭐랄까 우수한 프로그래머들의 직업병
11:59 <디토군> 애니메타 코드를 재작성하고 있긴 한데
11:59 <디토군> (…….)
11:59 <@sanxiyn> yak shaving이라 함은 예를 들어
11:59 <@sanxiyn> 애니메타 코드를 재작성하고 있는데
12:00 <@sanxiyn> 테스트하다가 CSS가 어디 미운 곳을 찾았는데
12:00 <@sanxiyn> 고치려니 짜증나서 CSS 마크업 언어를 찾다가 LESS를 발견했는데
12:00 <@sanxiyn> LESS 그냥 쓰면 되는데 루비로 되어 있어서 파이썬으로 다시 짜려다가
12:00 <@sanxiyn> 테스트 수트가 없어서 LESS 테스트 수트를 만든다거나
12:00 <@sanxiyn> 이런 걸 말하는 것임
12:00 <@sanxiyn> (특정인을 가리키려는 의도는 없구요)
12:00 <디토군> ㅋ

난 yak shaving으로 엄청난 시간을 낭비하는 사람이다. 예를 들어 VLAAH를 만들기 위해 인하우스 웹 프레임워크를 만들고, PostgreSQL을 사용하는데 마땅한 ORM이 없어서 PostgreSQL 전용의 ORM 프레임워크를 만들고, 데이터베이스 스키마 마이그레이션을 하기 위해 RoR에 있는 것과 비슷하게 또 만들고, ORM에서 블럭을 사용하거나 웹 프레임워크에서 액션을 고차함수로 주고 받고 싶어서 Phunctional을 만들고1… 그래서 지금은 VLAAH 코드의 중요한 부분에 모두 나의 책임이 엮여져 있기 때문에 야간개발팀에서 레거시 코드에 대한 다굴을 가장 많이 당하는 것도 나다.

이 블로그를 오랫동안 구독하셨던 분들은 기억하실 수도 있겠지만, 나는 Metaphor라는 언어도 하나 만들고 있다. 이것 역시 동기는 결국 yak shaving이다. 고등학교 2학년 때(5년이나 되었네?) 게임 제작 동아리에서 활동했는데, 게임에서 사용할 스크립트 언어를 찾아보라는 재석 선배2의 지시에 따라 Lua 같은 미니멀한 디자인의 언어를 찾다가 ‘아 뭔가 다 좀 부족해’(솔직히 그 게임에 쓰기엔 충분하고도 남았다)라는 생각을 하게 되었고, 그 뒤로 언어 구상을…

이르니아 연대기 사이트를 거의 7년 넘게 안 만들고 있는 이유도 비슷하다. 이르니아 연대기 사이트를 만들어야지. 근데 이건 일반적인 RDBMS로 설계하긴 애매한 데이터 모델이야. 온톨로지 데이터베이스가 적당하겠군? 하지만 이미 있는 것들은 쿼리를 위한 DSL이 이미 정해져있네? 난 SQL 싫어서3 완벽한 ORM을 추구하는 사람이야. 객체 맵핑을 쉽게 할 수 있는 온톨로지 데이터베이스를 먼저 만들어야겠군. 그런데 클라이언트 언어로 자연스럽게 쿼리할 수 있게 하려면 Lisp 같이 homoiconic한 언어를 써야겠네? 근데 난 S-expression을 그렇게까지 좋아하지도 않고 마침 내가 구상중인 언어 Metaphor도 homoiconic하지. 아 그럼 일단 Metaphor를 구현해야겠다. 근데 Metaphor는 성능 때문에 그냥 내 취향상 컴파일러를 만들어야겠어. 근데 역시 컴파일러는 GHC처럼 부트스트랩을 해야 개간지가 나잖아? 그럼 일단 Metaphor를 Python으로 최소한의 명세만 구현해놓고, Metaphor로 Metaphor 컴파일러를 구현해야겠네. Python으로 파서를 만드려면 역시 pyparsing이 편한가? 근데 pyparsing의 DSEL은 내 마음에 안들어. 더 깔끔하게 내가 직접 파서 프레임워크를 작성…

이게 내가 5년 넘게 만들어낸 게 별로 없는 이유 같다.


  1. 사실 VLAAH 만드려고 Phunctional을 만들었던 것은 아니지만… VLAAH에서 사용하기 위한 기능을 많이 추가한 것은 사실. 근데 PHP 5.3은 어설프지만 람다를 지원하게 되었다. 그래서 Phunctional은 이제 메인테인 안함. 

  2. shinvee 님. 지금은 야간개발팀 팀장인데, 그 때는 게임 동아리 선배였다. 

  3. 근데 내가 회사에서 사용하는 언어의 비율은 SQL이 50%, Python 30%, PHP 20% 정도. 게다가 주로 작성하는 SQL들은 짧은 것들이 아니라 SELECT 문 하나인데 보통 15줄이 넘어가고 길게는 50줄 넘는 경우도 있다. 난 불행하지 않아… 난 행복해… 

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