Goのイテレーター・ジェネレータについて
はじめに Go 1.23でイテレータ機能が標準ライブラリに追加されました。本記事では、新しく導入されたiterパッケージの使い方と、従来のスライスベースの反復処理との違いについて、実行フローとパフォーマンスについてまとめていきます。 イテレータとは イテレータは、コレクションの要素を順次走査するための抽象化です。Goでは従来からrangeを用いたスライスの反復処理が可能でしたが、Go1.23からは関数ベースのカスタムイテレータが言語レベルでサポートされるようになりました。 // 従来のスライスベースの反復処理 for i, v := range []string{"a", "b", "c"} { fmt.Println(i, v) } ジェネレータの概要 ジェネレータは、値を遅延評価的に生成するイテレータの一種です。Pythonのyieldキーワードのような専用構文はありませんが、iterパッケージで定義された型を使って実現します。 iter パッケージの型定義 package iter type Seq[V any] func(yield func(V) bool) type Seq2[K, V any] func(yield func(K, V) bool) Seq[V] 単一の値を返すイテレータ Seq2[K, V] キーと値のペアを返すイテレータ(mapのrangeループに相当) yield関数の戻り値は継続フラグで、falseを返すとイテレーションが中断されます。 スライス vs ジェネレータ スライスとジェネレータの実装について比較していきます。 【パターン1】 スライスベースの実装 func Test_Slice(t *testing.T) { strings := createSlice(5) for _, s := range strings { fmt.Printf("Test_Slice: %s\\n", s) } } func createSlice(max int) []string { slice := make([]string, 0, max) for i := range max { fmt.Printf("createSlice: %d\\n", i) slice = append(slice, strconv.Itoa(i)) } return slice } 実行結果: createSlice: 0 createSlice: 1 createSlice: 2 createSlice: 3 createSlice: 4 Test_Slice: 0 Test_Slice: 1 Test_Slice: 2 Test_Slice: 3 Test_Slice: 4 スライス生成が完全に完了してから、rangeループによる反復処理が開始されます。 【パターン2】 ジェネレータベースの実装 func Test_Yield(t *testing.T) { stringGenerator := generateString(5) for s := range stringGenerator { fmt.Printf("Test_Yield: %s\\n", s) } } func generateString(max int) iter.Seq[string] { return func(yield func(string) bool) { for i := range max { fmt.Printf("generateString: %d\\n", i) if !yield(strconv.Itoa(i)) { return } } } } 実行結果: generateString: 0 Test_Yield: 0 generateString: 1 Test_Yield: 1 generateString: 2 Test_Yield: 2 generateString: 3 Test_Yield: 3 generateString: 4 Test_Yield: 4 生成と処理が交互に実行されています。これが遅延評価の特徴です。 実行フローの違い スライスの場合 createSliceが全要素を生成 スライスがメモリ上に確保される rangeが各要素を順次処理 ジェネレータの場合 rangeがiter.Seq型の関数を実行 yieldが呼ばれるたびにループ本体が実行される 次の要素が必要になるまで生成処理は進まない 注目すべきは、generateStringの戻り値である関数を明示的に呼び出していない点です。rangeキーワードがiter.Seq型を検出すると、自動的に関数を実行してイテレーションを開始します。 パフォーマンス特性の比較 項目スライスジェネレータメモリ使用量O(n) 全要素を保持O(1) 現在の状態のみ初期化コスト高い - 全要素を事前生成低い - 遅延生成反復処理速度高速 - メモリアクセスのみやや低速 - 毎回関数呼び出しCPU使用率低い(反復時)高い(関数呼び出しオーバーヘッド)早期終了時の効率無駄な生成が発生必要な分だけ生成 ベンチマーク例 func BenchmarkSlice(b *testing.B) { for i := 0; i < b.N; i++ { slice := createSlice(1000) for _, s := range slice { _ = s } } } func BenchmarkGenerator(b *testing.B) { for i := 0; i < b.N; i++ { gen := generateString(1000) for s := range gen { _ = s } } } 使い分けの指針 スライスを選ぶべきケース 全要素を複数回走査する必要がある データサイズが小さく、メモリに余裕がある 反復処理のパフォーマンスが重要 データを一度に取得するコストが低い ジェネレータを選ぶべきケース データサイズが大きく、メモリ効率が重要 要素生成のコストが高い(DB クエリ、API コールなど) 早期終了の可能性が高い(条件に合う最初の要素を探すなど) 無限シーケンスを扱う場合 実践例:無限シーケンス func infiniteCounter() iter.Seq[int] { return func(yield func(int) bool) { i := 0 for { if !yield(i) { return } i++ } } } // 最初の10個だけ取得 func Test_InfiniteCounter(t *testing.T) { count := 0 for n := range infiniteCounter() { fmt.Println(n) count++ if count >= 10 { break } } } このようなパターンはスライスでは実現できません。 まとめ Go1.23のイテレータ機能は、従来のスライスベースの反復処理に加えて、メモリ効率の良い遅延評価を実現できます。 スライスメモリと引き換えに高速な反復処理 ジェネレータCPU時間と引き換えにメモリ効率の良い遅延評価 適切なパターンを選択することで、パフォーマンスとリソース使用量のバランスを最適化してきましょう。 参考資料 Go 1.23 Release Notes - Iterators