以下の内容はhttps://blog.rmatsuoka.org/entry/2025/01/13/220635より取得しました。


Go で型変換を含んだイテレータの処理をメソッドチェーンっぽく記述する

この記事は「はてなエンジニア Advent Calendar 2024 - Hatena Developer Blog」の44日目の記事です。

JavaScriptScala などで配列やリストを処理するときメソッドチェーンを使って書くことが多い。例えば次のコードは JavaScript のコードである。

type thing = {
  count: number;
  name: string;
};

const somethings: thing[] = ...;

somethings
  .filter(x => x.count > 10)
  .sort((a, b) => a.count - b.count)
  .map(x => x.name)

Method chaining - Wikipedia から引用して加筆。

このコードにおいて map が行う型の変換に注目してほしい。 thing 型から string 型に変わっている。

Go はこのようなメソッドチェーンによる型を変換ができない。なぜなら現状 Go は型パラメータをもったメソッドを定義できないからだ。例えば JavaScriptmap と同等なものを仮に Go で書けるのであれば、次のように書きたい。

// 注意: 以下のコードは実際の Go では動かない!

// Stream[A] はイテレータをメソッドチェーンで処理できるようにメソッドを提供する。
type Stream[A any] ...

func (s *Stream[A]) Map[B any](f func(A) B) *Stream[B] {
    ...
}

しかし残念なことに現状の Go では、"[B any]" のように型パラメータをもったメソッドを定義することができないので上のコードは構文エラーである。

ところで Go の関数には型パラメータをつけることができる。私は関数によってメソッドチェーンっぽい記述ができることに気がついた。

最初に結論をだそう。引用した JavaScript のメソッドチェーンは、Go では次のように書ける。

type thing struct {
    count int
    name string
}

var somethings iter.Seq[thing] = ...

result := Stream(somethings,
    Filter(func(x thing) bool { return x.count > 10 },
        Sort(func(a thing, b thing) int { return a.count - b.count },
            Map(func(x thing) string { return x.name },
                End))))
// result の型: iter.Seq[string]

次のページで実際に実行できる。

go.dev

なにやらお尻に括弧が多いけど、いかにもメソッドチェーンっぽいではないか(!?)。Stream はメソッドチェーン(のようなもの)を開始する関数だ。 JavaScript ではメソッドであった .filter.sort, .map が Go では関数としてそれぞれ Filter, Sort, Map になっている。以下 Map などを変形関数と呼ぶことにする。変形関数は継続の処理をメソッドでつなげる代わりに第2引数で受け取っている。チェーンの最後にある Map の第二引数の End はメソッドチェーンを終わらせる関数である。重要な点として型は推論されるから、型パラメータをわざわざ記述する必要がない。

これはどういう仕組みだろうか。Stream, End, Sort, Filter, Map の定義は次のようになる。

// stream を開始する。
func Stream[F, A any](seqA iter.Seq[A], cont func(iter.Seq[A]) F) F

// stream を終了する。
func End[F any](f F) F

// stream において要素を sort する関数
func Sort[F, A any](cmp func(A, A) int, cont func(iter.Seq[A] F) func(iter.Seq[A]) F

// stream において fn(a) == false の値 a を除去する関数。
func Filter[F, A any](fn func(A) bool, cont func (iter.Seq[A]) F) func(iter.Seq[A]) F

// stream において A -> B にする関数。
func Map[F, A, B any](fn func (A) B, cont func (iter.Seq[B]) F) func(iter.Seq[A]) F

Sort, Filter や Map はどれも似た引数を持っている。例えば Map について考えよう。Map の第1引数 fn は説明不要だろう。しかし第2引数の cont は一体何?型 F って誰? 奇妙なことに引数に変換前の型 B が登場し、返り値に変換前の型 A が登場している。

第2引数 cont はどこから来るのかを念頭におくと考えやすい。 さきほど「変形関数は継続の処理をメソッドでつなげる代わりに第2引数で受け取っている」とさらっと書いた。 この継続こそが cont なのである。 上の例で FilterSort(..., Map(..., End)) という継続の処理を第二引数で受け取っている。 同様に SortMap(..., End) という継続を受け取っている。 つまり cont は「継続の処理をする」関数である。 ちなみに cont の引数は継続で初めに処理される値である。 上の例なら Filter が受け取った継続は最初の処理 である Sortを する値を受け取る。

cont の返り値の型 F は継続の最終結果である。 上記の例で Map は継続として変形関数の返り値ではなく、 End を受け取っている。End のコードは次の通り。

func End[F any](f F) F {
    return f
}

上の例では Map の継続は何もないEnd は単に f を受け取って何もせずに f を返す関数である。

さて変形関数の返り値は直前の変形関数に渡す継続である。 例えば、上記の Sort の返り値は Filter の第二引数に渡されている。すでに書いたとおり Filter の第二引数は 「Sort(..., Map(..., End)) という継続の処理」であるから、つまり Sort は「Sort(..., Map(..., End)) という継続の処理」を作って返しているのである。Sort の実装は次の通り

func Sort[F, A any](cmp func(A, A) int, cont func(iter.Seq[A]) F) func(iter.Seq[A]) F {
    return func(seqA iter.Seq[A]) F {
        newSeqA := slices.Values(slices.SortedFunc(seqA, cmp))
        return cont(newSeqA)
    }
}

チェーンの始まりにおく、継続を最後に受け取る Stream は次の通り。

func Stream[F, A any](seqA iter.Seq[A], cont func(iter.Seq[A]) F) F {
    return cont(seqA)
}

何度も述べたが、 cont は継続の処理をする関数である。 Stream ではただ単に継続の処理を呼ぶだけだ。

このように継続を渡していくプログラミング技法は「継続渡しスタイル」と言われている。

ja.wikipedia.org

この継続を使ってメソッドチェーンを再現する方法は拡張ができそうだ。 例えば

  • End の代わりにReduce や合計を計算する Sumイテレータを配列にする Collect を定義することができるだろう。
  • Map と同じように FlatMap を定義することもできるだろう。




以上の内容はhttps://blog.rmatsuoka.org/entry/2025/01/13/220635より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14