이터레이터와 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의 동작 방식