Zコンビネータを思いつきたい
2024/01/22友達とラムダ計算の話をしていて、Zコンビネータを天下り的に与えられるのが納得できなかったので、導出を追いかけようと思います。
普通の再帰関数
n
の階乗を求める関数f
を、再帰を使って普通に書いてみます。
const f = (n) => (n === 0 ? 1 : n * f(n - 1));
今回の目標は、代入を使わずにf
を実装することです。上記のコードはconst f =
と書いているのでダメです。
代入を使わずに再帰する
代入が使えないということで、とりあえずconst f =
を削除してみます。
(n) => (n === 0 ? 1 : n * f(n - 1));
このコードは動きません。f
がどこにもないからです。
ラムダ計算という関数しかない世界で、f
をどこかからもらってくるには、引数で受け取るしかないはずです。(f) =>
を先頭に追加します。
(f) => (n) => n === 0 ? 1 : n * f(n - 1);
この新しい関数をg
と呼ぶことにします。
gの定義:
(f) => (n) => n === 0 ? 1 : n * f(n - 1)
g
は、f
を受け取ってf
を返す関数です。引数はそのままf
であり、戻り値である(n) => n === 0 ? 1 : n * f(n - 1)
もまたf
の実装そのものだからです。
以下のようにして、g
に名前を付けて扱いやすくします。
// prettier-ignore
(
(g) => g
// ^ gを受け取る
)
(
// gを渡す
(f) => (n) => n === 0 ? 1 : n * f(n - 1)
);
(g) => g
はg
をそのまま返しているので、上記のコードを評価するとg
になります。目標はf
を実装することなので、ここでなんとかしてf
を返せないか考えます。
g
は、f
を受け取ってf
を返す関数でした。なのでg(f)
とすればf
が得られます。しかし、ここにはf
が無いので無理です。
// prettier-ignore
(
(g) => g(f)
// ^ fはないのでダメ
)
(
// g
(f) => (n) => n === 0 ? 1 : n * f(n - 1)
);
ここにあるのはg
だけです。うーん、なんとかg(g)
みたいな感じでf
が得られるようにできないかな?と考えます。
そういう都合の良い関数h
の定義を考えてみます。
hの定義(不完全):
(h) => (n) => n === 0 ? 1 : n * ???(n - 1)
h
は、h
を受け取ってf
を返す関数です。h(h)
とするとf
が得られるので都合が良いですね。
???
の部分が問題です。以前はf
と書いていましたが、ここにはf
はありません。
しかしh
はあるので、h(h)
とすることでf
が得られると気づきます。そのようにh
を定義したからです。
したがって、h
は以下のように定義できます。
hの定義:
(h) => (n) => n === 0 ? 1 : n * h(h)(n - 1)
これを使って先程のコードのg
をh
に置き換えます。
// prettier-ignore
(
(h) => h(h)
// ^^^^ fを返せた!
)
(
// h
(h) => (n) => n === 0 ? 1 : n * h(h)(n - 1)
);
これは実際に動きます。以下のコードは120
に評価されます。
// prettier-ignore
(
(h) => h(h)
)
(
// h
(h) => (n) => n === 0 ? 1 : n * h(h)(n - 1)
)
(5); // 120に評価される
すごい!1
リファクタリング
現時点でも目標は達成なのですが、もうちょっとリファクタリングしてみます。
というのも、(n) => n === 0 ? 1 : n * h(h)(n - 1)
の中でh(h)
しているのが嫌です。階乗のロジックの中に再帰の都合のコードが混ざっている感じがするからです。
というわけで、h
を受け取った直後を関数でくるんで、h(h)
と階乗のロジックを分離します。
// prettier-ignore
(
(h) => h(h)
)
(
// リファクタ中のh
(h) =>
((f) => (n) => n === 0 ? 1 : n * f(n - 1))
// ^ fを受け取る
// ^ 単にfと書ける
(h(h))
// ^^^^ fを生成して渡す *1
);
残念ながらこれは関数呼び出しが無限に発生してしまい動作しません。
JavaScriptでは引数部分が適用よりも前に評価されます(適用順序、作用的順序)。リファクタ中のh
にh
を渡すと、まず引数である*1
部分のh(h)
が評価されます。しかしそのh(h)
もまたh
にh
を渡しているので、内部のh(h)
が評価されて……というように無限に続いてしまいます。
これを回避するために、*1
部分を関数でくるんで、評価を遅延させます。
くるんだ後もこれはf
として振る舞ってほしいです。なのでまず引数はn
です。次に戻り値については、階乗の計算結果f(n)
であるべきで、h(h)
がf
なので、h(h)(n)
と書けます。
// prettier-ignore
(
(h) => h(h)
)
(
(h) =>
((f) => (n) => n === 0 ? 1 : n * f(n - 1)) // *2
// ^ ここでfを適用するまで↓のh(h)の評価が遅延される
((n) => h(h)(n))
// ^^^^ hの定義よりf
// ^^^^^^^^^^^^^^ (n) => f(n)、つまりfと同じ
);
ここで、*2
の部分がg
の定義と同じだと気づきます。これを外側から与えてやるようにします。
// prettier-ignore
(
(g) =>
// ^ gを受け取る
(
(h) => h(h)
)
(
(h) =>
(g)
// ^ 単にgと書ける *3
((n) => h(h)(n))
)
)
(
// gを渡す
(f) => (n) => n === 0 ? 1 : n * f(n - 1)
);
*3
付近の無駄なカッコを消して整理します。
// prettier-ignore
(
(g) =>
(
(h) => h(h) // *4
)
(
(h) => g((n) => h(h)(n)) // *5
)
)
(
// g
(f) => (n) => n === 0 ? 1 : n * f(n - 1)
);
*4
と*5
の行は見た目が似ていることに気づきます。実際、これらはどちらもh
を受け取ってf
を返すという、同じことをしています。
*4
についてはh(h)
を返しているため、h
の定義より自明ですね。*5
については、まず(n) => h(h)(n)
がf
となるように先程作ったことを思い出します。そしてそれをg
に与えているため、g
の定義よりf
を返しているとわかります。
というわけで、*4
を*5
で書き換えても大丈夫そうです。そのほうが長くなってしまいますが、対称性があってきれいなのでそうしてみます。
// prettier-ignore
(
// Z
(g) =>
(
(h) => g((n) => h(h)(n))
)
(
(h) => g((n) => h(h)(n))
)
)
(
// g
(f) => (n) => n === 0 ? 1 : n * f(n - 1)
);
これでZコンビネータが現れました。
Wikipediaに書かれてるZコンビネータと比べると、引数の名前が違うだけで等価であるとわかります。
// 今回求めたZコンビネータ
(g) => ((h) => g((n) => h(h)(n)))((h) => g((n) => h(h)(n)));
// Wikipediaのやつ
(f) => ((x) => f((y) => x(x)(y)))((x) => f((y) => x(x)(y)));
階乗に限らず、再帰したい関数をg
の形で実装し、それに(g) => ((h) => g((n) => h(h)(n)))((h) => g((n) => h(h)(n)))
を適用すれば、関数だけで再帰し放題なので勝ちです。
おまけ
TypeScriptには再帰型があるので、Zコンビネータにも型を付けられます。
n
という命名は階乗に限った話なので、arg
に置き換えました。
type F<Arg, Result> = (arg: Arg) => Result;
type G<Arg, Result> = (f: F<Arg, Result>) => F<Arg, Result>;
type H<Arg, Result> = (h: H<Arg, Result>) => F<Arg, Result>;
const z = <Arg, Result>(g: G<Arg, Result>) =>
((h) => g((arg) => h(h)(arg)))((h: H<Arg, Result>) => g((arg) => h(h)(arg)));
const fib: F<number, number> = z((fib) => (n) => n === 1 ? 1 : n * fib(n - 1));
console.log(fib(5)); // 120
予備知識が無い状態で上記の型を書くのは難しいでしょう。導出を理解することで、とても直感的に書けているのがわかります。
まとめ
Zコンビネータを思いついてみました。ウソです。
実際はいろいろな資料を参考にした上で、めちゃくちゃ噛み砕いた説明を自分なりにまとめただけでした。
これを無から生成しちゃう人がいるってやばいっすね。
参考資料
- 誰得UNIX: Yコンビネータを導出してみる
- 完全には理解できていないですが、多分今回の説明と同じ流れのことをやっているようで大変参考になりました。
- チョウゲンボウとホシムクドリから賢人鳥を導出する
- まったく理解できていないですが、いつかは僕も鳥を愛でたいなと思いました。
- これに似た形がSICPの練習問題 4.21に登場します。問題を解いていた当初は感覚でコードを書いていましたが、こうして導出を追ってみると納得です。↩