この記事は「はてなエンジニア 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 で書けるのであれば、次のように書きたい。
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))))
次のページで実際に実行できる。
go.dev
なにやらお尻に括弧が多いけど、いかにもメソッドチェーンっぽいではないか(!?)。Stream はメソッドチェーン(のようなもの)を開始する関数だ。 JavaScript ではメソッドであった .filter や .sort, .map が Go では関数としてそれぞれ Filter, Sort, Map になっている。以下 Map などを変形関数と呼ぶことにする。変形関数は継続の処理をメソッドでつなげる代わりに第2引数で受け取っている。チェーンの最後にある Map の第二引数の End はメソッドチェーンを終わらせる関数である。重要な点として型は推論されるから、型パラメータをわざわざ記述する必要がない。
これはどういう仕組みだろうか。Stream, End, Sort, Filter, Map の定義は次のようになる。
func Stream[F, A any](seqA iter.Seq[A], cont func(iter.Seq[A]) F) F
func End[F any](f F) F
func Sort[F, A any](cmp func(A, A) int, cont func(iter.Seq[A] F) func(iter.Seq[A]) F
func Filter[F, A any](fn func(A) bool, cont func (iter.Seq[A]) F) func(iter.Seq[A]) F
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 ではただ単に継続の処理を呼ぶだけだ。
このように継続を渡していくプログラミング技法は「継続渡しスタイル」と言われている。
ja.wikipedia.org
この継続を使ってメソッドチェーンを再現する方法は拡張ができそうだ。
例えば
End の代わりにReduce や合計を計算する Sum、イテレータを配列にする Collect を定義することができるだろう。
Map と同じように FlatMap を定義することもできるだろう。