Goのイテレータを自作する—range over funcとiter.Pullの書き方

Goのイテレータを自作する—range over funcとiter.Pullの書き方 | mohablog

Goでイテレータといえば、チャネルか、インデックスを持ち回るforループでした。Go 1.23からrange over funcが安定し、自前の型でもfor rangeを回せます。標準のiterパッケージはGo 1.26.0(2026-02-10リリース)でも中心にあるので、その書き方を実機で確かめます。

目次

range over funcが標準化した背景

Go 1.22までは、独自コレクションを走査させる標準的な手段がありませんでした。各ライブラリがNext()メソッドやコールバックを思い思いに用意し、呼び出し側は毎回作法を覚える必要がありました。

for rangeが関数を受け取れるようになった

Go 1.23から、for v := range ffに特定のシグネチャを持つ関数を書けます。ループ本体が、その関数に渡されるyieldとして実行される仕組み。コレクション側は「値を1つ作ってはyieldに渡す」だけで、for rangeに組み込めます。

push型イテレータという考え方

公式のiterパッケージ概要では、この標準イテレータを“push iterators”と呼びます。

The standard iterators can be thought of as “push iterators”, which push values to the yield function.
値を取りに行く(pull)のではなく、イテレータ側がyieldへ値を押し込む(push)。データベースドライバのNext()がpull型だとすれば、こちらは制御が反転しています。

iter.Seqを自作する

// Countdown は n から 1 まで降順に値を返すイテレータを返す。
func Countdown(n int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for i := n; i >= 1; i-- {
			if !yield(i) {
				return
			}
		}
	}
}

func main() {
	for v := range Countdown(3) {
		fmt.Println(v)
	}
}

実行結果。

3
2
1

Seqの正体はyieldを受け取る関数

iter.Seqの定義はシンプルです。型エイリアスではなく、ただの関数型。

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

戻り値をiter.Seq[int]と書くだけで、呼び出し側はfor rangeでそのまま回せます。ジェネリクスが効いているので、iter.Seq[string]でもiter.Seq[*User]でも同じ書き味。

yieldの戻り値でbreakに対応する

yieldが返すboolが肝です。ループ本体でbreakすると、yieldfalseを返します。そこで走査を止めてreturnする責任は、イテレータ側にあります。上のCountdownif !yield(i) { return }と書いているのはこのため。途中でbreakしたときに、無駄な値を作り続けないための合図です。

キー付きならSeq2を使う

インデックスやキーを一緒に返したいときはiter.Seq2

// Enumerate は値にインデックスを添えて返す Seq2 イテレータ。
func Enumerate[V any](s []V) iter.Seq2[int, V] {
	return func(yield func(int, V) bool) {
		for i, v := range s {
			if !yield(i, v) {
				return
			}
		}
	}
}

func main() {
	words := []string{"go", "rust", "zig"}
	for i, w := range Enumerate(words) {
		fmt.Printf("%d=%s ", i, w)
	}
	fmt.Println()
}
0=go 1=rust 2=zig

slices・mapsの新関数と組み合わせる

自作だけでなく、標準ライブラリがすでにイテレータを返します。Go 1.23でslicesmapsに追加された関数を押さえておくと、自前のforを書く場面が減ります。

関数戻り値用途
slices.Values(s)iter.Seq[E]スライスを値の列に変える
slices.All(s)iter.Seq2[int, E]インデックス付きで走査
slices.Collect(seq)[]Eイテレータをスライスに集める
slices.Sorted(seq)[]E集めてソートする
maps.Keys(m) / maps.Values(m)iter.Seq[K] / iter.Seq[V]マップのキー・値を走査

マップのキーを安定した順序で回す

マップのfor rangeは順序が不定です。キー順で安定させたいとき、maps.Keysslices.Sortedを繋げば1行で済みます。

m := map[string]int{"c": 3, "a": 1, "b": 2}
for _, k := range slices.Sorted(maps.Keys(m)) {
	fmt.Printf("%s:%d ", k, m[k])
}
fmt.Println()

got := slices.Collect(maps.Values(m))
slices.Sort(got)
fmt.Println("values:", got)
a:1 b:2 c:3
values: [1 2 3]

インデックスを保ったまま絞り込む

slices.Allはインデックス付きのSeq2を返します。元の行番号を保ったまま条件で絞りたいときに効きます。

logs := []string{"INFO ok", "ERROR disk", "INFO done", "ERROR net"}
for i, line := range slices.All(logs) {
	if line[:5] == "ERROR" {
		fmt.Printf("L%d: %s\n", i, line)
	}
}
L1: ERROR disk
L3: ERROR net

iter.Pullでpull型に変換する

for rangeが万能とは限りません。2本のイテレータを同時に1要素ずつ進めたいとき、push型のままでは書けません。for rangeは1本のループに制御を握られるからです。ここでpull型への変換が要ります。

2本の列を同時に進めたい場面

典型例がマージ処理。昇順の列AとBを1本の昇順列にまとめるには、両者の先頭を比べて小さいほうを取り、その列だけを1つ進める、を繰り返します。これはpush型のfor range2重ループでは表現できません。

Pullはnextとstopを返す

iter.Pullはpush型のイテレータを受け取り、pull型のインターフェースに変えます。

func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())

公式概要の説明はこうです。

Pull converts a standard push iterator to a “pull iterator”, which can be called to pull one value at a time from the sequence.
next()を呼ぶたびに値が1つ返り、列が尽きると2つ目の戻り値がfalseになります。これで2本を独立に進められます。

// Merge は2つの昇順イテレータを1本の昇順列にマージする。
func Merge(a, b iter.Seq[int]) iter.Seq[int] {
	return func(yield func(int) 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 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()
		}
	}
}

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

stopを呼ばないとgoroutineがリークする

iter.Pullは内部でコルーチンを使い、push型の関数を途中で止められる状態に保ちます。列を最後まで読み切らずに処理を抜ける場合、stop()を呼ばないとその裏側の実行主体が宙づりのまま残ります。要はgoroutineリークと同じ構図。だからnextA, stopA := iter.Pull(a)の直後にdefer stopA()を置きます。最後まで読み切る場合でもdeferを付けておけば事故りません。リークの検出と原因追跡はGoのgoroutineリーク対策pprofを使って掘り下げています。

イテレータでエラーをどう扱うか

push型イテレータはエラーの扱いで最初に詰まります。yieldのシグネチャはfunc(V) boolで、errorを返す口がありません。ファイル読み込みやネットワーク越しの走査で失敗を伝えたいとき、どうするか。

アンチパターン: errをpanicやログで握り潰す

エラーを返せないからと、イテレータ内でlog.Fatalしたりpanicに逃がす実装をたまに見ます。呼び出し側はエラーをハンドリングする機会を奪われ、ライブラリとしては使いものになりません。

Seq2[T, error]で値とエラーを一緒に流す

素直な解はiter.Seq2[T, error]。値とエラーをペアでyieldし、受け取った側がerr != nilを見てbreakします。

// Lines は io.Reader を1行ずつ返す。yield は error を返せないので
// (行, error) の Seq2 にして、エラーも値として流す。
func Lines(r *bufio.Reader) iter.Seq2[string, error] {
	return func(yield func(string, error) bool) {
		for {
			line, err := r.ReadString('\n')
			if line != "" {
				if !yield(strings.TrimRight(line, "\n"), nil) {
					return
				}
			}
			if err != nil {
				if err.Error() != "EOF" {
					yield("", err)
				}
				return
			}
		}
	}
}

func main() {
	src := bufio.NewReader(strings.NewReader("alpha\nbravo\ncharlie"))
	for line, err := range Lines(src) {
		if err != nil {
			fmt.Println("error:", err)
			break
		}
		fmt.Println("read:", line)
	}
}
read: alpha
read: bravo
read: charlie

並行処理の途中で出たエラーをまとめて扱いたいなら、イテレータの外側でerrgroupによるエラー集約と組み合わせる手もあります。

性能とハマりどころ

単純なループより遅い、ただしアロケーションはゼロ

1万要素の合計を、インデックスループ・自作イテレータ・slices.Valuesで測りました(Go 1.26.0, Apple M5, b.Loop()使用)。

BenchmarkIndexLoop-10        433773      2645 ns/op    0 B/op   0 allocs/op
BenchmarkRangeOverFunc-10     72420     16551 ns/op    0 B/op   0 allocs/op
BenchmarkSlicesValues-10      72452     16608 ns/op    0 B/op   0 allocs/op

自作イテレータはインデックスループの約6.3倍yieldという関数呼び出しが要素ごとに挟まるためです。ただし両者とも0 allocs/opで、ヒープ確保は増えません。そして合計のような極小のループ本体だからこの差が目立つ点に注意。本体でI/Oや重い計算をすれば、相対的な差は縮みます。ホットパスの[]int合計をイテレータにする必要はありません。

yieldの戻り値を捨てるとpanicする

イテレータ自作でよくあるバグが、yieldの戻り値の無視です。break後も走査を続けると、ランタイムが弾きます。

// BAD: yield が false を返したのに return せず走査を続けている。
func BadCountdown(n int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for i := n; i >= 1; i-- {
			yield(i) // 戻り値を無視している
		}
	}
}

func main() {
	for v := range BadCountdown(5) {
		fmt.Println(v)
		if v == 3 {
			break
		}
	}
}
5
4
3
panic: runtime error: range function continued iteration after function for loop body returned false

breakv == 3まで出た直後、4をyieldしようとして落ちます。メッセージが具体的なので原因はすぐ分かりますが、本番でpanicになる前にif !yield(...) { return }を徹底しておきます。

まとめ

  • range over funcはGo 1.23で安定し、Go 1.26でも標準の書き方。自前の型をfor rangeで回せる
  • iter.Seq[V]func(yield func(V) bool)という関数型。yieldの戻り値を見てreturnするのがイテレータ側の責任
  • キー付きはiter.Seq2slices.Valuesmaps.Keysslices.Sortedを繋ぐと自前ループが減る
  • 2本の列を同時に進めるならiter.Pullでpull型に変換。stop()deferしないとリークする
  • エラーはiter.Seq2[T, error]で値と一緒に流す。yieldはerrorを返せない
  • 性能はインデックスループの約6.3倍だが0 allocs。重い本体なら差は縮む
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次