はじめに
こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 本稿では、2024年9月24日(火)にファインディ株式会社主催の「Goのイテレータ深堀りNight」というイベントで登壇してきましたので、その報告と内容について紹介します。
「Goのイテレータ深堀りNight」は、2024年8月にリリースされたGo1.23の機能の1であるrange over func(通称イテレータ)について、6人の登壇者がさまざまな角度で10分のライトニングトーク(LT)を行うイベントです。筆者は、トップバッターということで「まずはイテレータ(range over func)の仕様を学ぼう 」という発表を行いました。
登壇に用いた資料は次のリンクから閲覧ができます。
イテレータの導入経緯
イテレータは単独で導入を検討されていた機能ではありません。Go1.18でジェネリクス(型パラメータ)が導入される際の議論からうまれた機能です。図のように、Go1.18ではジェネリクス(型パラメータ)について言語仕様の追加や宣言済み識別子(any
とcomparable)の追加は行われたものの、標準ライブラリはgo
パッケージなどを除いて公開されているAPIはほとんど更新されませんでした。
Goコミュニティにおいて、ジェネリクスを使ったコードの経験がある程度貯まるまでは、標準ライブラリへに追加しないという理由で、slices
パッケージやmaps
パッケージはx/exp
パッケージ以下で実験的に公開されていました。実際にslices
パッケージとmaps
パッケージの一部の機能は変更され、constraints
パッケージは公開されず、一部の機能がcmp
パッケージとして標準ライブラリに追加されました。
そして、Go1.21でついにslices
パッケージとmaps
パッケージは標準ライブラリに追加されました。しかし、maps.Keys
関数やmaps.Values
関数など、スライスを返す関数は、x/exp/maps
パッケージでは提供されていたものの、標準ライブラリとしては追加されませんでした。これらの関数はスライスで返すのではなく、新たに議論が始まっていたイテレータ型として返すべきだという議論になったからです。
次のリンクから当時の議論を追うことができるでしょう。
有志が作成しているGoの歴史をまとめているサイトgolang.design/historyによれば、イテレータに関連する提案はかなり前からされていたようです。
議論が活発になったのは、ジェネリクスがGo1.18でリリースされ、次のGitHub Discussionが作成されてからでしょう。
このDiscussionでは、次のようなインタフェースを満たす型の値がfor range
文でNext
メソッドを呼び出されていくことでイテレーションができるというものでした。
// Iter supports iterating over a sequence of values of type `E`. type Iter[E any] interface { // Next returns the next value in the iteration if there is one, // and reports whether the returned value is valid. // Once Next returns ok==false, the iteration is over, // and all subsequent calls will return ok==false. Next() (elem E, ok bool) }
この議論は後方互換性の観点からインタフェースでイテレータの仕組みを提供するのは難しいという結論になり、次に示したDiscussionにあるように関数を用いた方法に議論が移っていきました。
そして議論の末、ついにGo1.23でイテレータ(range over func)がリリースされました。しかし、型パラメータの時と同じように標準ライブラリの変更は最低限になっており、slices
パッケージやmaps
パッケージにイテレータを返す関数が少し追加されただけです。
それでもGopher(Goのユーザ)たち念願のmaps.Keys
関数が導入されました。なお、maps.Keys
関数はfor range
文でマップ型の値をイテレーションしてキーを取得した場合と同様にキーの並び順がランダムになるため、次のようにslices.Sorted
関数かslices.SortedFunc
関数を使用すると良いでしょう。
m := map[string]int{"dog": 1, "cat": 99} fmt.Println(slices.Sorted(maps.Keys(m))
型パラメータのおさらい
このようにイテレータは型パラメータ(ジェネリクス)と密接に関係した機能です。型パラメータを知らずにイテレータを理解することは難しいため、本登壇ではイテレータの説明の前に型パラメータについて、図のように簡単におさらいをしました。
パラメータという言葉は、ここでは「外から変更できるもの」という意味で使用しています。日頃からよく使う関数のパラメータ(引数)と型パラメータを対比させて解説しました。
関数パラメータ(引数)を使用しない場合、関数内で使用する値は関数内で宣言した定数や変数、リテラル表記した値を使用する必要があり、関数の外から変更できません。そこで関数に引数を設けることで外から値を指定できるようになります。また、次のように関数宣言に記述する引数のことを特に仮引数(parameter)と呼び、F(10)
のように実際に指定した引数のことを実引数(argument)と呼びます。
func F(n int) { fmt.Println(n) }
関数の引数を値のパラメータだとすると、外部から変更できるものを型にしたものが型パラメータになります。 たとえば、次のような関数において、引数の型を外部から指定できるようにしたい場合を考えます。
func G(v int) { fmt.Println(v) }
次のように記述すると、引数の型を外部から指定できます。この時、T
は型パラメータと呼ばれ、any
は型制約(type constraint)となります。
型制約は、値に対する型のようなもので型パラメータT
として受け入れる型の条件でインタフェースを指定します。
なお、any
はどんな型でも受け付けることを表しています。
比喩的に表現すると、値に対する型や型パラメータに対する型制約は、求人における募集要項のようなものだと考えると良いでしょう。
条件に示したこと以上の働きはできないので、適切な条件を指定する必要がある点が似ています。
func G[T any](v T) {
fmt.Println(v)
}
また、G[int](10)
のように実際に指定した型のことを型引数(type argument)と呼びます。
関数における値の引数の仮引数・実引数は、型パラメータにおける型パラメータと型引数に対応しています。
さらにジェネリクス(型パラメータ)について学びたい場合は、公式チュートリアルや筆者のスライドを参考にすると良いでしょう。
言語仕様のアップデート
図のように、Go1.23ではfor range
文のrange expressionに関数が使えるようになりました。どんな関数でも良いわけではなく、引数にyield
関数と呼ばれる関数を取るような3種類の関数です。それぞれの違いは引数にとるyield
関数の引数の個数になり、0個、1個、2個の3種類になります。yield
関数はfor range
文のボディに処理を移す役割があるため、これらの数および型はfor range
文の左辺の識別子(または式)に一致しています。
図のようにGo1.22までは、range expressionで扱える式の型は、配列、配列へのポインタ、スライス、文字列、チャネル(双方向、受信専用)、整数(Go1.22で導入)でした。これらの値に対するイテレーションは、型ごとに決められています。たとえば、スライスや配列であれば先頭から順にアクセスし、マップであればランダムな順番でアクセスします。そして、range over func(イテレータ)によって、任意のデータ構造を任意のアルゴリズムでシーケンシャルにアクセスできるようにするになりました。
図のようにイテレータには3つの重要な仕様があります。1つめは、for range
文が実行される際に、range expressionに指定した関数が1回だけ実行される点です。イテレーションのアルゴリズムは、この関数に移譲されると考えると良いでしょう。2つめは、yield
関数を呼ぶとfor range
文のボディに処理が移る点です。for range
文のボディが1回だけ実行され、yield
関数はリターンされます。3つめは、break
などでfor range
文のイテレーションが途中で終了した場合、yield
関数はfalse
を返す点です。
簡単なイテレータの例を次に示します。Alphabet
はアルファベット列を生成するようなイテレータです。
イテレータとは関係ありませんが、rune
型は整数なのでfor c := 'A'; c <= 'Z'; c++
で、c
が'A'
から'Z'
まで変化していきます。
func Alphabet(yield func(rune) bool) { for c := 'A'; c <= 'Z'; c++ { // 'A', 'B', 'C', … if !yield(c) { return } } } func usage() { // ABC for c := range Alphabet { fmt.Printf("%c", c) if c == 'C' { break } } }
標準ライブラリの変更点
Go1.23では言語仕様の変更の他に、iter
パッケージが導入されました。iter
パッケージでは、図のようにSeq[V]
型とSeq2[K, V]
型が提供されています。
この2つの型のおかげで、次のようにイテレータをわかりやすく表記できるようになっています。
func Map[X, Y any](seq iter.Seq[X], f func(X) Y) iter.Seq[Y] { return func(yield func(Y) bool) { for x := range seq { if !yield(f(x)) { break } } } } func usage() { seq := Map(slices.Values([]int{10, 20}), func(x int) string { return fmt.Sprintf("0x%x", x) }) // 0xa // 0x14 for v := range seq { fmt.Println(v) } }
Map
関数は、あるiter.Seq[X]
型のイテレータをiter.Seq[Y]
型に変換する関数です。各要素は第2引数の関数f
によって変換されます。
なお、slices.Values
関数は、Go1.23で導入されたスライスの値からなるイテレータを生成する関数です。
注意点
図のように、yield
が宣言済み識別子(組み込み型)や予約語ではありません。ちなみに宣言済み識別子が増えることはありますが、予約語が増えたことはありません。
予約語は識別子にできない語なので、予約語を追加する場合は後方互換に気を使う必要があるからです。
Go1.21で互換性に関する機能の拡張が行われたため、予約語を追加しても後方互換性がなくなる可能性は低いですが、気軽に増やせるものではないでしょう。
また、yield
関数がfalse
を返した場合、さらに yield
関数を呼ぶとパニックが発生します。そのため、戻り値を if
文などでハンドリングしておく必要があります。
for range
文のrange expressionに値が nil
の関数を指定した場合、パニックが発生します。これは通常の関数呼び出しの場合と同じ挙動になっています。標準ライブラリでは、引数でイテレータを受け取る場合でも nil
かどうかのチェックは特別行っていないようです。一方で戻り値でイテレータを返す場合には、 nil
を返さないように作られています。
おわりに
本稿では、Go1.23で導入されたイテレータについて、その仕様に紹介しました。標準ライブラリで本格的に活用されるのはGo1.24になるようですが、それまでにぜひ触ってみましょう。
PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。