はじめに
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時間と引き換えにメモリ効率の良い遅延評価
適切なパターンを選択することで、パフォーマンスとリソース使用量のバランスを最適化してきましょう。