The Go Blog

関数型でのRange

イアン・ランス・テイラー
2024年8月20日

はじめに

これは、GopherCon 2024での私の講演のブログ記事版です。

関数型でのrangeは、Go 1.23リリースで追加された新しい言語機能です。このブログ記事では、なぜこの新機能が追加されるのか、それが正確には何なのか、そしてどのように使うのかを説明します。

なぜ?

Go 1.18以降、Goで新しい汎用コンテナ型を記述できるようになりました。例えば、この非常にシンプルなSet型を考えてみましょう。これはマップの上に実装された汎用型です。

// Set holds a set of elements.
type Set[E comparable] struct {
    m map[E]struct{}
}

// New returns a new [Set].
func New[E comparable]() *Set[E] {
    return &Set[E]{m: make(map[E]struct{})}
}

当然、セット型には要素を追加する方法と、要素が存在するかどうかを確認する方法があります。ここでの詳細は重要ではありません。

// Add adds an element to a set.
func (s *Set[E]) Add(v E) {
    s.m[v] = struct{}{}
}

// Contains reports whether an element is in a set.
func (s *Set[E]) Contains(v E) bool {
    _, ok := s.m[v]
    return ok
}

そして、他のことの中でも、2つのセットのユニオンを返す関数が必要になるでしょう。

// Union returns the union of two sets.
func Union[E comparable](s1, s2 *Set[E]) *Set[E] {
    r := New[E]()
    // Note for/range over internal Set field m.
    // We are looping over the maps in s1 and s2.
    for v := range s1.m {
        r.Add(v)
    }
    for v := range s2.m {
        r.Add(v)
    }
    return r
}

このUnion関数の実装を少し見てみましょう。2つのセットのユニオンを計算するために、各セットに含まれるすべての要素を取得する方法が必要です。このコードでは、セット型の非公開フィールドに対してfor/rangeステートメントを使用しています。これはUnion関数がセットパッケージで定義されている場合にのみ機能します。

しかし、誰かがセット内のすべての要素をループしたいと考える理由はたくさんあります。このセットパッケージは、ユーザーがそれを行うための何らかの方法を提供する必要があります。

それはどのように機能すべきでしょうか?

Set要素のプッシュ

1つのアプローチは、関数を受け取るSetメソッドを提供し、Set内のすべての要素に対してその関数を呼び出すことです。これをPushと呼びます。なぜなら、Setがすべての値を関数にプッシュするからです。ここで、関数がfalseを返した場合、その呼び出しを停止します。

func (s *Set[E]) Push(f func(E) bool) {
    for v := range s.m {
        if !f(v) {
            return
        }
    }
}

Go標準ライブラリでは、sync.Map.Rangeメソッド、flag.Visit関数、filepath.Walk関数などのケースでこの一般的なパターンが使用されているのがわかります。これは一般的なパターンであり、正確なものではありません。実際、これら3つの例はいずれもまったく同じようには機能しません。

Pushメソッドを使ってセットのすべての要素を出力する例は次のとおりです。要素に対して希望の処理を行う関数をPushで呼び出します。

func PrintAllElementsPush[E comparable](s *Set[E]) {
    s.Push(func(v E) bool {
        fmt.Println(v)
        return true
    })
}

Set要素のプル

Setの要素をループするもう1つのアプローチは、関数を返すことです。関数が呼び出されるたびに、Setから値が返され、その値が有効かどうかを報告するブール値も返されます。ループがすべての要素を通過すると、ブール値の結果はfalseになります。この場合、これ以上値が必要ないときに呼び出すことができる停止関数も必要です。

この実装では、セット内の値用と値の返却停止用という2つのチャネルを使用します。ゴルーチンを使用してチャネルに値を送信します。next関数は要素チャネルから読み取ることでセットから要素を返し、stop関数は停止チャネルを閉じることでゴルーチンに終了を指示します。これ以上値が必要ないときにゴルーチンが終了するように、stop関数が必要です。

// Pull returns a next function that returns each
// element of s with a bool for whether the value
// is valid. The stop function should be called
// when finished calling the next function.
func (s *Set[E]) Pull() (func() (E, bool), func()) {
    ch := make(chan E)
    stopCh := make(chan bool)

    go func() {
        defer close(ch)
        for v := range s.m {
            select {
            case ch <- v:
            case <-stopCh:
                return
            }
        }
    }()

    next := func() (E, bool) {
        v, ok := <-ch
        return v, ok
    }

    stop := func() {
        close(stopCh)
    }

    return next, stop
}

標準ライブラリには、この方法とまったく同じように機能するものはありません。runtime.CallersFramesreflect.Value.MapRangeはどちらも似ていますが、関数を直接返すのではなく、メソッドを持つ値を返します。

Pullメソッドを使ってSetのすべての要素を出力する例は次のとおりです。Pullを呼び出して関数を取得し、その関数をforループで繰り返し呼び出します。

func PrintAllElementsPull[E comparable](s *Set[E]) {
    next, stop := s.Pull()
    defer stop()
    for v, ok := next(); ok; v, ok = next() {
        fmt.Println(v)
    }
}

アプローチの標準化

セットのすべての要素をループする2つの異なるアプローチを見てきました。異なるGoパッケージでは、これらのアプローチや他のいくつかのアプローチが使用されています。これは、新しいGoコンテナパッケージを使い始めると、新しいループメカニズムを学習する必要があるかもしれないことを意味します。また、コンテナ型がループを異なる方法で処理するため、いくつかの異なる種類のコンテナで機能する1つの関数を記述できないことも意味します。

コンテナをループするための標準的なアプローチを開発することで、Goエコシステムを改善したいと考えています。

イテレータ

これはもちろん、多くのプログラミング言語で発生する問題です。

1994年に初めて出版された人気の「デザインパターン」という書籍では、これをイテレータパターンと説明しています。「イテレータは、集約オブジェクトの内部表現を公開せずに、その要素に順番にアクセスする方法を提供する」ために使用します。この引用が言う「集約オブジェクト」とは、私が「コンテナ」と呼んできたものです。集約オブジェクト、またはコンテナとは、私たちが議論してきたSet型のように、他の値を保持する単なる値です。

プログラミングにおける多くのアイデアと同様に、イテレータはバーバラ・リスコフが1970年代に開発したCLU言語にまで遡ります。

今日では、C++、Java、Javascript、Python、Rustなど、多くの人気のある言語が何らかの形でイテレータを提供しています。

しかし、Goのバージョン1.23以前では提供されていませんでした。

For/range

ご存知のように、Goにはスライス、配列、マップといった組み込みのコンテナ型があります。そして、それらの値の内部表現を公開せずに要素にアクセスする方法、つまりfor/rangeステートメントがあります。for/rangeステートメントは、Goの組み込みコンテナ型(および文字列、チャネル、Go 1.22からはint)で機能します。

for/rangeステートメントは反復処理ですが、今日の人気のある言語に登場するようなイテレータではありません。それでも、for/rangeを使ってSet型のようなユーザー定義コンテナを反復処理できると便利です。

しかし、Goのバージョン1.23以前では、これはサポートされていませんでした。

このリリースの改善点

Go 1.23では、ユーザー定義コンテナ型に対するfor/rangeと、標準化された形式のイテレータの両方をサポートすることにしました。

for/rangeステートメントを拡張し、関数型に対するrangeをサポートしました。これがユーザー定義コンテナのループにどのように役立つかは、以下で説明します。

また、関数型をイテレータとして使用することをサポートするために、標準ライブラリの型と関数を追加しました。イテレータの標準的な定義により、異なるコンテナ型とスムーズに連携する関数を記述できるようになります。

(一部の)関数型でのRange

改良されたfor/rangeステートメントは、任意の関数型をサポートしていません。Go 1.23以降、単一の引数を取る関数でのrangeをサポートするようになりました。この単一の引数自体は、0〜2個の引数を取り、boolを返す関数である必要があります。慣習的に、これをyield関数と呼びます。

func(yield func() bool)

func(yield func(V) bool)

func(yield func(K, V) bool)

Goにおけるイテレータとは、これら3つの型のいずれかの関数を意味します。以下で説明するように、標準ライブラリには別の種類のイテレータ、つまりプルイテレータがあります。標準イテレータとプルイテレータを区別する必要がある場合、標準イテレータをプッシュイテレータと呼びます。それは、後述するように、yield関数を呼び出すことによって一連の値を「プッシュ」するからです。

標準(プッシュ)イテレータ

イテレータを使いやすくするために、新しい標準ライブラリパッケージiterは、SeqSeq2の2つの型を定義しています。これらはイテレータ関数型、つまりfor/rangeステートメントで使用できる型の名前です。Seqという名前は、イテレータが値のシーケンスをループするため、sequenceの略です。

package iter

type Seq[V any] func(yield func(V) bool)

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

// for now, no Seq0

SeqSeq2の違いは、Seq2がマップのキーと値のようなペアのシーケンスであるという点だけです。この記事では簡略化のためにSeqに焦点を当てますが、ほとんどのことはSeq2にも当てはまります。

イテレータの仕組みを説明するには、例を見るのが一番簡単です。ここで、SetメソッドのAllは関数を返します。Allの戻り値の型はiter.Seq[E]なので、イテレータを返すことがわかります。

// All is an iterator over the elements of s.
func (s *Set[E]) All() iter.Seq[E] {
    return func(yield func(E) bool) {
        for v := range s.m {
            if !yield(v) {
                return
            }
        }
    }
}

イテレータ関数自体は、引数として別の関数、つまりyield関数を取ります。イテレータは、セット内のすべての値に対してyield関数を呼び出します。この場合、イテレータ(Set.Allによって返される関数)は、以前に見たSet.Push関数によく似ています。

これはイテレータの仕組みを示しています。値のシーケンスに対して、シーケンス内の各値でyield関数を呼び出します。yield関数がfalseを返した場合、それ以上値は必要ないため、イテレータは必要なクリーンアップを実行して単に戻ることができます。yield関数がfalseを返さない場合、シーケンス内のすべての値でyieldを呼び出した後、イテレータは単に戻ることができます。

それが機能の仕組みですが、これらを初めて見たとき、最初の反応は「たくさんの関数が飛び交っている」だろうと認識しましょう。それは間違いではありません。2つのことに焦点を当てましょう。

1つ目は、この関数のコードの最初の行を超えると、イテレータの実際の実装は非常にシンプルであるということです。つまり、yieldがfalseを返したら停止し、セットのすべての要素でyieldを呼び出すだけです。

        for v := range s.m {
            if !yield(v) {
                return
            }
        }

2つ目は、これを使うのが本当に簡単だということです。s.Allを呼び出してイテレータを取得し、for/rangeを使ってs内のすべての要素をループします。for/rangeステートメントは任意のイテレータをサポートしており、これがどれほど簡単に使えるかを示しています。

func PrintAllElements[E comparable](s *Set[E]) {
    for v := range s.All() {
        fmt.Println(v)
    }
}

この種のコードでは、s.Allは関数を返すメソッドです。私たちはs.Allを呼び出し、それが返す関数に対してfor/rangeを使って範囲指定しています。この場合、Set.Allをイテレータ関数自体にすることもできましたが、イテレータ関数を返すようにすることもできました。しかし、イテレータを返す関数が引数を取る必要がある場合や、セットアップ作業を行う必要がある場合など、一部のケースではうまくいきません。慣習として、すべてのコンテナ型がイテレータを返すAllメソッドを提供するように奨励しています。これにより、プログラマはAllを直接範囲指定するか、Allを呼び出して範囲指定できる値を取得するかを覚える必要がなくなります。彼らは常に後者を行うことができます。

よく考えてみると、コンパイラはs.Allによって返されるイテレータに渡すyield関数を作成するために、ループを調整していることがわかるでしょう。これを効率的にし、ループ内のbreakpanicのようなものを正しく処理するために、Goコンパイラとランタイムにはかなりの複雑さがあります。このブログ記事では、それらのいずれも扱いません。幸いなことに、この機能を実際に使用する際には、実装の詳細は重要ではありません。

プルイテレータ

for/rangeループでイテレータを使用する方法を見てきました。しかし、単純なループがイテレータを使用する唯一の方法ではありません。たとえば、時には2つのコンテナを並行して反復処理する必要があるかもしれません。どのようにそれを行うのでしょうか?

答えは、別の種類のイテレータ、つまりプルイテレータを使用することです。標準イテレータ(プッシュイテレータとも呼ばれる)は、yield関数を引数として取り、yield関数を呼び出すことでシーケンス内の各値をプッシュする関数であることを見てきました。

プルイテレータは逆の働きをします。つまり、呼び出すたびにシーケンス内の次の値を返すように書かれた関数です。

2種類のイテレータの違いを再度説明して、覚えておきましょう。

  • プッシュイテレータは、シーケンス内の各値をyield関数にプッシュします。プッシュイテレータはGo標準ライブラリの標準イテレータであり、for/rangeステートメントによって直接サポートされます。
  • プルイテレータは逆の働きをします。プルイテレータを呼び出すたびに、シーケンスから別の値をプルして返します。プルイテレータはfor/rangeステートメントによって直接サポートされません。しかし、プルイテレータをループする通常のforステートメントを記述するのは簡単です。実際、以前にSet.Pullメソッドを使用する例を見たことがあります。

プルイテレータを自分で記述することもできますが、通常はそうする必要はありません。新しい標準ライブラリ関数iter.Pullは、標準イテレータ、つまりプッシュイテレータである関数を受け取り、関数のペアを返します。1つ目はプルイテレータで、呼び出されるたびにシーケンス内の次の値を返す関数です。2つ目は、プルイテレータが完了したときに呼び出すべき停止関数です。これは以前に見たSet.Pullメソッドに似ています。

iter.Pullによって返される最初の関数(プルイテレータ)は、値と、その値が有効かどうかを報告するブール値を返します。シーケンスの終わりでは、ブール値はfalseになります。

iter.Pullは、シーケンスの最後まで読み取らない場合に備えて、停止関数を返します。一般的なケースでは、プッシュイテレータ(iter.Pullの引数)がゴルーチンを起動したり、反復が完了したときにクリーンアップが必要な新しいデータ構造を構築したりする可能性があります。プッシュイテレータは、yield関数がfalseを返した場合(つまり、これ以上値が必要ない場合)にクリーンアップを実行します。for/rangeステートメントと一緒に使用する場合、for/rangeステートメントは、ループがbreakステートメントまたはその他の理由で早期に終了した場合に、yield関数がfalseを返すことを保証します。一方、プルイテレータでは、yield関数を強制的にfalseにすることはできないため、停止関数が必要です。

これを別の言い方をすると、停止関数を呼び出すと、プッシュイテレータによって呼び出されたときにyield関数がfalseを返すようになります。

厳密に言えば、プルイテレータがシーケンスの終わりに達したことを示すためにfalseを返す場合、停止関数を呼び出す必要はありませんが、通常は常に呼び出す方が簡単です。

以下は、プルイテレータを使用して2つのシーケンスを並行して処理する例です。この関数は、2つの任意のシーケンスが同じ順序で同じ要素を含んでいるかどうかを報告します。

// EqSeq reports whether two iterators contain the same
// elements in the same order.
func EqSeq[E comparable](s1, s2 iter.Seq[E]) bool {
    next1, stop1 := iter.Pull(s1)
    defer stop1()
    next2, stop2 := iter.Pull(s2)
    defer stop2()
    for {
        v1, ok1 := next1()
        v2, ok2 := next2()
        if !ok1 {
            return !ok2
        }
        if ok1 != ok2 || v1 != v2 {
            return false
        }
    }
}

この関数はiter.Pullを使用して、2つのプッシュイテレータs1s2をプルイテレータに変換します。deferステートメントを使用して、プルイテレータの処理が完了したら確実に停止されるようにします。

その後、コードはループし、プルイテレータを呼び出して値を取得します。最初のシーケンスが完了した場合、2番目のシーケンスも完了していればtrue、そうでなければfalseを返します。値が異なる場合、falseを返します。その後、次の2つの値をプルするためにループします。

プッシュイテレータと同様に、プルイテレータを効率的にするためにGoランタイムにはいくつかの複雑さがありますが、これは実際にiter.Pull関数を使用するコードには影響しません。

イテレータの反復

これで、関数型でのrangeとイテレータについて知るべきことはすべて理解できました。ぜひ活用してください!

それでも、いくつか言及する価値のある点があります。

アダプター

イテレータの標準的な定義の利点は、それらを使用する標準アダプタ関数を記述できることです。

たとえば、値のシーケンスをフィルタリングし、新しいシーケンスを返す関数を以下に示します。このFilter関数は引数としてイテレータを受け取り、新しいイテレータを返します。もう1つの引数は、Filterが返す新しいイテレータにどの値を含めるかを決定するフィルタ関数です。

// Filter returns a sequence that contains the elements
// of s for which f returns true.
func Filter[V any](f func(V) bool, s iter.Seq[V]) iter.Seq[V] {
    return func(yield func(V) bool) {
        for v := range s {
            if f(v) {
                if !yield(v) {
                    return
                }
            }
        }
    }
}

以前の例と同様に、関数シグネチャは最初に見ると複雑に見えます。シグネチャを理解すれば、実装は簡単です。

        for v := range s {
            if f(v) {
                if !yield(v) {
                    return
                }
            }
        }

コードは入力イテレータを範囲指定し、フィルタ関数をチェックし、出力イテレータに入れるべき値でyieldを呼び出します。

以下にFilterの使用例を示します。

(現在、Go標準ライブラリにはFilterのバージョンはありませんが、将来のリリースで追加される可能性があります。)

二分木

コンテナ型をループするのにプッシュイテレータがどれほど便利であるかを示す例として、この単純な二分木型を考えてみましょう。

// Tree is a binary tree.
type Tree[E any] struct {
    val         E
    left, right *Tree[E]
}

ツリーに値を挿入するコードは示しませんが、当然、ツリー内のすべての値を範囲指定する方法が必要です。

ブール値を返す方がイテレータコードを書きやすいことがわかります。for/rangeでサポートされる関数型は何も返さないため、ここでのAllメソッドは、イテレータ自体(ここではpushと呼ばれる)を呼び出し、ブール値の結果を無視する小さな関数リテラルを返します。

// All returns an iterator over the values in t.
func (t *Tree[E]) All() iter.Seq[E] {
    return func(yield func(E) bool) {
        t.push(yield)
    }
}

// push pushes all elements to the yield function.
func (t *Tree[E]) push(yield func(E) bool) bool {
    if t == nil {
        return true
    }
    return t.left.push(yield) &&
        yield(t.val) &&
        t.right.push(yield)
}

pushメソッドは、再帰を使ってツリー全体を巡回し、各要素でyieldを呼び出します。yield関数がfalseを返した場合、メソッドはスタックの最上位までfalseを返します。それ以外の場合、反復が完了すると単に戻ります。

これは、このイテレータアプローチを使って、複雑なデータ構造でも簡単にループできることを示しています。ツリー内の位置を記録するために別のスタックを維持する必要はありません。ゴルーチンの呼び出しスタックをそのまま使用できます。

新しいイテレータ関数

Go 1.23では、スライスとマップパッケージにもイテレータを操作する関数が新しく追加されました。

以下は、スライスパッケージの新しい関数です。AllValuesは、スライスの要素に対するイテレータを返す関数です。Collectはイテレータから値を取得し、それらの値を保持するスライスを返します。他の関数についてはドキュメントを参照してください。

以下は、マップパッケージの新しい関数です。AllKeys、およびValuesは、マップの内容に対するイテレータを返します。Collectは、イテレータからキーと値を取得し、新しいマップを返します。

標準ライブラリのイテレータの例

以下は、以前に見たFilter関数と一緒にこれらの新しい関数をどのように使用するかの例です。この関数は、intからstringへのマップを受け取り、引数nよりも長いマップ内の値だけを保持するスライスを返します。

// LongStrings returns a slice of just the values
// in m whose length is n or more.
func LongStrings(m map[int]string, n int) []string {
    isLong := func(s string) bool {
        return len(s) >= n
    }
    return slices.Collect(Filter(isLong, maps.Values(m)))
}

maps.Values関数は、m内の値に対するイテレータを返します。Filterはそのイテレータを読み取り、長い文字列だけを含む新しいイテレータを返します。slices.Collectはそのイテレータから新しいスライスに読み込みます。

もちろん、これを簡単に行うループを記述することもできますし、多くの場合、ループの方が明確です。常にこのスタイルのコードを記述することを推奨するわけではありません。とはいえ、イテレータを使用する利点は、この種の関数がどのシーケンスでも同じように機能することです。この例では、Filter内のコードをまったく変更することなく、Filterが入力としてマップを、出力としてスライスを使用していることに注目してください。

ファイル内の行をループする

これまでに見てきた例のほとんどはコンテナに関するものですが、イテレータは柔軟性があります。

イテレータを使用しない、バイトスライス内の行をループするこの単純なコードを考えてみましょう。これは簡単に記述でき、かなり効率的です。

    nl := []byte{'\n'}
    // Trim a trailing newline to avoid a final empty blank line.
    for _, line := range bytes.Split(bytes.TrimSuffix(data, nl), nl) {
        handleLine(line)
    }

しかし、bytes.Splitは行を保持するためにバイトスライスのスライスを割り当てて返します。ガベージコレクタは、最終的にそのスライスを解放するために少し作業をする必要があります。

以下は、バイトスライスの行に対するイテレータを返す関数です。通常のイテレータシグネチャの後、関数は非常にシンプルです。データから行を取り続け、何も残らなくなるまで、各行をyield関数に渡します。

// Lines returns an iterator over lines in data.
func Lines(data []byte) iter.Seq[[]byte] {
    return func(yield func([]byte) bool) {
        for len(data) > 0 {
            line, rest, _ := bytes.Cut(data, []byte{'\n'})
            if !yield(line) {
                return
            }
            data = rest
        }
    }
}

これで、バイトスライスの行をループするコードは次のようになります。

    for line := range Lines(data) {
        handleLine(line)
    }

これは以前のコードと全く同じくらい簡単に記述でき、行のスライスを割り当てる必要がないため、少し効率的です。

プッシュイテレータに関数を渡す

最後の例として、rangeステートメントでプッシュイテレータを使用する必要がないことを示します。

以前、セットの各要素を出力するPrintAllElements関数を見ました。セットのすべての要素を出力する別の方法を以下に示します。s.Allを呼び出してイテレータを取得し、手書きのyield関数を渡します。このyield関数は値を出力し、trueを返すだけです。ここで2つの関数呼び出しがあることに注意してください。s.Allを呼び出してイテレータ自体である関数を取得し、その関数を手書きのyield関数で呼び出しています。

func PrintAllElements[E comparable](s *Set[E]) {
    s.All()(func(v E) bool {
        fmt.Println(v)
        return true
    })
}

この方法でこのコードを記述する特定の理由はありません。これは、yield関数が魔法ではないことを示す単なる例です。それは好きな関数でかまいません。

go.modの更新

最後の注意点:すべてのGoモジュールは、使用する言語バージョンを指定します。つまり、既存のモジュールで新しい言語機能を使用するには、そのバージョンを更新する必要がある場合があります。これはすべての新しい言語機能に当てはまり、関数型でのrangeに特有のものではありません。関数型でのrangeはGo 1.23リリースで新しいため、これを使用するには少なくともGo言語バージョン1.23を指定する必要があります。

言語バージョンを設定する方法は(少なくとも)4つあります。

  • コマンドラインでgo get go@1.23を実行する(またはgoディレクティブのみを編集する場合はgo mod edit -go=1.23)。
  • go.modファイルを手動で編集し、go行を変更する。
  • モジュール全体では古い言語バージョンを維持しつつ、特定のファイルで関数型でのrangeの使用を許可するために//go:build go1.23ビルドタグを使用する。

次の記事: 新しいユニークパッケージ
前の記事: Go 1.23がリリースされました
ブログインデックス