Go Wiki: 範囲関数の実験

このページは元々、関数に対する範囲指定という実験的な言語機能について説明していました。この機能はGo 1.23 に追加されました。それを説明するブログ記事があります。

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

関数に対する範囲指定の実行方法の簡単な例は何ですか?

スライスを逆方向に反復処理するこの関数を考えてみましょう

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 に変換されます。他の制御構造はより複雑ですが、それでも可能です。

範囲関数を使用した慣用的な 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 を返すのを無視した場合はどうなりますか?

関数に対する範囲指定ループの場合、本文用に生成された yield 関数は、false を返した後、またはループ自体が終了した後に呼び出されたかどうかを確認します。どちらの場合も、パニックが発生します。

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

制限が必要です。そうでなければ、コンパイラがばかげたプログラムを拒否したときに、人々はバグを報告します。何もない状態から設計しているのであれば、おそらく無制限と言うでしょうが、実装は最大1000までしか許可しないなどと言うでしょう。

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

2つで停止するもう1つの理由は、一般的なコードが定義するための関数シグネチャの数を制限することです。今日、(iter)[/pkg/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

ループ本文のスタックトレースはどのように表示されますか?

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

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

範囲指定関数ループ本文が呼び出しを遅延させた場合、他の種類の範囲ループの場合と同様に、ループを含む外部関数が返るときに実行されます。つまり、defer のセマンティクスは、どのような種類の値が範囲指定されているかには依存しません。依存していた場合、非常に混乱するでしょう。設計の観点からは、そのような依存関係は機能しないようです。範囲指定関数ループ本文で defer を許可しないことを提案する人もいますが、これは範囲指定されている値の種類に基づくセマンティックな変更であり、同様に機能しないようです。

ループ本文の defer は、範囲指定関数で特別なことが起こっていることを知らなくても、実行されるように見えます。

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

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

この回答のペアは、呼び出しが defer 文が実行された時間順序とは異なる順序で実行されることを意味する可能性があり、ここではゴルーチンとの類似性が役立ちます。メイン関数が1つのゴルーチンで実行され、イテレータが別のゴルーチンで実行され、チャネルを介して値を送信しているとします。その場合、外部関数のループ本文がイテレータの後に呼び出しを遅延させたとしても、イテレータが外部関数よりも先に返されるため、defer は作成された順序とは異なる順序で実行される可能性があります。

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

遅延された呼び出しは、通常のリターンと同じ順序でパニックに対して実行されます。最初にイテレータによって遅延された呼び出し、次にループ本文によって遅延され、外部関数にアタッチされた呼び出しです。通常のリターンとパニックが遅延された呼び出しを異なる順序で実行した場合、非常に驚くでしょう。

ここでも、イテレータを独自のゴルーチンで実行することとの類似点があります。ループが開始される前に、メイン関数がイテレータのクリーンアップを遅延させた場合、ループ本文のパニックは遅延されたクリーンアップ呼び出しを実行し、イテレータに切り替え、その遅延された呼び出しを実行してから、メインゴルーチンでパニックを続行するために切り替え直します。これは、追加のゴルーチンがなくても、通常のイテレータで遅延された呼び出しが実行されるのと同じ順序です。

これらの defer とパニックのセマンティクスの詳細な根拠については、このコメントを参照してください。

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

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

関数に対する範囲指定は、手書きのループと同じくらいのパフォーマンスを発揮できますか?

原則として、はい。

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 を非仮想化できます

{
    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])
}

これはかなりの作業のように見えますが、単純な本文と単純なイテレータに対してのみ、インライン化のしきい値未満で実行されるため、必要な作業はわずかです。より複雑な本文またはイテレータの場合、関数呼び出しのオーバーヘッドはわずかです。

特定のリリースでは、コンパイラがこの一連の最適化を実装する場合と実装しない場合があります。私たちはすべてのリリースでコンパイラを改善し続けています。

関数に対する範囲指定の動機をもっと教えていただけますか?

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

もう1つの同様に優れた動機は、結果のシーケンスを収集し、すべてをスライスとして返す標準ライブラリの多くの関数に対して、より良い答えを提供することです。結果を一度に1つずつ生成できる場合、反復処理を可能にする表現は、スライス全体を返すよりもスケーラブルです。この反復処理を表す関数の標準シグネチャはありません。範囲に関数のサポートを追加すると、標準シグネチャが定義され、使用を促進する真の利点が提供されます。

たとえば、スライスを返すが、代わりにイテレータを返す形式が適していると思われる標準ライブラリの関数をいくつか示します

また、スライス形式で提供することに消極的だった関数もあり、イテレータ形式で追加する価値があるでしょう。たとえば、テキストの行を反復処理する strings.Lines(text) があるはずです。

同様に、bufio.Reader または bufio.Scanner の行の反復処理は可能ですが、パターンを知る必要があり、パターンはそれらの2つで異なり、各タイプで異なる傾向があります。反復処理を表現する標準的な方法を確立すると、今日存在する多くの異なるアプローチを収束させるのに役立ちます。

イテレータの追加の動機については、#54245 を参照してください。関数に対する範囲指定の具体的な追加の動機については、#56413 を参照してください。

関数に対する範囲指定を使用する Go プログラムは読みやすいでしょうか?

読みやすくなると考えています。たとえば、明示的なカウントダウンループの代わりに slices.Backward を使用すると、特にカウントダウンループを毎日見ておらず、境界条件を注意深く考えて正しいことを確認する必要がある開発者にとって、理解しやすくなります。

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

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

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

イテレータを別々のコルーチンまたはゴルーチンで実行すると、すべてを1つのスタックに配置するよりもコストがかかり、デバッグが困難になります。すべてを1つのスタックに配置するため、その事実により、いくつかの目に見える詳細が変更されます。最初の例は上記で示しました。スタックトレースには、呼び出し元の関数とイテレータ関数が交互に表示され、プログラムのページには存在しない明示的な yield 関数も表示されます。

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


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