newmo 技術ブログ

技術で地域をカラフルに

まずはイテレータ(range over func)の仕様を学ぼう - Goのイテレータ深堀りNight

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 本稿では、2024年9月24日(火)にファインディ株式会社主催の「Goのイテレータ深堀りNight」というイベントで登壇してきましたので、その報告と内容について紹介します。

findy.connpass.com

「Goのイテレータ深堀りNight」は、2024年8月にリリースされたGo1.23の機能の1であるrange over func(通称イテレータ)について、6人の登壇者がさまざまな角度で10分のライトニングトーク(LT)を行うイベントです。筆者は、トップバッターということで「まずはイテレータ(range over func)の仕様を学ぼう 」という発表を行いました。

登壇に用いた資料は次のリンクから閲覧ができます。

docs.google.com

イテレータの導入経緯

イテレータの導入経緯

イテレータは単独で導入を検討されていた機能ではありません。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パッケージでは提供されていたものの、標準ライブラリとしては追加されませんでした。これらの関数はスライスで返すのではなく、新たに議論が始まっていたイテレータ型として返すべきだという議論になったからです。

次のリンクから当時の議論を追うことができるでしょう。

github.com

github.com

有志が作成しているGoの歴史をまとめているサイトgolang.design/historyによれば、イテレータに関連する提案はかなり前からされていたようです。

golang.design

議論が活発になったのは、ジェネリクスがGo1.18でリリースされ、次のGitHub Discussionが作成されてからでしょう。

github.com

この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にあるように関数を用いた方法に議論が移っていきました。

github.com

そして議論の末、ついに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)と呼びます。 関数における値の引数の仮引数・実引数は、型パラメータにおける型パラメータと型引数に対応しています。

さらにジェネリクス(型パラメータ)について学びたい場合は、公式チュートリアルや筆者のスライドを参考にすると良いでしょう。

docs.google.com

言語仕様のアップデート

イテレータ(range over func)

図のように、Go1.23ではfor range文のrange expressionに関数が使えるようになりました。どんな関数でも良いわけではなく、引数にyield関数と呼ばれる関数を取るような3種類の関数です。それぞれの違いは引数にとるyield関数の引数の個数になり、0個、1個、2個の3種類になります。yield関数はfor range文のボディに処理を移す役割があるため、これらの数および型はfor range文の左辺の識別子(または式)に一致しています。

range over funcと他のrange over

図のようにGo1.22までは、range expressionで扱える式の型は、配列、配列へのポインタ、スライス、文字列、チャネル(双方向、受信専用)、整数(Go1.22で導入)でした。これらの値に対するイテレーションは、型ごとに決められています。たとえば、スライスや配列であれば先頭から順にアクセスし、マップであればランダムな順番でアクセスします。そして、range over func(イテレータ)によって、任意のデータ構造を任意のアルゴリズムでシーケンシャルにアクセスできるようにするになりました。

重要な3つの仕様

図のようにイテレータには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ではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me