본문으로 건너뛰기

이터레이터와 range-over-func

이 챕터에서 다루는 것

for ... range는 오랫동안 슬라이스·맵·문자열·채널·정수에만 쓸 수 있었다. Go 1.23에 함수에 대한 range가 들어오면서 사용자 정의 자료구조도 range로 돌 수 있게 됐다.

이 기능이 그냥 문법 설탕이 아닌 이유, 이터레이터를 직접 쓰는 법, 그리고 4-7에서 손으로 쓴 에러 트리 순회를 이터레이터로 다시 쓰는 것까지 다룬다.

문제 — 순회와 처리가 붙어 버린다

4-7에서 에러 트리를 출력하려고 이런 함수를 썼다.

func printTree(err error, depth int) {
fmt.Printf("%s%T: %v\n", strings.Repeat(" ", depth), err, firstLine(err))
switch x := err.(type) {
case interface{ Unwrap() error }:
printTree(x.Unwrap(), depth+1)
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
printTree(e, depth+1)
}
}
}

순회 로직과 출력 로직이 한 함수에 붙어 있다. 이제 "잎 노드만 세고 싶다", "첫 *fieldError에서 멈추고 싶다", "깊이 2까지만 보고 싶다"는 요구가 오면 printTree를 복사해 고치거나, 콜백을 인자로 받게 바꿔야 한다.

콜백을 받는 방식이 전통적인 답이다.

func walkTree(err error, depth int, f func(int, error))

이것도 동작하지만 break가 없다. 중간에 멈추려면 fbool을 돌려주게 하고 호출부에서 전파해야 하며, continue도 없고, defer도 자연스럽지 않다. range 루프가 주는 제어 흐름을 콜백은 흉내 낼 수 없다.

함수에 대한 range가 정확히 이 문제를 푼다. 콜백 방식에 break/continue/return을 얹은 것이다.

range가 받아 주는 세 가지 함수

Go 1.23부터 range 뒤에 다음 세 모양의 함수를 놓을 수 있다.

func(yield func() bool) // 값 없음
func(yield func(V) bool) // 값 하나 → iter.Seq[V]
func(yield func(K, V) bool) // 값 둘 → iter.Seq2[K, V]

iter 패키지가 뒤의 둘에 이름을 붙여 뒀다.

type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

iter.Seq[V]는 그냥 함수 타입이다. 인터페이스도 구조체도 아니다. 이 점이 다른 언어와 크게 다른데, Java의 Iterator나 Python의 __next__는 "다음 값을 달라"고 당기는(pull) 모양인 반면 Go는 미는(push) 모양이다. 이터레이터가 루프를 돌면서 값을 yield에 밀어 넣고, 소비자의 루프 본문이 그 yield가 된다.

examples/05-generics-and-advanced/04-range-over-func/main.go
package main

import (
"fmt"
"iter"
"maps"
"slices"
"strings"
)

// ---- range가 받아 주는 세 가지 함수 모양 ----

// 1. func(yield func() bool) — 값을 안 준다. 횟수만 센다.
func repeat(n int) func(func() bool) {
return func(yield func() bool) {
for range n {
if !yield() {
return
}
}
}
}

// 2. iter.Seq[V] = func(yield func(V) bool) — 값 하나
func countdown(from int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := from; i > 0; i-- {
if !yield(i) {
return // 소비자가 break했다
}
}
}
}

// 3. iter.Seq2[K, V] = func(yield func(K, V) bool) — 값 둘
func words(s string) iter.Seq2[int, string] {
return func(yield func(int, string) bool) {
for i, w := range strings.Fields(s) {
if !yield(i, w) {
return
}
}
}
}

// yield의 반환값을 무시하면 break가 동작하지 않는다.
// range 구현이 패닉으로 잡아 준다.
func brokenCountdown(from int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := from; i > 0; i-- {
yield(i) // 반환값을 버렸다
}
}
}

func main() {
fmt.Print("repeat(3): ")
for range repeat(3) {
fmt.Print("* ")
}
fmt.Println()

fmt.Print("countdown(5): ")
for n := range countdown(5) {
fmt.Print(n, " ")
}
fmt.Println()

fmt.Print("countdown(5) + break: ")
for n := range countdown(5) {
if n < 3 {
break
}
fmt.Print(n, " ")
}
fmt.Println()

for i, w := range words("제네릭 이터레이터 리플렉션") {
fmt.Printf("words[%d]=%s ", i, w)
}
fmt.Println()

// ---- 표준 라이브러리가 주는 이터레이터 ----
langs := []string{"go", "rust", "zig"}
fmt.Println("slices.Values:", slices.Collect(slices.Values(langs)))
for i, v := range slices.All(langs) {
fmt.Printf("All[%d]=%s ", i, v)
}
fmt.Println()
for i, v := range slices.Backward(langs) {
fmt.Printf("Backward[%d]=%s ", i, v)
}
fmt.Println()

// Chunk는 이터레이터를 돌려준다. 슬라이스 조각을 순서대로 낸다.
for c := range slices.Chunk([]int{1, 2, 3, 4, 5}, 2) {
fmt.Print(c, " ")
}
fmt.Println()

m := map[string]int{"a": 1, "b": 2, "c": 3}
// maps.All의 순서는 맵 순회 순서라서 무작위다. 정렬해서 낸다.
pairs := slices.SortedFunc(seqOfStrings(maps.All(m)), strings.Compare)
fmt.Println("maps.All(정렬):", pairs)

// AppendSeq는 이터레이터를 기존 슬라이스 뒤에 붙인다.
fmt.Println("AppendSeq:", slices.AppendSeq([]int{0}, countdown(3)))

// ---- yield의 반환값을 무시하면 패닉이다 ----
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("yield 반환값 무시:", rec)
}
}()
for n := range brokenCountdown(5) {
if n < 4 {
break
}
}
}()
}

// seqOfStrings는 Seq2를 "k=v" 문자열 Seq로 바꾼다. 어댑터의 가장 단순한 형태다.
func seqOfStrings(seq iter.Seq2[string, int]) iter.Seq[string] {
return func(yield func(string) bool) {
for k, v := range seq {
if !yield(fmt.Sprintf("%s=%d", k, v)) {
return
}
}
}
}
go run ./04-range-over-func
repeat(3): * * *
countdown(5): 5 4 3 2 1
countdown(5) + break: 5 4 3
words[0]=제네릭 words[1]=이터레이터 words[2]=리플렉션
slices.Values: [go rust zig]
All[0]=go All[1]=rust All[2]=zig
Backward[2]=zig Backward[1]=rust Backward[0]=go
[1 2] [3 4] [5]
maps.All(정렬): [a=1 b=2 c=3]
AppendSeq: [0 3 2 1]
yield 반환값 무시: runtime error: range function continued iteration after function for loop body returned false

제어 흐름이 어떻게 뒤집히는가

for n := range countdown(5) { ... }를 컴파일러는 대략 이렇게 바꾼다.

countdown(5)(func(n int) bool {
// 루프 본문이 여기 들어간다
// break → return false
// continue → return true
// return → 바깥 함수까지 빠져나가는 코드가 추가된다
return true
})

루프 본문이 yield 함수가 된다. 그래서 이터레이터를 쓰는 쪽은 평범한 for처럼 보이지만, 실제로는 클로저가 만들어져 호출된다.

breakyieldfalse를 돌려주는 것으로 전달된다. 이터레이터 구현이 if !yield(v) { return }을 지켜야 하는 이유다. 지키지 않으면 이렇게 된다.

yield 반환값 무시: runtime error: range function continued iteration after function for loop body returned false

런타임이 잡아 준다. 이터레이터를 잘못 구현해도 조용히 틀리지 않는다는 뜻이고, Go가 이 기능을 안전하게 만든 핵심 장치다.

:::warning if !yield(v) { return } 이 관용구를 외운다 이터레이터 구현의 모든 yield 호출은 반환값을 확인해야 한다. 반복이 중첩되어 있으면 안쪽에서 바깥쪽까지 false를 전파해야 한다. 아래 트리 순회 예제에서 그 형태를 본다. :::

slicesmaps의 이터레이터

함수방향반환
slices.All(s)슬라이스 → 이터레이터iter.Seq2[int, E]
slices.Values(s)슬라이스 → 이터레이터iter.Seq[E]
slices.Backward(s)슬라이스 → 이터레이터 (역순)iter.Seq2[int, E]
slices.Chunk(s, n)슬라이스 → 조각 이터레이터iter.Seq[Slice]
maps.All(m) / Keys / Values맵 → 이터레이터iter.Seq2/Seq
slices.Collect(seq)이터레이터 → 슬라이스[]E
slices.Sorted(seq)이터레이터 → 정렬된 슬라이스[]E
slices.AppendSeq(s, seq)이터레이터 → 기존 슬라이스 뒤Slice
maps.Collect(seq2)이터레이터 → 맵map[K]V

slices.Values(s)가 이상해 보일 수 있다. 슬라이스는 그냥 range로 돌면 되는데 왜 이터레이터로 바꾸는가? 이터레이터를 받는 함수에 슬라이스를 넣기 위해서다. 슬라이스와 맵과 트리와 파일 스트림이 전부 iter.Seq로 통일되면, 그것들을 다루는 함수를 한 번만 쓰면 된다.

사용자 정의 이터레이터

이제 직접 만든다.

examples/05-generics-and-advanced/04-custom-iterators/main.go
package main

import (
"bufio"
"cmp"
"fmt"
"iter"
"slices"
"strings"
)

// ---- 이진 탐색 트리 ----

type Tree[T cmp.Ordered] struct {
root *node[T]
size int
}

type node[T cmp.Ordered] struct {
val T
left, right *node[T]
}

func (t *Tree[T]) Insert(v T) {
t.root = insert(t.root, v, &t.size)
}

func insert[T cmp.Ordered](n *node[T], v T, size *int) *node[T] {
if n == nil {
*size++
return &node[T]{val: v}
}
switch cmp.Compare(v, n.val) {
case -1:
n.left = insert(n.left, v, size)
case 1:
n.right = insert(n.right, v, size)
}
return n
}

func (t *Tree[T]) Len() int { return t.size }

// All은 중위 순회 이터레이터다. 관례상 컬렉션의 기본 이터레이터 이름은 All이다.
// 재귀 도우미가 bool을 돌려주게 만들어 조기 종료를 위로 전파한다.
func (t *Tree[T]) All() iter.Seq[T] {
return func(yield func(T) bool) {
walk(t.root, yield)
}
}

func walk[T cmp.Ordered](n *node[T], yield func(T) bool) bool {
if n == nil {
return true
}
return walk(n.left, yield) && yield(n.val) && walk(n.right, yield)
}

// Backward는 역순 순회다. 순서가 여럿이면 이름으로 구분한다.
func (t *Tree[T]) Backward() iter.Seq[T] {
return func(yield func(T) bool) {
walkBack(t.root, yield)
}
}

func walkBack[T cmp.Ordered](n *node[T], yield func(T) bool) bool {
if n == nil {
return true
}
return walkBack(n.right, yield) && yield(n.val) && walkBack(n.left, yield)
}

// ---- 어댑터: 이터레이터를 받아 이터레이터를 돌려준다 ----

func Filter[T any](seq iter.Seq[T], keep func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for v := range seq {
if keep(v) && !yield(v) {
return
}
}
}
}

func MapSeq[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
return func(yield func(U) bool) {
for v := range seq {
if !yield(f(v)) {
return
}
}
}
}

func Take[T any](seq iter.Seq[T], n int) iter.Seq[T] {
return func(yield func(T) bool) {
if n <= 0 {
return
}
count := 0
for v := range seq {
if !yield(v) {
return
}
count++
if count == n {
return
}
}
}
}

// ---- 에러가 날 수 있는 이터레이터는 Seq2[T, error]로 만든다 ----

// Lines는 r에서 한 줄씩 읽는다. 한 번만 순회할 수 있는 일회용 이터레이터다.
func Lines(r *strings.Reader) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
sc := bufio.NewScanner(r)
for sc.Scan() {
if !yield(sc.Text(), nil) {
return
}
}
if err := sc.Err(); err != nil {
yield("", err)
}
}
}

func main() {
t := &Tree[int]{}
for _, v := range []int{5, 3, 8, 1, 4, 7, 9, 5} {
t.Insert(v)
}
fmt.Println("크기:", t.Len())
fmt.Println("중위 순회:", slices.Collect(t.All()))
fmt.Println("역순 순회:", slices.Collect(t.Backward()))

// 조기 종료가 재귀를 뚫고 올라가는지 확인한다.
fmt.Print("5를 만나면 중단: ")
for v := range t.All() {
if v == 5 {
break
}
fmt.Print(v, " ")
}
fmt.Println()

// 어댑터를 겹친다. 중간 슬라이스가 하나도 생기지 않는다.
pipeline := Take(
MapSeq(
Filter(t.All(), func(v int) bool { return v%2 == 1 }),
func(v int) string { return fmt.Sprintf("<%d>", v) },
),
3,
)
fmt.Println("홀수→문자열→3개:", slices.Collect(pipeline))

// 슬라이스로 같은 일을 하면 단계마다 새 슬라이스가 생긴다.
odds := []int{}
for _, v := range slices.Collect(t.All()) {
if v%2 == 1 {
odds = append(odds, v)
}
}
fmt.Println("슬라이스 방식 중간 결과:", odds)

// ---- 에러를 내는 이터레이터 ----
src := strings.NewReader("첫 줄\n둘째 줄\n셋째 줄")
for line, err := range Lines(src) {
if err != nil {
fmt.Println("읽기 실패:", err)
break
}
fmt.Println("줄:", line)
}

// 일회용이라 두 번째 순회는 아무것도 내지 않는다.
count := 0
for range Lines(src) {
count++
}
fmt.Println("두 번째 순회 줄 수:", count)
}
go run ./04-custom-iterators
크기: 7
중위 순회: [1 3 4 5 7 8 9]
역순 순회: [9 8 7 5 4 3 1]
5를 만나면 중단: 1 3 4
홀수→문자열→3개: [<1> <3> <5>]
슬라이스 방식 중간 결과: [1 3 5 7 9]
줄: 첫 줄
줄: 둘째 줄
줄: 셋째 줄
두 번째 순회 줄 수: 0

재귀 순회에서 조기 종료 전파하기

func walk[T cmp.Ordered](n *node[T], yield func(T) bool) bool {
if n == nil {
return true
}
return walk(n.left, yield) && yield(n.val) && walk(n.right, yield)
}

한 줄에 전부 들어 있다. &&가 단축 평가이므로 어느 하나가 false가 되면 나머지를 건너뛰고 false가 위로 올라간다. Tree.All이 이 도우미를 감싸고, 반환값을 버린다(최상위에서는 더 전파할 곳이 없다).

break가 재귀 여섯 단계를 뚫고 올라오는 것을 출력에서 확인할 수 있다.

5를 만나면 중단: 1 3 4

어댑터가 진짜 이득인 지점

Take(MapSeq(Filter(t.All(), 홀수), 문자열로), 3)

이 파이프라인은 중간 슬라이스를 하나도 만들지 않는다. 그리고 게으르다. Take(_, 3)이 세 개를 받으면 false를 돌려주고, 그것이 MapSeqFilterwalk까지 전파되어 트리 순회 자체가 멈춘다. 무한 시퀀스나 큰 파일에서 결정적인 차이를 만든다.

슬라이스 방식은 Collect로 7개를 다 만들고, 필터로 5개짜리를 또 만들고, 변환으로 또 만든 뒤 3개를 잘라낸다.

:::info 그래도 대부분은 슬라이스가 낫다 이터레이터 파이프라인은 클로저 호출이 겹겹이 쌓인다. 원소가 수천 개 이하이고 파이프라인이 두세 단계라면, slices.Collect로 받아 평범한 for 루프를 도는 편이 읽기 쉽고 대체로 더 빠르다.

이터레이터가 이기는 자리는 데이터가 크거나, 끝이 없거나, 조기 종료가 흔하거나, 소스가 여러 종류일 때다. 습관적으로 파이프라인을 만들지 않는다. :::

이터레이터의 이름 규칙

iter 패키지 문서가 관례를 정해 두었다.

이름의미
All()컬렉션의 기본 순회. 이름이 하나면 이것
Backward(), Preorder()순서가 여럿일 때 순서를 이름에 넣는다
Cities(), Languages()한 타입이 여러 시퀀스를 가질 때 무엇을 도는지
Scan(min, max)설정이 필요하면 인자를 받는다

Iterator()Iter()는 Go 이름이 아니다.

에러는 Seq2[T, error]

Lines가 그 형태다. 표준 라이브러리도 이 관례를 따른다.

for line, err := range Lines(r) {
if err != nil {
// 처리하고 break
break
}
...
}

에러가 났을 때 yield(zero, err)를 한 번 부르고 끝내는 것이 관례다. 에러를 낸 뒤 계속 순회하지 않는다.

일회용 이터레이터

두 번째 순회 줄 수: 0

Lines*strings.Reader를 소비하므로 두 번째 순회에서는 아무것도 나오지 않는다. slices.ValuesTree.All은 몇 번이든 다시 돌 수 있다. 일회용이면 문서에 반드시 적는다iter 패키지 문서가 "It returns a single-use iterator."라고 적으라고 명시한다.

4-7의 에러 트리를 다시 쓴다

약속했던 부분이다. 같은 순회를 이터레이터로 바꾸면 무엇이 달라지는지 보자.

examples/05-generics-and-advanced/04-error-tree/main.go
package main

import (
"errors"
"fmt"
"iter"
"strings"
)

var (
errNameRequired = errors.New("이름은 필수다")
errBadEmail = errors.New("이메일 형식이 아니다")
errAgeRange = errors.New("나이 범위를 벗어났다")
)

type fieldError struct {
Field string
Err error
}

func (e *fieldError) Error() string { return e.Field + ": " + e.Err.Error() }
func (e *fieldError) Unwrap() error { return e.Err }

// ---- 4-7에서 쓴 재귀 출력 함수 ----
// 순회와 출력이 한 함수에 붙어 있어서, 개수를 세거나 조기 종료하려면 고쳐 써야 한다.
func printTree(err error, depth int) {
if err == nil {
return
}
fmt.Printf("%s%T: %v\n", strings.Repeat(" ", depth), err, firstLine(err))

switch x := err.(type) {
case interface{ Unwrap() error }:
printTree(x.Unwrap(), depth+1)
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
printTree(e, depth+1)
}
}
}

// ---- 같은 순회를 이터레이터로 ----
// 깊이와 에러를 내보내기만 한다. 무엇을 할지는 소비자가 정한다.
func Walk(err error) iter.Seq2[int, error] {
return func(yield func(int, error) bool) {
walk(err, 0, yield)
}
}

func walk(err error, depth int, yield func(int, error) bool) bool {
if err == nil {
return true
}
if !yield(depth, err) {
return false
}
switch x := err.(type) {
case interface{ Unwrap() error }:
return walk(x.Unwrap(), depth+1, yield)
case interface{ Unwrap() []error }:
for _, e := range x.Unwrap() {
if !walk(e, depth+1, yield) {
return false
}
}
}
return true
}

func main() {
joined := errors.Join(
&fieldError{Field: "name", Err: errNameRequired},
fmt.Errorf("검증 단계: %w", &fieldError{Field: "email", Err: errBadEmail}),
&fieldError{Field: "age", Err: fmt.Errorf("%w: %d", errAgeRange, 999)},
)
err := fmt.Errorf("사용자 생성 실패: %w", joined)

fmt.Println("--- 4-7의 printTree")
printTree(err, 0)

fmt.Println("--- 같은 트리를 이터레이터로")
for depth, e := range Walk(err) {
fmt.Printf("%s%T: %v\n", strings.Repeat(" ", depth), e, firstLine(e))
}

// 순회를 분리했으므로 소비자가 자유롭게 조합한다.
fmt.Println("--- 잎 노드(더 감쌀 것이 없는 에러)만")
for _, e := range Walk(err) {
if !hasChildren(e) {
fmt.Println(" *", e)
}
}

fmt.Println("--- 센티널 개수 세기")
n := 0
for _, e := range Walk(err) {
if errors.Is(e, errNameRequired) || errors.Is(e, errBadEmail) || errors.Is(e, errAgeRange) {
n++
}
}
fmt.Println("센티널을 품은 노드 수:", n)

fmt.Println("--- 첫 fieldError에서 중단")
for _, e := range Walk(err) {
if fe, ok := e.(*fieldError); ok {
fmt.Println("찾음:", fe.Field)
break
}
}

fmt.Println("--- 깊이 1까지만")
for depth, e := range Walk(err) {
if depth > 1 {
continue
}
fmt.Printf("[%d] %v\n", depth, firstLine(e))
}
}

func hasChildren(err error) bool {
switch err.(type) {
case interface{ Unwrap() error }, interface{ Unwrap() []error }:
return true
}
return false
}

func firstLine(err error) string {
s := err.Error()
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}
go run ./04-error-tree
--- 4-7의 printTree
*fmt.wrapError: 사용자 생성 실패: name: 이름은 필수다 …
*errors.joinError: name: 이름은 필수다 …
*main.fieldError: name: 이름은 필수다
*errors.errorString: 이름은 필수다
*fmt.wrapError: 검증 단계: email: 이메일 형식이 아니다
*main.fieldError: email: 이메일 형식이 아니다
*errors.errorString: 이메일 형식이 아니다
*main.fieldError: age: 나이 범위를 벗어났다: 999
*fmt.wrapError: 나이 범위를 벗어났다: 999
*errors.errorString: 나이 범위를 벗어났다
--- 같은 트리를 이터레이터로
*fmt.wrapError: 사용자 생성 실패: name: 이름은 필수다 …
*errors.joinError: name: 이름은 필수다 …
*main.fieldError: name: 이름은 필수다
*errors.errorString: 이름은 필수다
*fmt.wrapError: 검증 단계: email: 이메일 형식이 아니다
*main.fieldError: email: 이메일 형식이 아니다
*errors.errorString: 이메일 형식이 아니다
*main.fieldError: age: 나이 범위를 벗어났다: 999
*fmt.wrapError: 나이 범위를 벗어났다: 999
*errors.errorString: 나이 범위를 벗어났다
--- 잎 노드(더 감쌀 것이 없는 에러)만
* 이름은 필수다
* 이메일 형식이 아니다
* 나이 범위를 벗어났다
--- 센티널 개수 세기
센티널을 품은 노드 수: 10
--- 첫 fieldError에서 중단
찾음: name
--- 깊이 1까지만
[0] 사용자 생성 실패: name: 이름은 필수다 …
[1] name: 이름은 필수다 …

무엇이 좋아졌는가

첫 두 블록의 출력이 같다. 즉 순회 로직은 그대로다. 바뀐 것은 누가 무엇을 할지 정하는가다.

  • printTree는 출력만 한다. 다른 일을 하려면 함수를 고쳐야 한다.
  • Walk는 아무것도 하지 않는다. 출력, 세기, 필터링, 조기 중단이 전부 소비자 코드 네 줄로 표현된다.

break가 그냥 동작한다. "첫 fieldError에서 중단" 블록에서 break 한 줄로 재귀 전체가 멈춘다. 콜백 방식이었다면 bool 반환과 전파 코드를 손으로 써야 했다.

continue도 그냥 동작한다. "깊이 1까지만" 블록이 그렇다.

:::warning errors.Is를 트리 순회와 섞으면 중복 계산된다 출력의 센티널을 품은 노드 수: 10을 보자. 노드가 10개인데 전부 세어졌다. errors.Is 자체가 래핑 체인을 따라 내려가기 때문이다. 최상위 *fmt.wrapError에 대해 errors.Is(e, errNameRequired)를 물으면 자식들을 뒤져서 true를 준다.

직접 순회하면서 errors.Is를 부르는 것은 거의 항상 중복이다. 트리 전체에서 찾는 것이 목적이라면 루트에 errors.Is 한 번이면 되고, 노드 하나하나를 분류하는 것이 목적이라면 errors.Is가 아니라 ==나 타입 단언을 써야 한다 (4-7). :::

iter.Pull — 밀기를 당기기로 바꾼다

push 이터레이터로는 못 하는 일이 있다. 시퀀스 두 개를 나란히 진행하는 것이다. range 두 개를 동시에 한 걸음씩 돌릴 방법이 없기 때문이다.

func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())
examples/05-generics-and-advanced/04-pull/main.go
package main

import (
"cmp"
"fmt"
"iter"
"slices"
)

// Merge는 정렬된 두 시퀀스를 하나로 합친다.
// range 두 개를 동시에 진행할 수 없으므로 push 이터레이터로는 쓸 수 없다.
func Merge[T cmp.Ordered](a, b iter.Seq[T]) iter.Seq[T] {
return func(yield func(T) bool) {
nextA, stopA := iter.Pull(a)
defer stopA()
nextB, stopB := iter.Pull(b)
defer stopB()

va, okA := nextA()
vb, okB := nextB()
for okA && okB {
if cmp.Less(va, vb) {
if !yield(va) {
return
}
va, okA = nextA()
} else {
if !yield(vb) {
return
}
vb, okB = nextB()
}
}
for okA {
if !yield(va) {
return
}
va, okA = nextA()
}
for okB {
if !yield(vb) {
return
}
vb, okB = nextB()
}
}
}

// Pairs는 연속한 두 값을 짝지어 낸다. 이것도 한 번에 두 개를 꺼내야 하는 경우다.
func Pairs[T any](seq iter.Seq[T]) iter.Seq2[T, T] {
return func(yield func(T, T) bool) {
next, stop := iter.Pull(seq)
defer stop()
for {
v1, ok1 := next()
if !ok1 {
return
}
v2, ok2 := next() // 홀수 개면 v2는 제로값이다
if !yield(v1, v2) {
return
}
if !ok2 {
return
}
}
}
}

func main() {
a := slices.Values([]int{1, 4, 7, 10})
b := slices.Values([]int{2, 3, 8})
fmt.Println("Merge:", slices.Collect(Merge(a, b)))

// 이터레이터는 재사용 가능하다. slices.Values는 호출될 때마다 처음부터 돈다.
fmt.Println("Merge 재실행:", slices.Collect(Merge(a, b)))

for x, y := range Pairs(slices.Values([]string{"가", "나", "다", "라", "마"})) {
fmt.Printf("(%s,%q) ", x, y)
}
fmt.Println()

// ---- Pull을 직접 쓰기 ----
next, stop := iter.Pull(slices.Values([]int{10, 20, 30}))
v1, _ := next()
v2, _ := next()
fmt.Println("직접 pull:", v1, v2)
stop() // 남은 것을 안 볼 것이므로 정리한다

// stop 이후에는 항상 (제로값, false)다.
v3, ok := next()
fmt.Println("stop 이후:", v3, ok)

// 조기 종료해도 defer stop()이 있으므로 Merge 안쪽 이터레이터가 정리된다.
fmt.Print("Merge + break: ")
for v := range Merge(slices.Values([]int{1, 3, 5}), slices.Values([]int{2, 4})) {
if v > 3 {
break
}
fmt.Print(v, " ")
}
fmt.Println()
}
go run ./04-pull
Merge: [1 2 3 4 7 8 10]
Merge 재실행: [1 2 3 4 7 8 10]
(가,"나") (다,"라") (마,"")
직접 pull: 10 20
stop 이후: 0 false
Merge + break: 1 2 3

defer stop()은 필수다

iter.Pull은 내부적으로 코루틴을 만든다. 이터레이터 함수를 별도의 실행 흐름에서 돌리다가 next()가 불릴 때마다 한 걸음 진행시키고 다시 멈춘다.

끝까지 소비하지 않고 그만두면 그 실행 흐름이 남는다. stop()이 그것을 정리한다. 그래서 iter.Pull을 쓰는 코드는 예외 없이 이 모양이다.

next, stop := iter.Pull(seq)
defer stop()

stop()을 여러 번 불러도 안전하고, stop() 이후 next()는 항상 (제로값, false)다.

시퀀스를 끝까지 다 돌았으면 stop()을 안 불러도 누수는 없다. 그래도 defer로 거는 것이 습관이다 — 나중에 break가 추가되면 그때 버그가 되기 때문이다.

Pairs의 출력 (마,"")을 보자. 원소가 홀수 개라 마지막 짝의 두 번째가 제로값이다. ok2를 확인하지 않으면 이런 경계를 놓친다.

:::note Pull은 비싸다 iter.Pull은 코루틴 전환을 동반하므로 값 하나당 비용이 range보다 크다. 두 시퀀스를 나란히 진행해야 하거나, 소비 시점을 직접 통제해야 할 때만 쓴다. 한 시퀀스를 그냥 도는 것이면 range가 답이다. :::

흔히 하는 실수

1. yield의 반환값을 무시한다

for _, v := range items {
yield(v) // break가 무시된다 → 런타임 패닉
if !yield(v) { return } // 이렇게
}

런타임이 잡아 주지만, 잡히는 곳은 소비자가 break를 쓸 때뿐이다. 테스트에서 break를 안 쓰면 발견이 늦는다.

2. 재귀 순회에서 종료를 전파하지 않는다

안쪽 재귀 호출의 bool을 확인하지 않으면 break 후에도 순회가 계속되고, 결국 yield 재호출로 패닉이 난다. walk(n.left, yield) && yield(n.val) && walk(n.right, yield) 형태를 기억한다.

3. iter.Pullstop()을 잊는다

조기 종료 시 코루틴이 남는다. defer stop()을 반사적으로 쓴다.

4. 이터레이터 안에서 컬렉션을 수정한다

range가 도는 도중에 트리에 삽입하거나 맵에서 삭제하면 결과가 정의되지 않는다. 수정이 필요하면 iter 문서가 권하는 대로 위치 타입을 내보내거나, 일단 slices.Collect로 받아 놓고 순회 후에 수정한다.

5. 모든 것을 이터레이터로 만든다

메서드가 []T를 돌려주면 될 자리에 iter.Seq[T]를 쓰면, 호출자는 매번 slices.Collect를 부르게 되고 len()도 못 쓴다. 작은 컬렉션은 슬라이스를 돌려주는 편이 낫다. 이터레이터가 정당한 경우는 큰 데이터, 무한 시퀀스, 지연 계산, 조기 종료가 흔한 경우다.

6. 일회용인지 문서에 안 적는다

Lines 같은 스트림 기반 이터레이터를 재사용 가능한 것으로 착각하면 두 번째 순회가 조용히 0개를 낸다. 예외가 아니라 빈 결과라서 발견이 늦다.

정리

  • range 뒤에 func(yield func() bool), iter.Seq[V], iter.Seq2[K, V] 세 모양의 함수를 놓을 수 있다.
  • iter.Seq[V]는 함수 타입이다. Go의 이터레이터는 값을 미는(push) 모양이고, 소비자의 루프 본문이 yield가 된다.
  • if !yield(v) { return } 이 이터레이터 구현의 기본 관용구다. 재귀라면 &&로 위까지 전파한다. 어기면 런타임이 패닉으로 잡는다.
  • slices.All/Values/Backward/Chunk, maps.All/Keys/Values가 만들고, slices.Collect/Sorted/AppendSeq, maps.Collect가 되받는다.
  • 이터레이터를 받아 이터레이터를 돌려주는 어댑터를 겹치면 중간 슬라이스 없이, 게으르게 처리된다. 그래도 작은 데이터에는 슬라이스가 낫다.
  • 이름은 All, Backward, 도메인 이름을 쓴다. Iter()는 Go 이름이 아니다.
  • 에러는 Seq2[T, error] 로 내보내고, 낸 뒤에는 순회를 끝낸다. 일회용이면 문서에 적는다.
  • 순회와 처리를 분리하면 소비자가 break, continue, 세기, 필터링을 자유롭게 조합할 수 있다. 4-7의 printTreeWalk로 바뀐 것이 그 예다.
  • iter.Pull 은 push를 pull로 바꾼다. 두 시퀀스를 나란히 진행할 때 쓰고, defer stop() 을 반드시 건다.

연습문제

  1. TreeRange(min, max T) iter.Seq[T]를 추가해 보자. min ≤ v ≤ max인 값만 중위 순서로 낸다. 힌트: 순회 자체를 가지치기할 수 있다 — n.val < min이면 왼쪽 서브트리를 아예 안 봐도 된다. Filter(t.All(), ...)와 비교해서 무엇이 더 나은가?

  2. Walk를 써서 에러 트리를 map[string]int(타입 이름 → 개수)로 집계해 보자. fmt.Sprintf("%T", e)를 키로 쓰면 된다. 그다음, 잎 노드만 집계하도록 바꾸려면 무엇을 바꿔야 하는가? hasChildren 없이 할 수 있는가?

  3. Zip[A, B any](a iter.Seq[A], b iter.Seq[B]) iter.Seq2[A, B]를 만들어 보자. 둘 중 짧은 쪽이 끝나면 멈춘다. iter.Pull이 왜 필요한지, 그리고 stop을 두 개 다 걸어야 하는 이유를 확인해 보자. 짧은 쪽이 끝났을 때 긴 쪽의 코루틴은 어떻게 되는가?