この記事は「はてなエンジニア Advent Calendar 2024 - Hatena Developer Blog」の44日目の記事です。
JavaScript や Scala などで配列やリストを処理するときメソッドチェーンを使って書くことが多い。例えば次のコードは 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 は型パラメータをもったメソッドを定義できないからだ。例えば JavaScript の map
と同等なものを仮に 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]
次のページで実際に実行できる。
なにやらお尻に括弧が多いけど、いかにもメソッドチェーンっぽいではないか(!?)。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
なのである。
上の例で Filter
は Sort(..., Map(..., End))
という後続の処理を第二引数で受け取っている。
同様に Sort
は Map(..., 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
ではただ単に後続の処理を呼ぶだけだ。
このように継続を渡していくプログラミング技法は「継続渡しスタイル」と言われている。
この継続を使ってメソッドチェーンを再現する方法は拡張ができそうだ。 例えば
End
の代わりにReduce
や合計を計算するSum
、イテレータを配列にするCollect
を定義することができるだろう。Map
と同じようにFlatMap
を定義することもできるだろう。