第 7 課:エフェクト関数と合成:逆変換の自動合成
一言でいうと:前回の課では「逆関数は誰かがすでに書いてくれている」と仮定していました。今回は、すべてのエフェクトに逆関数を自ら携帯させ、合成時には逆が後入れ先出しで自動的に組み立てられると規定します——これで、何を載せても外せるようになり、半分だけ外すことさえできます。
1. なぜ「前回の課で逆関数は与えられている」では不十分なのか?
前回の課のモデルを覚えていますか。状態変換 f に逆変換 f⁻¹ を組み合わせ、取り消すときは recover が 1 ステップで蓄積されたすべてのエフェクトを一括で復元する、というものでした。このモデルには現実的でない点が 2 つあります(論文 3.1.2 節冒頭の原文の通りです):
問題 1:逆関数は事前に分かっているわけではない。 現実では、エフェクトの逆は呼び出し側がその場で提供しなければなりません。たとえば「ポップアップを開く」というエフェクト——ウィンドウをどこに開き、どんな内容を表示するかは発起者だけが知っており、「どうやって閉じるか」も発起者にしか説明できません。他人があらかじめ代わりに書いておくことはできないのです。
問題 2:recover は全か無かである。 全部取り消すか、まったく取り消さないかのどちらかで、他のエフェクトを残したまま、そのうちの 1 つだけを取り消すことができません。しかし現実には部分取り消しをしたいことがよくあります。たとえば「ステップ 2 の変更」は取り消しつつ、「ステップ 1」の結果は残したい、という場合です。
| 比較 | 前回の課(track / recover) | 今回の課(エフェクト関数) |
|---|---|---|
| 逆関数を誰が提供するか | 「与えられている」と仮定 | 呼び出し側がその場で提供 |
| 取り消しの粒度 | 全か無か | 部分取り消しが可能 |
| 復元の方式 | すべてをアトミックに復元 | 後入れ先出し、必要に応じて再生 |
ではどうするか? 論文のやり方はとても素朴です。モデルの入力側と出力側の両方に同時に「追加情報」を与える——
- 入力側:変換結果だけでなく、今回の逆関数も一緒に返す(Γ → Γ×(Γ→Γ))ことで、「手動追跡」の能力を得る;
- 出力側:追跡付きコンテキスト ∂Γ 上でも同様に逆関数を返す(∂Γ → ∂²Γ)ことで、「部分復元」の能力を得る。
一言でいえば:「逆」をシステムの仮定から、各エフェクトが持ち歩く荷物へと変えるのです。
2. エフェクト関数 𝔈Γ:状態 + 逆関数を一緒に返す
まず形式定義を見てみましょう(論文の定義 6):
𝔈Γ ≔ Γ → Γ × (Γ → Γ)
平易に言い換えると:エフェクト関数は現在のコンテキスト γ(ガンマ)を受け取り、2 つ組を返します:
- 第 1 成分 δ(デルタ):変換後の新しい状態;
- 第 2 成分 g:今回のエフェクトの逆関数——これを新しい状態に適用すれば、今回のエフェクトを取り消せます。
擬似コードで書くと、形は次のようになります:
function 打开弹窗(当前状态) {
const 新状态 = 当前状态 + ',弹窗已打开';
const 撤销函数 = (新状态) => 去掉弹窗(新状态); // これが今回の逆
return [新状态, 撤销函数]; // 重要:逆関数を状態と一緒に返す
}
この「一緒に返す」ことに注意してください。エフェクトを起こした者が、その場で取り消し方法を差し出さなければなりません。 システムは逆が既製であると仮定しません——これこそ、前節で述べた「呼び出し側がその場で提供する」ということです。
厳密エフェクト関数 𝔈Γ∗:逆は本物の逆でなければならない
「逆関数を携帯する」だけでは不十分です。呼び出し側が偽の逆を差し出したらどうなるでしょう? たとえば「ポップアップを開く」の逆関数が実は「何もしない」だったら、取り消しはインチキです。
そこで論文はもう 1 つ制約を加えています(定義 6 の 2 行目):任意の γ について、e(γ) = (δ, g) ならば、必ず
g(δ) = γ
が成り立たなければならない。
つまり:逆関数を新しい状態に適用すると、正確に元の状態へ戻らなければならない。 この制約を満たすエフェクト関数を厳密エフェクト関数(𝔈Γ∗)と呼びます。論文は可換図式によって、返される第 2 成分が確かに変換そのものの逆であり、でたらめな関数ではないことを保証しています。
🎁 たとえるなら:普通のエフェクト関数は「返品の約束」のようなもの、厳密エフェクト関数は「到着時の検査」のようなものです——返されたものが本当に元通りになることを検証して初めて、約束は有効になります。
良い知らせもあります(定理 8)。厳密性は合成の中で失われません——厳密エフェクト関数は effect によって ∂Γ に持ち上げられた後も厳密なままであり、品質保証は全行程で有効です。
3. エフェクト合成 ⋄:合成の逆は LIFO で自動的に組み立てられる
エフェクト関数が返すのは 2 つ組であり、もはや「状態 → 状態」の普通の関数ではないため、普通の関数のように直接合成することはできません。そこで論文は新しい演算 ⋄(「ダイヤモンド」と読みます)を定義しています。
定義 9 の構造を見てみましょう——2 つのエフェクト関数 f、g が与えられたとき:
function 复合(f, g) {
return (当前状态) => {
const [中间状态, s] = g(当前状态); // ステップ 1:まず g を実行
const [最终状态, t] = f(中间状态); // ステップ 2:次に f を実行
return [最终状态, (状态) => s(t(状态))]; // 合成逆 = まず t、次に s
};
}
- 実行順序:先に g、後に f(関数合成 f∘g の書き方の慣習と一致します:⋄ の左に書くのが後に実行されるもの);
- 合成逆:s は g の逆、t は f の逆で、合成逆は s∘t = まず t(f の逆)を適用し、次に s(g の逆)を適用する——つまり、後に実行した f を先に取り消し、先に実行した g を後で取り消します。
これが有名な**後入れ先出し(LIFO)**です:最後に実行されたエフェクトが、最初に取り消されます。
⋄ 运算:任意复合效应的逆,都能按 LIFO 自动组合出来
上の図は同じ理屈を「まず f を載せ、次に g を載せる」として描いています。アンロード時にはまず g の逆を適用し、次に f の逆を適用します——復元順序はロード順序のちょうど反転です。(図中の f、g は単なる例示のラベルです。重要なのは「後に載せたものを先に外す」という原則です。)
🎁 たとえ:皿を重ねたりコートを着たりするのと同じです——後から載せた皿が先に取り去られ、後から着たコートが先に脱がれます。最後に入ってきた者が、最初に出ていきます。
定理 11 こそがこの節の目玉です:
effectΓ(f) ⋄ effectΓ(g) = effectΓ(f ⋄ g)
平易に言い換えると:「合成の追跡 = 追跡の合成」。f と g を先に合成してから全体を追跡してもよいし、それぞれを追跡してから合成してもよい——どちらの道を通っても結果はまったく同じです。
これは何を意味するでしょう? 任意の合成エフェクトの逆は、合成によって自動的に導出され、手で書く必要がまったくないのです。各「原子」エフェクトに逆関数を 1 つ提供するだけで、合成された全体の逆は自動的に LIFO で組み立てられます。
💡 これが「逆変換の自動合成」の意味です:ブロックを組み立てるとき、各ブロックは解体方法を内蔵しており、組み立てられた全体も自動的に解体できます——「全体」のために解体方法を別途書く必要は永遠にありません。(定理 10 も保証しています:2 つの厳密エフェクトを合成しても結果は厳密なままで、品質保証は失われません。)
4. ロード = 逆の蓄積、アンロード = 逆の再生
最後にコンポーネントの場面に戻り、これらの部品を組み立てましょう。論文の表現は実に美しいです:時間的合成可能性——
- コンポーネントのロード:一連のエフェクト関数を順に適用し、それぞれの逆を 1 つの「累積逆関数」φ に蓄積していくことに相当します;
- コンポーネントのアンロード:φ を適用し、コンテキストを合成前の状態に復元することに相当します。
擬似コード:
let 当前状态 = 初始状态;
let 累积逆 = (状态) => 状态; // 最初は:取り消すものは何もない
function 加载组件(效应函数) {
const [新状态, 本次逆] = 效应函数(当前状态);
累积逆 = (状态) => 累积逆(本次逆(状态)); // 新しい逆を外側に重ねる:最新のものを先に取り消す
当前状态 = 新状态;
}
function 卸载组件() {
当前状态 = 累积逆(当前状态); // 累積逆の再生 = LIFO で自動的に巻き戻し
累积逆 = (状态) => 状态; // クリア
}
システムは「誰を先にロードし、誰を後にロードしたか」をまったく覚えておく必要がありません——順序は累積逆の入れ子構造の中にエンコードされており、アンロード時には自然に後入れ先出しで再生されます。論文でこのステップは effectΓ が ∂Γ 上で行うことです:入力 (γ, φ) を受け取り、e を適用して (δ, g) を得、新しいコンテキストは (δ, φ∘g) になります——新しい逆が古い逆の外側に重なります。
では「2 つのコンポーネントが互いに干渉しないか」という点はどうでしょう? 論文が与える判定基準は ⋄ の下での可換性です:
- f ⋄ g = g ⋄ f(2 つのエフェクトが可換)ならば、どちらを先に外しても同じで互いに干渉しないことを意味し、それぞれ独立したコンポーネントの中に安全に同居できます;
- 可換でないなら、システムは順序制約を課さなければなりません:コンポーネント内部では「後入れ先出しの規律」によって(3.3.2 節のエフェクトイテレーター)、コンポーネント間では「依存駆動の活性化順序」によって(3.2 節のリアクティブ・コエフェクト)。
⚠️ この筋道を覚えておいてください。「可換」は外す順序に意味があるかどうかを決め、「自動導出」は逆が必ず存在することを保証します。 次の課では、コンポーネント内部で LIFO の規律を強制する「エフェクトイテレーター」を見ていきます。
要点の振り返り
- 逆関数は事前に分からない:エフェクトを適用する際に呼び出し側がその場で提供しなければならない。recover は全か無かなので、部分取り消しができるモデルが必要。
- エフェクト関数 𝔈Γ = Γ → Γ×(Γ→Γ):現在の状態を入力し、「新しい状態 + 今回の逆関数」を返す。
- 厳密エフェクト関数 𝔈Γ∗:g(δ) = γ という制約が 1 つ増え、逆が本物の逆であることを保証する。
- エフェクト合成 f ⋄ g:先に g を実行し、次に f を実行。合成逆 = 先に f を取り消し、次に g を取り消す——後入れ先出し(LIFO)。
- 定理 11:effect は ⋄ 演算を保存する(「合成の追跡 = 追跡の合成」)。任意の合成エフェクトの逆は合成から自動導出され、手書き不要。
- ロード = 逆の蓄積、アンロード = 逆の再生:復元順序は自動的に LIFO。可換なエフェクトは独立して共存でき、可換でないものは順序の規律で制約される。
🚀 次回予告:「可換でないエフェクトは順序通りに進めなければならない」とすれば、その順序を誰が保証するのでしょうか? 答えは、コンポーネント内部で「後入れ先出しの規律」を強制する構造——エフェクトイテレーター(3.3.2 節)です。
セルフテスト · エフェクト合成
回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。
