🎶

Go 1.23のrange over funcを概念モデルで理解する

に公開

前置き

はじめまして、株式会社Digeonでインターンをしているk-satoです。
みなさんはGo 1.23で正式に導入されたrange over funcはご存じでしょうか。
恥ずかしながら、私はつい最近知り合いのエンジニアに教えていただきました。
ただ初見だとあまり理解できずにもやもやしたので、自分が納得できる解釈を見つけるまで色々と調べてみました。
今回はそのまとめになります。

注意点

  • あくまで個人的な解釈です
  • 処理の理解のため概念モデルを使用しますが、実際には正しくない内容も含まれます
  • どう扱うか、扱いこなすかはnon-goalです
  • ただrange over funcを使うだけなら必要のない知識になります

Fortunately the implementation details are not important when it comes to actually using this feature.公式ブログより)
(意訳:実際にこの機能を使う上では、実装の詳細を知らなくても問題ありません。 )

range over funcとは?

詳しい説明の前に、range over funcとはどういったものかを一度見てみます。
フィボナッチ数列を例に簡単にコードを書いてみました。
用語については後述するので、流し気味で読んでみてください。

func main() {
    for v := range genFibonacciIter(5) {
        fmt.Println(v) // output: 1, 1, 2, 3, 5
    }
}

// genFibonacciIterはフィボナッチ数列をn個生成し、都度yieldを呼び出すイテレータを作成します
func genFibonacciIter(n int) iter.Seq[int] {
    return func(yield func(int) bool) { // rangeで使用されるイテレータ関数
        a, b := 0, 1
        for i := 1; i <= n; i++ {
            a, b = b, a+b
            if !yield(a) {
                return
            }
        }
    }
}

https://go.dev/play/p/SMyEFYBdBqh

このようにrange expression(rangeの右側)に関数を渡すことが出来ます。
具体的には以下のシグネチャを持つ関数を使用することが出来ます。

func(yield func() bool)
func(yield func(V) bool)
func(yield func(K, V) bool)

これらは同様にGo 1.23で実装されたiterパッケージを使うことで、より分かりやすく表現できます(コード例ではすでにそうしています)。
iter.Seq[int]は、func(yield func(int) bool)という関数型の別名です。
標準ライブラリでは以下のように定義されています。

// これらは主に値を返すシーケンスを扱うために用意されています。
// そのため値を返さないfunc(yield func() bool)の別名は用意されていません。
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

個人的に?が浮かんだのは以下の点です。

  • イテレータ関数(returnされている関数)が、どこにreturnされてどこで呼び出されているか
  • yieldとはなにか
  • for v := range ...のvには何が入るのか

ただ今では自分の中で腹落ちしているので、次項から処理の流れと解釈について話していきます。

処理の流れ

それでは上のコードがどういった動きをするかを見ていきます。
以下の順で処理が実行されます。

  1. genFibonacciIter(5)が呼び出される
  2. genFibonacciIterがイテレータ関数をreturnする
  3. コンパイラがyieldを作る
  4. コンパイラが、returnされたイテレータ関数をyieldを引数として呼び出す
  5. イテレータ関数の中のforループがスタートし、yieldが呼び出される
  6. yieldがfalseを返せばその時点でreturn
  7. forループが終了し、イテレータ関数の呼び出しも終了

各ステップについて詳しく調べてみます。

イテレータ関数が呼び出されるまでの流れ

実装ではgenFibonacciIterfor ... rangeで呼び出されていましたね。

// genFibonacciIterはフィボナッチ数列をn個生成し、都度yieldを呼び出すイテレータを作成します
func genFibonacciIter(n int) iter.Seq[int] {
    return func(yield func(int) bool) { // rangeで使用されるイテレータ関数
        a, b := 0, 1
        for i := 1; i <= n; i++ {
            a, b = b, a+b
            if !yield(a) {
                return
            }
        }
    }
}

この時にコンパイラが裏側で行っていることを概念的なコードで表してみます(あくまでイメージです)。
例えばfという変数は実際のコードには存在しませんが、内部的には下記のイメージで動いています。

// イテレータ関数 iter.Seq[int] のreturn
f := genFibonacciIter(5)

// yieldの作成(forループ1回分の処理を表す関数)
yield := func(v int) bool {
    fmt.Println(v) // forのbody
    return true
}

// イテレータ関数の呼び出し
f(yield)
  • genFibonacciIterはイテレータ関数をreturnするので、fで受け取ります。
  • yieldは、コードでいうfor ... rangeのbody(繰り返される部分)を含んだ関数になります(詳しくは後述します)。
  • f(yield)によってイテレータ関数が呼び出されます。

ここまでがイテレートが始まるまでの準備段階になります。

yieldの中身

ここではyieldの中身と、どうやってmainのforループが回っているかについて話します。
前項で、yieldは以下のように作られると説明しました。

yield := func(v int) bool {
    fmt.Println(v) // forのbody
    return true // ループ制御
}

コメントにもあるように、yieldforループ1回分の処理(body)とループの継続判断を担う関数になります。
つまりこのyieldの呼び出し一回分が、forループの実行一回分と紐づいているということです。

// main関数のfor range
for v := range genFibonacciIter(5) {
    fmt.Println(v)
} 

なぜこうなるかのカギはrangeの動きです。
slice・map・channelに対するrangeは、自分から値を取得しにいく動きをします。
なのでforはそれを変数で受け取り、bodyを実行するだけです。

ちなみにこのslice・map・channelに対するrangeを、pull型のイテレーションと言います。
rangeが値を取ってくる(pullしてくる)イメージです。

しかしrange over funcの場合は特殊で、(ここもあくまで概念的にですが)コンパイラがyieldを作成し、rangeがイテレータ関数に渡します。
yieldはforループ1回分の処理を表し、それを繰り返し呼び出すことでループを進めるのがイテレータ関数です。
つまり本来for ... rangeが担っているループ制御を、イテレータ関数に委任するイメージで自分は解釈しています。

またまたちなみにですが、pull型に対してこちらをpush型のイテレーションと言います。
rangeがyieldをイテレータ関数に渡す(pushする)イメージです。

少しまとめてみます。
ループをイテレータ関数に任せるとなると、forのbodyをどうにかイテレータ関数に渡さないといけません。そのための関数がyieldです。
コンパイラがbodyを含んだyieldを作りイテレータ関数に渡すことで、イテレータ関数が任意のタイミングでbodyを実行することが出来ます。

yieldがboolを返す理由

この記事の序盤に、以下のシグネチャを使用することが出来ると言いました。

func(yield func() bool)
func(yield func(V) bool)
func(yield func(K, V) bool)

yieldの引数については、forが受け取れる形式でないといけないので納得できると思います。
ではなぜ戻り値はboolでないといけないんでしょうか。

前項でも少し触れましたが、これはループを制御するためです。
例えばmain側のforが以下のような例を考えてみます。

func main() {
    for v := range genFibonacciIter(5) {
        if v == 3 {
            break
        }
        fmt.Println(v) // output: 1, 1, 2
    }
}

このように、何らかの条件でループを打ち切りたい時があるでしょう。
この場合、概念的には、yieldはbreakをreturn falseと置き換えたような動きをすると捉えることが出来ます。

yield := func(v int) bool {
    if v == 3 {
        return false
    }
    fmt.Println(v)
    return true
}

注意にあるように厳密には違いますが、自分の中ではこの解釈で納得しています。
(良い考え方があればぜひご教示ください!)

これによってyieldがループを継続するかの意思を伝えてくれるため、イテレータ関数が安心してyieldを呼び出すことが出来ます。

まとめ

ここまでの内容をまとめると、

  • range over funcとは、range expressionで与えたイテレータ関数が主導権を握り、yieldを通してforループを進めていく仕組み
  • yieldはforループ1回分の処理とループの継続判断を担う関数であり、forのbodyをもとにコンパイラによって構成される
  • イテレータ関数は、yieldを呼び出すことで任意のタイミングでforループの処理を実行できる

つまり、forループの進行をrange側ではなく、イテレータ関数側に委ねることが出来る仕組みだと自分は理解しました。

最初は全く理解できなかったrange over funcですが、概念モデルを使って整理することで、ようやく自分なりに納得できる形で理解することが出来ました。

自分なりの解釈ではありますが、誰かの理解の助けになれば嬉しいです。

ここまでお付き合い頂きありがとうございました!

参考資料

"知り合いのエンジニア"の記事

https://zenn.dev/tingtt/articles/0bb50445f1d8cf

Go公式ブログ

https://go.dev/blog/range-functions

株式会社Digeon

Discussion