이터레이터와 for 문

이터레이터와 for 문

for 문은 컨테이너의 요소를 하나씩 꺼내며 반복하는 방법이에요. 그러나 정작 그 일을 하는 건 for 문이 아니라 이터레이터(iterator)죠. 이 섹션에서는 이터레이터가 무엇이고, for 문이 어떻게 이터레이터와 함께 동작하는지, 그리고 좀 더 자유롭게 쓸 수 있는 closure 이터레이터까지 차근차근 살펴볼게요.

출처: Nim Manual

본문

for 문은 컨테이너의 요소들을 순회하기 위한 추상적인 메커니즘이에요. 그 역할을 맡은 게 바로 iterator죠. while 문과 마찬가지로 for 문도 암묵적 블록(implicit block)을 열어요. 그래서 break 문으로 루프를 빠져나올 수 있답니다.

for 루프는 반복 변수(iteration variable)를 선언해요. 이 변수들의 유효 범위는 루프 몸체의 끝까지예요. 반복 변수의 타입은 이터레이터의 반환 타입으로부터 추론된다는 점도 기억해 두면 좋아요.

이터레이터는 프로시저와 비슷한데, for 루프의 문맥에서 호출될 수 있다는 점이 달라요. 이터레이터는 추상 타입에 대한 순회 방식을 정의할 수 있게 해 주죠. 호출된 이터레이터 안의 yield 문은 for 루프 실행에서 아주 핵심적인 역할을 해요. yield 문에 도달하면 그 데이터가 for 루프 변수에 묶이고, 제어는 for 루프 몸체로 넘어가거든요. 이터레이터의 지역 변수와 실행 상태는 호출 사이에 자동으로 저장돼요. 예를 볼게요.

# this definition exists in the system module
iterator items*(a: string): char {.inline.} =
  var i = 0
  while i < len(a):
    yield a[i]
    inc(i)

for ch in items("hello world"): # `ch` is an iteration variable
  echo ch

컴파일러는 마치 프로그래머가 아래처럼 작성한 것과 같은 코드를 생성해요.

var i = 0
while i < len(a):
  var ch = a[i]
  echo ch
  inc(i)

이때 이터레이터가 튜플을 yield 하면, 튜플의 성분 개수만큼 반복 변수를 쓸 수 있어요. i번째 반복 변수의 타입은 i번째 성분의 타입이 되죠. 다시 말해 for 루프 문맥에서 암묵적인 튜플 언패킹이 지원된다는 뜻이에요.

암묵적인 items/pairs 호출

for 루프의 표현식 e가 이터레이터를 가리키는 게 아니고, 반복 변수가 정확히 1개라면 그 표현식은 items(e)로 다시 쓰여요. 즉 items 이터레이터가 암묵적으로 호출되는 거예요.

for x in [1,2,3]: echo x

반복 변수가 정확히 2개라면 pairs 이터레이터가 암묵적으로 호출돼요.

items/pairs라는 식별자에 대한 심볼 탐색은 이 재작성 단계 이후에 이뤄져요. 그래서 items/pairs의 모든 오버로드가 고려될 수 있답니다.

일급 이터레이터

Nim에는 두 종류의 이터레이터가 있어요: inline 이터레이터와 closure 이터레이터예요. inline 이터레이터는 컴파일러가 항상 인라인 처리해서 이 추상화에 드는 오버헤드가 0이지만, 그만큼 코드 크기가 크게 늘어날 수 있어요.

주의할 점이 있어요. inline 이터레이터를 쓰는 for 루프의 몸체는 이터레이터 코드에 등장하는 각 yield 문마다 인라인돼요. 그래서 가능하면 코드에 yield를 하나만 두도록 리팩터링해서 코드 비대화를 피하는 게 이상적이에요.

inline 이터레이터는 2급 시민이에요. 즉 템플릿, 매크로, 다른 inline 이터레이터처럼 인라인 처리하는 코드 시설에만 매개변수로 넘길 수 있죠.

그에 비해 closure 이터레이터는 훨씬 자유롭게 전달할 수 있어요.

iterator count0(): int {.closure.} =
  yield 0

iterator count2(): int {.closure.} =
  var x = 1
  yield x
  inc x
  yield x

proc invoke(iter: iterator(): int {.closure.}) =
  for x in iter(): echo x

invoke(count0)
invoke(count2)

closure 이터레이터와 inline 이터레이터에는 몇 가지 제약이 있어요.

  • 현재로서는 closure 이터레이터를 컴파일 타임에 실행할 수 없어요.
  • return은 closure 이터레이터에서는 허용되지만 inline 이터레이터에서는 허용되지 않아요(그래도 거의 쓸 일이 없죠). return은 순회를 끝내는 역할을 해요.
  • inline 이터레이터는 재귀가 불가능해요.
  • inline이든 closure든 이터레이터에는 특별한 result 변수가 없어요.

{.closure.}{.inline.}도 명시적으로 표시하지 않은 이터레이터는 기본적으로 inline으로 취급돼요. 다만 이 기본값은 구현의 향후 버전에서 바뀔 수 있어요.

이터레이터 타입은 항상 암묵적으로 closure 호출 규약을 가져요.

이름 있는 이터레이터와 달리, 익명 이터레이터 표현식은 이터레이터 타입으로 평가돼요. 실제로는 이렇게 된답니다. {.closure.}가 붙지 않은 이름 있는 이터레이터 선언은 inline이 기본이지만, let it = iterator(): int = yield 1 같은 표현식은 호출 가능한 closure 이터레이터 값을 만들어 내요.

다음 예시는 이터레이터로 협력형 태스킹(collaborative tasking) 시스템을 구현하는 방법을 보여줘요.

# simple tasking:
type
  Task = iterator (ticker: int)

iterator a1(ticker: int) {.closure.} =
  echo "a1: A"
  yield
  echo "a1: B"
  yield
  echo "a1: C"
  yield
  echo "a1: D"

iterator a2(ticker: int) {.closure.} =
  echo "a2: A"
  yield
  echo "a2: B"
  yield
  echo "a2: C"

proc runTasks(t: varargs[Task]) =
  var ticker = 0
  while true:
    let x = t[ticker mod t.len]
    if finished(x): break
    x(ticker)
    inc ticker

runTasks(a1, a2)

내장 함수 system.finished는 이터레이터가 작업을 끝냈는지 판단할 때 쓸 수 있어요. 이미 작업을 마친 이터레이터를 호출하려 해도 예외가 발생하지 않아요.

다만 system.finished는 쓰기 까다롭다는 점을 알아야 해요. 이터레이터가 끝난 뒤에도 한 번 더 반복해야 true를 반환하기 때문이에요.

iterator mycount(a, b: int): int {.closure.} =
  var x = a
  while x <= b:
    yield x
    inc x

var c = mycount # instantiate the iterator
while not finished(c):
  echo c(1, 3)

# Produces
1
2
3
0

그래서 실제로는 다음처럼 작성해야 해요.

var c = mycount # instantiate the iterator
while true:
  let value = c(1, 3)
  if finished(c): break # and discard 'value'!
  echo value

마치 이터레이터가 실제로 (value, done) 쌍을 반환하고 finished가 숨겨진 done 필드에 접근한다고 생각하면 이해하기 쉬워요.

closure 이터레이터는 재개 가능한 함수라서 매 호출마다 인자를 넘겨줘야 해요. 이 제약을 우회하려면 바깥쪽 팩토리 프로시저의 매개변수를 포획하면 되죠.

proc mycount(a, b: int): iterator (): int =
  result = iterator (): int =
    var x = a
    while x <= b:
      yield x
      inc x

let foo = mycount(1, 4)

for f in foo():
  echo f

for 루프 매크로를 쓰면 호출을 inline 이터레이터처럼 더 자연스럽게 만들 수 있어요.

import std/macros
macro toItr(x: ForLoopStmt): untyped =
  let expr = x[0]
  let call = x[1][1] # Get foo out of toItr(foo)
  let body = x[2]
  result = quote do:
    block:
      let itr = `call`
      for `expr` in itr():
          `body`

for f in toItr(mycount(1, 4)): # using early `proc mycount`
  echo f

백엔드 함수 호출 장치가 완전히 동원되기 때문에, closure 이터레이터 호출은 보통 inline 이터레이터보다 비용이 더 높아요. 이렇게 호출 지점에 매크로 래퍼를 두른 것은 그 점을 상기시키는 유용한 장치가 될 수 있죠.

팩토리 프로시저는 일반 프로시저이므로 재귀가 가능해요. 위 매크로는 이런 재귀가 마치 재귀 이터레이터처럼 보이게 해 주죠. 예를 들어:

proc recCountDown(n: int): iterator(): int =
  result = iterator(): int =
    if n > 0:
      yield n
      for e in toItr(recCountDown(n - 1)):
        yield e

for i in toItr(recCountDown(6)): # Emits: 6 5 4 3 2 1
  echo i

템플릿과 매크로에 이터레이터를 전달하는 방법은 iterable도 함께 보면 좋아요.

더 알아보기 (Learn more)

  • Nim Manual: Iterators and the for statement
  • system.items, system.pairs 같은 표준 이터레이터와 for 루프의 암묵적 items/pairs 호출 규칙
  • {.closure.}/{.inline.} 이터레이터의 차이와 finished, yield의 동작 방식