イテレータ
Go において、特定のデータ構造をイテレートするためのキーワードは for range です。以前の章でそのいくつかの使用方法を紹介しましたが、これは言語に組み込まれたいくつかのデータ構造にのみ作用できます。
- 配列
- スライス
- 文字列
- map
- チャネル
- 整数値
これは非常に柔軟性がなく、拡張性がなく、カスタムタイプにはほとんど対応していません。しかし、Go1.23 バージョン以降、for range キーワードは range over func をサポートするようになり、これによりカスタムイテレータが可能になりました。
概要
まず例を通じてイテレータを初步的に理解しましょう。関数のセクションで説明した 閉包によるフィボナッチ数列の例 を覚えているでしょうか。その実装コードは以下の通りです。
func Fibonacci(n int) func() (int, bool) {
a, b, c := 1, 1, 2
i := 0
return func() (int, bool) {
if i >= n {
return 0, false
} else if i < 2 {
f := i
i++
return f, true
}
a, b = b, c
c = a + b
i++
return a, true
}
}これをイテレータに改造できます。以下に示します。コード量が減少していることがわかります。
func Fibonacci(n int) func(yield func(int) bool) {
a, b, c := 0, 1, 1
return func(yield func(int) bool) {
for range n {
if !yield(a) {
return
}
a, b = b, c
c = a + b
}
}
}Go のイテレータは range over func スタイルで、for range キーワードを直接使用できます。使用もより便利になりました。
func main() {
n := 8
for f := range Fibonacci(n) {
fmt.Println(f)
}
}出力は以下の通りです。
0
1
1
2
3
5
8
13上記に示すように、イテレータはクロージャ関数であり、コールバック関数をパラメータとして受け取ります。yield のようなキーワードさえ見ることができます。Python を書いたことがある人には馴染みがあるでしょう。これは Python のジェネレーターに似ています。Go のイテレータには新しいキーワードや構文特性は追加されていません。上記の例では yield も単なるコールバック関数であり、キーワードではありません。公式はこの名前を分かりやすくするために付けただけです。
推送式イテレータ
イテレータの定義については、iter パッケージで以下の説明が見つかります。
An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.
イテレータは、シーケンスの連続する要素をコールバック関数(通常 yield と呼ばれる)に渡す関数です。
ここから明確にわかるのは、イテレータはコールバック関数をパラメータとして受け取る関数であり、イテレート過程中にシーケンスの要素を順にコールバック関数 yield に渡すということです。以前の例では以下のようにイテレータを使用しました。
for f := range Fibonacci(n) {
fmt.Println(f)
}公式の定義によれば、上記のイテレータ Backward の例は以下のコードと等価です。
Fibonacci(n)(func(f int) bool {
fmt.Println(f)
return true
})ループ本体がイテレータのコールバック関数 yield です。関数が true を返すとイテレータはイテートを継続し、そうでなければ停止します。
さらに、iter 標準ライブラリにもイテレータ型 iter.Seq が定義されており、その型は関数です。
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)iter.Seq のコールバック関数は 1 つのパラメータのみを受け取るため、イテレート時に for range は 1 つの戻り値のみを持ちます。以下に示します。
for v := range iter {
// body
}iter.Seq2 のコールバック関数は 2 つのパラメータを受け取るため、イテレート時に for range は 2 つの戻り値を持ちます。以下に示します。
for k, v := range iter {
// body
}標準ライブラリでは 0 パラメータの Seq は定義されていませんが、これも完全に許可されています。これは以下に相当します。
func(yield func() bool)使用は以下の通りです。
for range iter {
// body
}コールバック関数のパラメータ数は 0〜2 個のみで、それを超えるとコンパイルできません。
要するに、for range のループ本体はイテレータ内の yield コールバック関数です。for range が返す値の数に応じて、対応する yield 関数のパラメータ数が決まります。各イテレート時に、イテレータは yield 関数を呼び出し、ループ本体のコードを実行し、シーケンスの要素を yield 関数に積極的に渡します。このように要素を積極的に渡すイテレータを一般に推送式イテレータ(pushing iterator)と呼びます。典型的な例は他の言語の foreach です。例えば JavaScript です。
let arr = [1, 2, 3, 4, 5];
arr
.filter((e) => e % 2 === 0)
.forEach((e) => {
console.log(e);
});Go での表現形式は range によってイテレートされる要素を返すことです。
for index, value := range iterator() {
fmt.Println(index, value)
}一部の言語(例えば Java)では、これは別の名前があります:データストリーム処理です。
ループ本体のコードがコールバック関数としてイテレータに渡され、それがクロージャ関数である可能性が高いため、Go はクロージャ関数が defer、return、break、goto などのキーワードを使用する際に、通常のループ本体コードセグメントと同じように振る舞うようにする必要があります。以下のいくつかの状況を考えてみましょう。
例えばイテレーターループ内で return する場合、yield コールバック関数内でこの return をどのように処理すればよいでしょうか。
for index, value := range iterator() {
if value > 10 {
return
}
fmt.Println(index, value)
}コールバック関数内で直接 return することはできません。そうするとイテレートが停止するだけで、return の効果は達成されません。
iterator()(func(index int, value int) bool {
if value > 10 {
return false
}
fmt.Println(index, value)
})また、イテレーターループ内で defer を使用する場合も同様です。
for index, value := range iterator() {
defer fmt.Println(index, value)
}コールバック関数内で defer を直接使用することもできません。そうするとコールバック関数の終了時に直接遅延呼び出しが行われてしまいます。
iterator()(func(index int, value int) bool {
defer fmt.Println(index, value)
})他のいくつかのキーワード break、continue、goto も同様です。幸い、Go はこれらの状況を処理してくれています。私たちは使用するだけでよく、これらについて心配する必要は暂时ありません。興味がある場合は、rangefunc/rewrite.go のソースコードを自分で参照してください。
拉取式イテレータ
推送式イテレータ(pushing iterator)はイテレータがイテートロジックを制御し、ユーザーが受動的に要素を取得するのに対し、拉取式イテレータ(pulling iterator)はユーザーがイテートロジックを制御し、積極的にシーケンス要素を取得します。一般に、拉取式イテレータには next()、stop() などの特定の関数があり、イテートの開始または終了を制御します。これはクロージャまたは構造体である可能性があります。
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line, err := scanner.Text(), scanner.Err()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(line)
}上記に示すように、Scanner は Text() メソッドを通じてファイルの次の行のテキストを取得し、Scan() メソッドを通じてイテートが終了したかどうかを示します。これも拉取式イテレータの 1 つのモードです。Scanner は構造体を使用して状態を記録しますが、iter パッケージで定義された拉取式イテレータはクロージャを使用して状態を記録します。iter.Pull または iter.Pull2 関数を通じて、標準的な推送式イテレータを拉取式イテレータに変換できます。iter.Pull と iter.Pull2 の違いは、後者の戻り値が 2 つあることです。シグネチャは以下の通りです。
func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())
func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())これらはイテレータをパラメータとして受け取り、next() と stop() の 2 つの関数を返し、イテートの継続と停止を制御するために使用されます。
func next() (V, bool)
func stop()next はイテレートされる要素と、現在の値が有効かどうかを示すブール値を返します。イテートが終了すると、next 関数は要素のゼロ値と false を返します。stop 関数はイテートプロセスを終了します。呼び出し元がイテレータを使用しなくなった後、stop 関数を使用してイテートを終了する必要があります。ちなみに、複数のゴルーチンが同じイテレータの next 関数を呼び出すのは誤った方法です。なぜなら、それは同時実行安全ではないからです。
以下に例を示します。その機能は、以前のフィボナッチイテレータを拉取式イテレータに改造することです。
func main() {
n := 10
next, stop := iter.Pull(Fibonacci(n))
defer stop()
for {
fibn, ok := next()
if !ok {
break
}
fmt.Println(fibn)
}
}出力
0
1
1
2
3
5
8
13
21
34これにより、next と stop 関数を通じてイテートロジックを手動で制御できます。あなたはこの行為が余計だと思うかもしれません。そうする必要があるなら、なぜ最初のクロージャバージョンを直接使用しないのでしょうか。それも自分でイテートを制御できます。クロージャの使用方法は以下の通りです。
func main() {
fib := Fibonacci(10)
for {
n, ok := fib()
if !ok {
break
}
fmt.Println(n)
}
}変換プロセス:クロージャ → イテレータ → 拉取式イテレータ。クロージャと拉取式イテレータの使用方法はほぼ同じで、それらの考え方は同じです。後者はさまざまな処理により性能の拖累を引き起こす可能性があります。率直に言って、これは確かに余計です。その使用シナリオは実際には多くありません。しかし、iter.Pull は iter.Seq のために存在し、つまり推送式イテレータを拉取式イテレータに変換するために存在します。単に拉取式イテレータが必要なだけなら、それを変換するために推送式イテレータを実装することを検討してください。自分で実装する複雑さと性能を考慮してください。このフィボナッチ数列の例のように、一周回って原点に戻るだけです。唯一の利点は、公式のイテレータ仕様に適合することかもしれません。
エラー処理
イテレート時にエラーが発生した場合はどうすればよいでしょうか。それを yield 関数に渡して for range から返すことができます。呼び出し元に処理させます。以下の行イテレータの例のようになります。
func ScanLines(reader io.Reader) iter.Seq2[string, error] {
scanner := bufio.NewScanner(reader)
return func(yield func(string, error) bool) {
for scanner.Scan() {
if !yield(scanner.Text(), scanner.Err()) {
return
}
}
}
}TIP
值得注意的是、ScanLines イテレータは一度きりの使用です。ファイルが閉じられた後は再度使用できません。
2 つ目の戻り値が error 型であることがわかります。使用は以下の通りです。
for line, err := range ScanLines(file) {
if err != nil {
fmt.Println(err)
break
}
fmt.Println(line)
}このように処理すると、通常のエラー処理と変わりません。拉取式イテレータも同様です。
next, stop := iter.Pull2(ScanLines(file))
defer stop()
for {
line, err, ok := next()
if err != nil {
fmt.Println(err)
break
} else if !ok {
break
}
fmt.Println(line)
}panic が発生した場合は、通常通り recovery を使用すればよいです。
defer func() {
if err := recover(); err != nil {
fmt.Println("panic:", err)
os.Exit(1)
}
}()
for line, err := range ScanLines(file) {
if err != nil {
fmt.Println(err)
break
}
fmt.Println(line)
}拉取式イテレータも同様です。ここではデモンストレーションしません。
標準ライブラリ
多くの標準ライブラリもイテレータをサポートしています。最も一般的に使用されるのは slices と maps 標準ライブラリです。以下にいくつかの実用的な機能を紹介します。
slices.All
func All[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]slices.All はスライスをスライスイテレータに変換します。
func main() {
s := []int{1, 2, 3, 4, 5}
for i, n := range slices.All(s) {
fmt.Println(i, n)
}
}出力
0 1
1 2
2 3
3 4
4 5slices.Values
func Values[Slice ~[]E, E any](s Slice) iter.Seq[E]slices.Values はスライスをスライスイテレータに変換しますが、インデックスは付きません。
func main() {
s := []int{1, 2, 3, 4, 5}
for n := range slices.Values(s) {
fmt.Println(n)
}
}出力
1
2
3
4
5slices.Chunk
func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]slices.Chunk 関数は、n 個の要素をスライスとして呼び出し元に渡すイテレータを返します。
func main() {
s := []int{1, 2, 3, 4, 5}
for chunk := range slices.Chunk(s, 2) {
fmt.Println(chunk)
}
}出力
[1 2]
[3 4]
[5]slices.Collect
func Collect[E any](seq iter.Seq[E]) []Eslices.Collect 関数はスライスイテレータをスライスに収集します。
func main() {
s := []int{1, 2, 3, 4, 5}
s2 := slices.Collect(slices.Values(s))
fmt.Println(s2)
}出力
[1 2 3 4 5]maps.Keys
func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]maps.Keys は map のすべてのキーをイテレートするイテレータを返します。slices.Collect と組み合わせると、直接スライスに収集できます。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
keys := slices.Collect(maps.Keys(m))
fmt.Println(keys)
}出力
[three one two]map は順序がないため、出力も固定されていません。
maps.Values
func Values[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[V]maps.Values は map のすべての値をイテレートするイテレータを返します。slices.Collect と組み合わせると、直接スライスに収集できます。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
keys := slices.Collect(maps.Values(m))
fmt.Println(keys)
}出力
[3 1 2]map は順序がないため、出力も固定されていません。
maps.All
func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V]maps.All は map を map イテレータに変換します。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
for k, v := range maps.All(m) {
fmt.Println(k, v)
}
}通常、これを直接使用することはなく、他のデータストリーム処理関数と組み合わせて使用します。
maps.Collect
func Collect[K comparable, V any](seq iter.Seq2[K, V]) map[K]Vmaps.Collect は map イテレータを map に収集します。
func main() {
m := map[string]int{"one": 1, "two": 2, "three": 3}
m2 := maps.Collect(maps.All(m))
fmt.Println(m2)
}collect 関数は一般にデータストリーム処理の終端関数として使用されます。
連鎖呼び出し
上記の標準ライブラリが提供する関数を通じて、これらを組み合わせてデータストリームを処理できます。例えばデータストリームをソートするなどです。
sortedSlices := slices.Sorted(slices.Values(s))Go のイテレータはクロージャを使用しているため、このようにネストされた関数呼び出ししかできず、それ自体は連鎖呼び出しできません。呼び出しチェーンが長くなると可読性が低下します。しかし、構造体を通じてイテレータを記録することで、連鎖呼び出しを実装できます。
デモ
簡単な連鎖呼び出しのデモを以下に示します。これには Filter、Map、Find、Some などの一般的な機能が含まれています。
package iterx
import (
"iter"
"slices"
)
type SliceSeq[E any] struct {
seq iter.Seq2[int, E]
}
func (s SliceSeq[E]) All() iter.Seq2[int, E] {
return s.seq
}
func (s SliceSeq[E]) Filter(filter func(int, E) bool) SliceSeq[E] {
return SliceSeq[E]{
seq: func(yield func(int, E) bool) {
// インデックスを再編成
i := 0
for k, v := range s.seq {
if filter(k, v) {
if !yield(i, v) {
return
}
i++
}
}
},
}
}
func (s SliceSeq[E]) Map(mapFn func(E) E) SliceSeq[E] {
return SliceSeq[E]{
seq: func(yield func(int, E) bool) {
for k, v := range s.seq {
if !yield(k, mapFn(v)) {
return
}
}
},
}
}
func (s SliceSeq[E]) Fill(fill E) SliceSeq[E] {
return SliceSeq[E]{
seq: func(yield func(int, E) bool) {
for i, _ := range s.seq {
if !yield(i, fill) {
return
}
}
},
}
}
func (s SliceSeq[E]) Find(equal func(int, E) bool) (_ E) {
for i, v := range s.seq {
if equal(i, v) {
return v
}
}
return
}
func (s SliceSeq[E]) Some(match func(int, E) bool) bool {
for i, v := range s.seq {
if match(i, v) {
return true
}
}
return false
}
func (s SliceSeq[E]) Every(match func(int, E) bool) bool {
for i, v := range s.seq {
if !match(i, v) {
return false
}
}
return true
}
func (s SliceSeq[E]) Collect() []E {
var res []E
for _, v := range s.seq {
res = append(res, v)
}
return res
}
func (s SliceSeq[E]) Sort(cmp func(x, y E) int) []E {
collect := s.Collect()
slices.SortFunc(collect, cmp)
return collect
}
func (s SliceSeq[E]) SortStable(cmp func(x, y E) int) []E {
collect := s.Collect()
slices.SortStableFunc(collect, cmp)
return collect
}
func Slice[S ~[]E, E any](s S) SliceSeq[E] {
return SliceSeq[E]{seq: slices.All(s)}
}その後、連鎖呼び出しを通じて処理できます。いくつかの使用例を見てみましょう。
要素値の処理
func main() {
s := []string{"apple", "banana", "cherry"}
all := iterx.Slice(s).Map(strings.ToUpper).All()
for i, v := range all {
fmt.Printf("index: %d, value: %s\n", i, v)
}
}出力
index: 0, value: APPLE
index: 1, value: BANANA
index: 2, value: CHERRY特定の値の検索
func main() {
s := []int{1, 2, 3, 4, 5}
result := iterx.Slice(s).Find(func(i int, e int) bool {
return e == 3
})
fmt.Println(result)
}出力
3スライスの埋め尽くし
func main() {
s := []int{1, 2, 3, 4, 5}
result := iterx.Slice(s).Fill(6).Collect()
fmt.Println(result)
}出力
[6 6 6 6 6]要素のフィルタリング
func main() {
s := []int{1, 2, 3, 4, 5}
filter := iterx.Slice(s).Filter(func(i int, e int) bool {
return e%2 == 0
}).All()
for i, v := range filter {
fmt.Printf("Index: %d, Value: %d\n", i, v)
}
}出力
Index: 0, Value: 2
Index: 1, Value: 4残念なことに、Go は現在簡略化された匿名関数をサポートしていません。JavaScript、Rust、Java のアロー関数のようなもので、そうでなければ連鎖呼び出しはより簡潔でエレガントになる可能性があります。
性能
Go はイテレータのために多くの処理を行っているため、その性能は明らかにネイティブの for range ループには劣りません。最もシンプルなスライス走査の例を通じて、それらの性能差をテストしてみましょう。以下にいくつかの種類を示します。
- ネイティブ for ループ
- 推送式イテレータ
- 拉取式イテレータ
テストコードは以下の通りです。テストスライスの長さは 1000 です。
package main
import (
"iter"
"slices"
"testing"
)
var s []int
const n = 10000
func init() {
for i := range n {
s = append(s, i)
}
}
func testNaiveFor(s []int) {
for i, n := range s {
_ = i
_ = n
}
}
func testPushing(s []int) {
for i, n := range slices.All(s) {
_ = i
_ = n
}
}
func testPulling(s []int) {
next, stop := iter.Pull2(slices.All(s))
for {
i, n, ok := next()
if !ok {
stop()
return
}
_ = i
_ = n
}
}
func BenchmarkNaive_10000(b *testing.B) {
for range b.N {
testNaiveFor(s)
}
}
func BenchmarkPushing_10000(b *testing.B) {
for range b.N {
testPushing(s)
}
}
func BenchmarkPulling_10000(b *testing.B) {
for range b.N {
testPulling(s)
}
}テスト結果は以下の通りです。
goos: windows
goarch: amd64
pkg: golearn
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkNaive_10000
BenchmarkNaive_10000-16 492658 2398 ns/op 0 B/op 0 allocs/op
BenchmarkPushing_10000
BenchmarkPushing_10000-16 315889 3707 ns/op 0 B/op 0 allocs/op
BenchmarkPulling_10000
BenchmarkPulling_10000-16 2016 574509 ns/op 440 B/op 14 allocs/op
PASS
ok golearn 4.029s結果を通じて、推送式イテレータとネイティブの for range ループの差はそれほど大きくないことがわかります。しかし、拉取式イテレータは前述の 2 つよりもほぼ 2 桁遅いです。使用する際は、各自の実際の状況に応じて検討してください。
まとめ
ジェネリックの場合と同様に、Go のイテレータも同様に議論を呼んでいます。一部の人々の見解では、イテレータは過度の複雑さを導入し、Go の簡潔さの哲学に反しています。このようなイテレータのクロージャコードが増えると、デバッグが困難になり、読むことはさらにイライラするでしょう。
イテレータに関する激しい議論を多くの場所で見ることができます。
- Why People are Angry over Go 1.23 Iterators、ある外国人の兄貴によるイテレータの評価。一読の価値あり
- golang/go · Discussion #56413、rsc によって開始されたコミュニティディスカッション。多くの人々が自分の意見を表明しました
Go イテレータを理性的に看待しましょう。確かにコードの記述をより便利にしています。特にスライスタイプを処理する際に。同時に、いくつかの複雑さを導入し、イテレータ部分のコードの可読性を低下させます。しかし全体的に見て、これは実用的な特性であると私は考えています。
