Go 1.23で導入されたiteratorは何を解決し、なぜ今の形になったのか
まえがき
Go 1.23でiteratorが導入されました。
話題になっていたので見てみたんですが、少し見ただけでは、使い方、何を解決するのか、なぜこういう仕様になっているのかが分からなかったので調べました。それで分かったことの自分の理解をまとめます。
何を解決するのか
Goのfor-rangeでは元々slice, map, channelなどをiterateすることは出来ましたが、これらの型で対応できない、より一般的な抽象化・統一化されたiterationの手段はありませんでした。具体的には、例えば bufio.Reader.ReadByte を用いてiterateすると以下のようなコードになります。
for {
b, err := reader.ReadByte()
if err != nil {
if err == io.EOF {
break
}
// handle error
return
}
// do something
}
また、 bufio.Scanner.Scan を用いると以下のようになります。
for scanner.Scan() {
// do something with scanner.Text()
}
if err := scanner.Err(); err != nil {
// handle error
}
やりたいことは似てるのにかなり違うコードになっていますし、他にも違う使い方のAPIが存在します。Goの長所として誰が書いても似たコードになることがよく挙げられますが、この分野に関しては使うライブラリ・APIによってバラバラになってしまっています。
そこで、これがもし以下のように書けるようになったら分かりやすく統一された書き方が出来そうです。
for v, err := range scanner.Scan {
if err != nil {
// error handle
}
fmt.Println(v)
}
for v, err := range reader.ReadByte { // 最後に到達したら io.EOF を返すんじゃなくて勝手にbreakする
if err != nil {
// error handle
}
fmt.Println(v)
}
今回追加されたiteratorは、iterationコードが現状バラバラになっていると言う課題を解決し、統一された使いやすい・理解しやすいコードの記述を可能にします。
現在の仕様とイメージの掴み方
Go 1.23で導入された iter
パッケージには以下のtypeが定義されています。前節で例に出した値とエラーを返すiteratorの場合、 K
が値、 V
がエラーの Seq2
になります[1]。
type (
Seq[V any] func(yield func(V) bool)
Seq2[K, V any] func(yield func(K, V) bool)
)
例えば以下のオブジェクトがあった時、
var seq iter.Seq[string, error]
前節の例のように、以下のように書けます
for v, err := range seq {
if err != nil {
// handle error
}
// do something
}
この Seq2[K, V any]
の型が func(yield func(K, V) bool)
なのが個人的には分かりづらかったんですが、この関数はシーケンスのEachメソッドのようなものだと思うと理解できました。つまり、例えば以下のようなコードがあったとします。
type Array []int
func (a Array) Each(f func(int) bool) {
for _, v := range a {
if !f(v) {
return
}
}
}
このEach
は(f
がbool
を返す部分を除いて)他の言語でもよくあるシーケンスを走査するメソッドで、例えばこのArray
の中を全てprintする場合以下のようになります。
a := Array{1, 2, 3, 4, 5}
a.Each(func(v int) bool {
println(v)
return true
})
// Output:
// 1
// 2
// 3
// 4
// 5
この走査を途中でやめたい場合はその時点でfalse
を返します。
a := Array{1, 2, 3, 4, 5}
a.Each(func(v int) bool {
if v > 3 {
return false
}
println(v)
return true
})
// Output:
// 1
// 2
// 3
そしてこのEach
メソッドはiter.Seq[V any]
を満たしており、そのままfor-rangeで使うことができます。
for v := range a.Each {
if v > 3 {
break
}
println(v)
}
// Output:
// 1
// 2
// 3
つまり、以下の対応があると考えるとすんなり理解することが出来ました
-
iter.Seq[V any]
はシーケンスの中身も入ったEach
メソッド -
for
ブロックの中身がEach
メソッドに渡すコールバック関数(=yield
) -
yield
がtrue
を返すことがfor
の通常のループ、false
を返すことがfor
のbreak
に対応する
なぜ今の形になったのか
iteration を言語機能に組み込むことを考えた時、 func(yield func(V) bool)
ではなく、 func() (V, bool)
をiteratorとすることも考えられます。 func() (V, bool)
の場合、通常以下のように書いてiterateするところを
for v, ok := f(); ok; v, ok = f() {
// do something
}
以下のようにrangeに渡すと簡潔に書けるようになる、という案です。
for v := range f {
// do something
}
iteratorの文脈においてこのような方式の関数をpull関数、Goで採用された方式の func(yield func(V) bool)
の関数をpush関数と言います。Goが採用したのはpush関数ですが、pull関数も検討されていました。
pull関数が採用されなかったのは、pull関数は関数呼び出しにまたがる状態を内部で持つ必要があり、場合によってはこれ以上呼び出しを行わない際に呼び出し側から通知する必要があり、これがコードを複雑化させることが主な理由のようです。
例えば前述の Array
の例でpull関数を実装すると以下のようになり、 Array
の中で現在の位置を保持する必要があります。
type Array struct {
v []int
pos int
}
func (a *Array) Next() (int, bool) {
if a.pos >= len(a.v) {
return 0, false
}
v := a.v[a.pos]
a.pos++
return v, true
}
この Next
を使ったiterateを途中で抜けた場合 pos
が中途半端なままになってしまうのでこれを手動でリセットする必要があるかもしれません。また、他のケースで Next
の実装でgoroutineを用いられていると明示的に止めないとgoroutineリークが起きてしまいます。このようなケースもカバーするためには、iterateを止めたことを通知するための関数が別途必要になります。push関数の場合、前述の Array.Each
の例で言うと停止する時、つまり yield
が false
を返す時、その返り値をハンドルするのが Array.Each
なのでそのようなクリーンナップを Array.Each
自身で行うことができ、呼び出し側で行う必要がなくなります。
主にこの辺りが理由でpush関数方式が採用されたようです。
ただpull関数方式の方が都合が良いケースもあるようで、Go 1.23で追加されたiter packageではpush関数をpull関数に変換する関数が提供されています。
これからのiteratorとの付き合い方
「何を解決するのか」の章で書いた通り、iteratorはsliceなどのrangeで対応できないiterationの手段がないのでそれを統一するために生まれたので、逆に言えばsliceなどで対応できる場合にわざわざ自分で実装する必要はないでしょう。もし自分で実装する場合は、iteratorの実装側はそれなりに複雑なので、得られるメリットが導入される複雑さのデメリットを超えるかどうかを意識することが重要になってくるだろうと思います。
おそらくこれからさまざまなライブラリのiterationのAPIがiterator、つまり Seq[V any]
, Seq2[K, V any]
を返すようになると思われるので、iteratorの実装することがなかったとしても使い方は理解しておく方が良いと思います。そしてその使い方は非常にシンプルで、iteratorをrangeに渡すとsliceのfor-rangeのように扱うことが出来る、と言う部分を理解しておけばとりあえず困ることはあまりないじゃないかと思います。
Goの仕様策定の方針として、導入される複雑さはライブラリの実装者に押し付けて、それを利用する側には極力押し付けない、と言うものがあるようです[2] 。iteratorの実装は複雑だけれども使い方はシンプルになっているのはおそらくこの方針に則った結果だと思われます。
あとがき
この記事を書くにあたり主に以下のページを参照しました。個人的に重要だと思う場所を、出来るだけ間違いのないようまとめたつもりですが、より信頼性の高い詳細な情報を知りたい方はこちらを参照することをおすすめします。
- spec: add range over int, range over func · Issue #61405 · golang/go
- user-defined iteration using range over func values · golang/go · Discussion #56413
- discussion: standard iterator interface · golang/go · Discussion #54245
- proposal: Go 2: function values as iterators · Issue #43557 · golang/go
- Go Wiki: Rangefunc Experiment - The Go Programming Language
- iter package - iter - Go Packages
Discussion