Zenn
🔁

Go 1.23で導入されたiteratorは何を解決し、なぜ今の形になったのか

2024/08/25に公開

まえがき

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は(fboolを返す部分を除いて)他の言語でもよくあるシーケンスを走査するメソッドで、例えばこの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 )
  • yieldtrue を返すことが for の通常のループ、 false を返すことが forbreak に対応する

なぜ今の形になったのか

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 の例で言うと停止する時、つまり yieldfalse を返す時、その返り値をハンドルするのが 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の実装は複雑だけれども使い方はシンプルになっているのはおそらくこの方針に則った結果だと思われます。

あとがき

この記事を書くにあたり主に以下のページを参照しました。個人的に重要だと思う場所を、出来るだけ間違いのないようまとめたつもりですが、より信頼性の高い詳細な情報を知りたい方はこちらを参照することをおすすめします。

脚注
  1. Seq2 はmapのような構造体の K がkeyで V がvalueとして使うことを想定して K, Vになっていると思われますが、どの値を入れても動きますし、前節でエラーを返す関数が実践的な例として用意しやすかったので、ここではこのような説明になります。 ↩︎

  2. https://go.dev/blog/why-generics#:~:text=Complexity falls on the writer of generic code%2C not the user¶ ↩︎

Discussion

ログインするとコメントできます