Go Wiki: Rangefunc Experiment

このページでは、関数に対するrangeループという実験的な言語機能について元々説明していました。この機能はGo 1.23に追加されましたブログ記事で説明されています。

このページでは、変更点に関するよくある質問にいくつか回答します。

関数に対するrangeがどのように実行されるかの簡単な例は何ですか?

スライスを逆順にイテレートするためのこの関数を考えてみましょう。

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
    return func(yield func(int, E) bool) {
        for i := len(s)-1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

次のように呼び出すことができます。

s := []string{"hello", "world"}
for i, x := range slices.Backward(s) {
    fmt.Println(i, x)
}

このプログラムは、コンパイラ内で次のようなプログラムに変換されます。

slices.Backward(s)(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

本体の最後にあるreturn trueは、ループ本体の最後にある暗黙のcontinueです。明示的なcontinueも同様にreturn trueに変換されます。break文は代わりにreturn falseに変換されます。他の制御構造はより複雑ですが、それでも可能です。

range関数を使った慣用的なAPIはどのようなものになりますか?

まだわかりませんし、それは最終的な標準ライブラリの提案の一部です。私たちが採用した慣例の1つは、コンテナのAllメソッドがイテレータを返すことです。

func (t *Tree[V]) All() iter.Seq[V]

特定のコンテナは、他のイテレータメソッドも提供するかもしれません。おそらく、リストも逆順イテレーションを提供するでしょう。

func (l *List[V]) All() iter.Seq[V]
func (l *List[V]) Backward() iter.Seq[V]

これらの例は、ライブラリがこれらの種類の関数を読みやすく理解しやすく書くことができることを示すことを意図しています。

より複雑なループはどのように実装されますか?

単純なbreakやcontinueを超えて、他の制御フロー(ラベル付きbreak、continue、ループ外へのgoto、return)は、ループが中断したときにループ外のコードが参照できる変数を設定する必要があります。例えば、returndoReturn = true; return falseのようになり、ここでreturn falsebreakの実装であり、ループが終了すると、生成された他のコードがif (doReturn) returnを実行します。

完全な書き換えは、実装内のcmd/compile/internal/rangefunc/rewrite.goの冒頭で説明されています。

イテレータ関数がyieldの戻り値falseを無視した場合はどうなりますか?

関数に対するrangeループの場合、本体のために生成されたyield関数は、falseを返した後、またはループ自体が終了した後に呼び出されたかどうかをチェックします。いずれの場合も、パニックが発生します。

なぜyield関数は最大2つの引数に制限されているのですか?

制限は必要です。さもないと、人々はばかげたプログラムをコンパイラが拒否したときにバグを報告するでしょう。もし私たちが設計上の制約が全くない状態で設計するなら、無制限だが実装は1000までしか許容しない、といったことを言うかもしれません。

しかし、私たちは設計上の制約が全くないわけではありません。go/astgo/parserが存在し、それらは2つまでのrange値を表現および解析することしかできません。既存のrange使用法をシミュレートするには、2つの値をサポートする必要があることは明らかです。3つ以上の値をサポートすることが重要であれば、これらのパッケージを変更することもできますが、3つ以上をサポートする非常に強力な理由はないように見えるため、最も単純な選択は2つで止めて、これらのパッケージを変更しないことです。将来、より多くの値の強力な理由が見つかれば、その制限を再検討することができます。

2つで止めるもう1つの理由は、一般的なコードが定義する関数シグネチャの数をより限定することです。現在、iterパッケージはイテレータの名前を簡単に定義できます。

package iter

type Seq[V any] func(yield func(V) bool) bool
type Seq2[K, V any] func(yield func(K, V) bool) bool

ループ本体でのスタックトレースはどのようになりますか?

ループ本体はイテレータ関数から呼び出され、イテレータ関数はループ本体が存在する関数から呼び出されます。スタックトレースは、その現実を示します。これは、イテレータのデバッグ、デバッガでのスタックトレースとの整合などに重要です。

ループ本体が呼び出しを遅延した場合、またはイテレータ関数が呼び出しを遅延した場合はどうなりますか?

関数に対するrangeループの本体が呼び出しを遅延した場合、それは他の種類のrangeループと同様に、ループを含む外側の関数が戻るときに実行されます。つまり、deferのセマンティクスは、rangeされている値の種類に依存しません。もし依存するとしたら、非常に混乱するでしょう。そのような依存性は、設計の観点からは機能しないように思われます。一部の人々は、関数に対するrangeループの本体でdeferを禁止することを提案していますが、これはrangeされている値の種類に基づいた意味的な変更であり、同様に機能しないように思われます。

ループ本体のdeferは、関数に対するrangeで特別なことが起こっていることを知らなくても、実行されるように見える exactly なタイミングで実行されます。

イテレータ関数が呼び出しを遅延した場合、その呼び出しはイテレータ関数が戻るときに実行されます。イテレータ関数は、値がなくなったとき、またはループ本体によって停止を指示されたとき(ループ本体がbreakステートメントに到達し、それがreturn falseに変換されたため)に戻ります。これは、ほとんどのイテレータ関数にとってまさに望ましい動作です。たとえば、ファイルから行を返すイテレータは、ファイルを開き、ファイルのクローズを遅延させ、それから行をyieldできます。

イテレータ関数のdeferは、その関数がrangeループで全く使用されていることを知らなくても、実行されるように見える exactly なタイミングで実行されます。

この2つの回答は、呼び出しがdefer文が実行されたときとは異なる時間順序で実行される可能性があることを意味しており、ここではゴルーチンの類推が役立ちます。メイン関数が1つのゴルーチンで実行され、イテレータが別のゴルーチンで実行され、チャネルを介して値を送信していると考えてください。その場合、イテレータが外側の関数よりも早く戻るため、たとえ外側の関数ループ本体がイテレータが呼び出しを遅延した後で呼び出しを遅延したとしても、deferは作成されたときとは異なる順序で実行されることがあります。

ループ本体がパニックになった場合はどうなりますか? またはイテレータ関数がパニックになった場合はどうなりますか?

遅延呼び出しは、通常のreturnの場合と同じ順序でパニックに対しても実行されます。まずイテレータによって遅延された呼び出し、次にループ本体によって遅延され、外側の関数にアタッチされた呼び出しです。通常のreturnとパニックで遅延呼び出しが異なる順序で実行された場合、非常に驚くべきことでしょう。

ここでも、イテレータを独自のゴルーチンで実行することに例えることができます。ループが開始する前にメイン関数がイテレータのクリーンアップを遅延させた場合、ループ本体でのパニックは遅延されたクリーンアップ呼び出しを実行し、それがイテレータに切り替わり、その遅延呼び出しを実行し、その後メインゴルーチンでのパニックを続行するために戻ります。これは、余分なゴルーチンがなくても、通常のイテレータで遅延呼び出しが実行されるのと同じ順序です。

これらのdeferとpanicのセマンティクスに関するより詳細な根拠については、こちらのコメントを参照してください。

イテレータ関数がループ本体でのパニックを回復した場合、どうなりますか?

コンパイラとランタイムはこのケースを検出し、ランタイムパニックをトリガーします。

関数に対するrangeは、手書きのループと同じくらい効率的に実行できますか?

原則として、はい。

slices.Backwardの例をもう一度考えてみましょう。これはまず次のように変換されます。

slices.Backward(s)(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

コンパイラはslices.Backwardが自明であることを認識し、インライン化して次のように生成します。

func(yield func(int, E) bool) bool {
    for i := len(s)-1; i >= 0; i-- {
        if !yield(i, s[i]) {
            return false
        }
    }
    return true
}(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

次に、関数リテラルがすぐに呼び出されることを認識し、それをインライン化します。

{
    yield := func(i int, x string) bool {
        fmt.Println(i, x)
        return true
    }
    for i := len(s)-1; i >= 0; i-- {
        if !yield(i, s[i]) {
            goto End
        }
    }
End:
}

次に、yieldをdevirtualizeできます。

{
    for i := len(s)-1; i >= 0; i-- {
        if !(func(i int, x string) bool {
            fmt.Println(i, x)
            return true
        })(i, s[i]) {
            goto End
        }
    }
End:
}

次に、その関数リテラルをインライン化できます。

{
    for i := len(s)-1; i >= 0; i-- {
        var ret bool
        {
            i := i
            x := s[i]
            fmt.Println(i, x)
            ret = true
        }
        if !ret {
            goto End
        }
    }
End:
}

その時点から、SSAバックエンドはすべての不要な変数を見通すことができ、そのコードを次のように扱います。

for i := len(s)-1; i >= 0; i-- {
    fmt.Println(i, s[i])
}

これはかなりの作業のように見えますが、単純な本体と単純なイテレータに対してのみ、インライン化のしきい値以下で実行されるため、関与する作業は小さいです。より複雑な本体やイテレータの場合、関数呼び出しのオーバーヘッドは取るに足らないものです。

どのリリースでも、コンパイラがこの一連の最適化を実装するかどうかは未定です。私たちはリリースごとにコンパイラの改善を続けています。

関数に対するrangeのさらなる動機を提供できますか?

最も最近の動機は、ジェネリックスの追加です。これにより、順序付きマップなどのカスタムコンテナが開発されることが予想され、これらのカスタムコンテナがrangeループとうまく連携することが望ましいでしょう。

もう1つの同様に良い動機は、標準ライブラリの多くの関数が結果のシーケンスを収集し、その全体をスライスとして返すという問題に対するより良い解決策を提供することです。結果が一度に1つずつ生成できる場合、それらを反復処理できる表現は、スライス全体を返すよりも効率的にスケーリングします。この反復を表す関数の標準的なシグネチャがありません。rangeに関数のサポートを追加することは、標準的なシグネチャを定義し、その使用を促す本当の利点を提供するでしょう。

たとえば、標準ライブラリには、スライスを返すものの、おそらくイテレータを返す形式に値する関数がいくつかあります。

  • strings.Split
  • strings.Fields
  • 上記バイトのバリアント
  • regexp.Regexp.FindAllとその仲間たち

また、スライス形式で提供することに躊躇していたが、イテレータ形式で追加されるべき関数もあります。たとえば、テキスト内の行を反復処理するstrings.Lines(text)があるべきです。

同様に、bufio.Readerやbufio.Scannerでの行のイテレーションは可能ですが、パターンを知っている必要があり、そのパターンは両者で異なり、型ごとに異なる傾向があります。イテレーションを表現する標準的な方法を確立することは、今日存在する多くの異なるアプローチを収束させるのに役立つでしょう。

イテレータに関する追加の動機については、#54245を参照してください。特に関数に対するrangeに関する追加の動機については、#56413を参照してください。

関数に対するrangeを使用するGoプログラムは読みやすくなりますか?

私たちはそうできると考えています。例えば、明示的なカウントダウンループの代わりにslices.Backwardを使用する方が理解しやすいはずです。特に、カウントダウンループを毎日見かけるわけではなく、境界条件を慎重に考えて正確であることを確認する必要がある開発者にとっては。

関数に対するrangeの可能性は、range xを見たときに、xが何であるかを知らない場合、どのようなコードが実行されるか、どの程度効率的であるかを正確に知らないことを意味するのは事実です。しかし、スライスとマップのイテレーションは、実行されるコードと速度に関してはすでにかなり異なります。チャネルは言うまでもありません。通常の関数呼び出しもこの問題を抱えています。一般的に、呼び出された関数が何をするのか全くわかりませんが、それでも読みやすく理解しやすいコードを書く方法を見つけ、パフォーマンスに関する直感を構築しています。

関数に対するrangeについても同様のことが起こるでしょう。私たちは時間とともに有用なパターンを構築し、人々は最も一般的なイテレータを認識し、それらが何をするかを知るようになるでしょう。

なぜセマンティクスは、イテレータ関数がコルーチンやゴルーチンで実行された場合とまったく同じではないのですか?

イテレータを別のコルーチンやゴルーチンで実行することは、すべてを1つのスタックに置くよりも費用がかかり、デバッグも困難です。すべてを1つのスタックに置くため、その事実は特定の可視の詳細を変更します。上記で最初に見られたのは、スタックトレースが呼び出し関数とイテレータ関数がインターリーブされていることを示すだけでなく、プログラムのページには存在しない明示的なyield関数も示すことです。

イテレータ関数を独自のコルーチンやゴルーチンで実行することを類推やメンタルモデルとして考えることは役立つかもしれませんが、場合によってはそのメンタルモデルは最良の答えを与えません。なぜなら、それは2つのスタックを使用するのに対し、実際のP実装は1つのスタックを使用するように定義されているからです。


このコンテンツはGo Wikiの一部です。