第 9 課:コンポーネントのライフサイクル:冪等、イテレーション、エポック、非同期
一言でいうと:コンポーネント = 「依存宣言 d(自分が必要なもの)」+「エフェクト関数 e(自分が提供するもの)」。目標状態は 2 つだけ——Active(エフェクトが適用済みで依存がすべて満たされている)と Inactive(どちらか一つでも欠けていれば非アクティブ)。そして現実の世界で安全に両者を行き来させるのが 4 つの仕組み——冪等(各逆関数は最大 1 回しか効果を発揮しない)、イテレーション(1 回の Reload を多数のステップに分け、いつでも中断できる)、エポック(目標状態にバージョン番号を付けて陳腐化を見抜く)、非同期の慣性(遷移には実時間がかかるので、一度始まったら最後まで実行させる)。
1. コンポーネントと目標状態:1 つのコンポーネント、2 つの顔
前の 2 課では 2 つの「半分」をそれぞれ学びました:
- エフェクト(第 6〜7 課):コンポーネントがアクティブなとき何をするか、そしてそれをどうやって取り消すか。
- 余エフェクト(第 5 課、第 8 課):コンポーネントが環境に何を提供してもらう必要があるか、そして環境が変化したときにどう反応するか。
この課では両側を 1 つのランタイムエンティティ——コンポーネント(component)——に統合します。論文の定義 20 は実に端的です:
コンポーネント ℭ = 𝔇 × 𝔈、すなわち 2 要素組 (d, e):
- d(依存宣言、余エフェクト仕様):コンポーネントが環境に提供を要求する依存——「自分が必要なもの」。
- e(エフェクト関数):コンポーネントがアクティブ状態のときにコンテキストへ提供するエフェクト——「自分が提供するもの」。
たとえ話をしましょう:プラグインをインストールする前に、まずその「インストール説明書」を見ます——どのインターフェースに接続し、どの設定を読むのか(d)。インストールしてアクティブ化した後、プラグインは仕事を始めます(e)。宣言と行動は、別物です。
目標状態:Active か Inactive か?
コンポーネントの 2 つの側面が共同で目標状態を決定します。論文が示す判定基準は2 つの条件が同時に成立することです:
| 側面 | 条件 | 平たく言うと |
|---|---|---|
| エフェクト側 | コンポーネントが「生きている」:エフェクトがコンテキストに適用済みで、対応する逆関数がまだ呼び出されていない | 自分がやった仕事がまだ残っていて、取り消されていない |
| 余エフェクト側 | 宣言されたすべての依存が満たされている(𝜎 ⊨ d) | 欲しいものを環境がすべてくれた |
- 両方とも成立 → 目標状態は Active(アクティブ)
- それ以外 → 目標状態は Inactive(非アクティブ)
「または」ではなく「かつ」である点に注意してください:依存がすべて揃っていても、エフェクトがまだ適用されていなければコンポーネントは Active ではありません。逆に、エフェクトが適用されていても依存が取り去られれば、コンポーネントは即座に Inactive に戻ります。
Reload と Unload:状態が変われば遷移する
目標状態が変化するたびに(どちらの側面が原因でも)、システムは遷移を開始します:
- Reload(ロード):コンポーネントのエフェクト関数 e を実行し、副作用をコンテキストに蓄積する——「アクティブ化して仕事を始める」。
- Unload(アンロード):蓄積された逆関数を再生し、コンテキストを元の状態に戻す——「仕事を終え、やったことをすべて取り消す」。
最も単純なライフサイクルは、以下の 2 状態マシンです:
生命周期 = 可回退效应与反应式余效应「相遇」的地方
Reload と Unload は、第 8 課の「リアクティブ」を具現化した動作です:環境が変われば、コンポーネントは自動的に再ロードまたはアンロードされ、すべてがロールバック可能で痕跡を残さない。 しかし現実の世界は 2 状態マシンよりはるかに複雑です——論文は続く 4 つの節で 4 つの問いに答えます。それがこの課のタイトルにある 4 つの言葉です。
2. 冪等な回復:逆関数は最大 1 回しか効果を発揮しない
まず、考えるだけで恐ろしい問題を見てみましょう:Unload は「蓄積された逆関数を再生する」とありますが、同じ逆関数が 2 回再生されないことは誰が保証するのでしょうか?
ある逆関数が 2 回呼び出されたら、それはやってもいないことを取り消したのと同じことです——コンテキストが二度壊されることになり、ロールバック可能なシステムにとって絶対に許容できません。論文が指摘するように、リスクは「全体の回復」という層にはありません(recover の層は構造上安全です:回復後、蓄積関数は恒等関数にリセットされるため、2 回目の回復は自然に何もしない)。リスクはより目立たない場所——局所的な解放関数——にあります。
第 6 課を思い出してください:effect は 1 つのエフェクトの解放関数を呼び出し元に返し(論文の「𝜕 → 𝜕²」成分)、そのエフェクトをいつ「手放す」かは呼び出し元自身が決めます。ここで問題です:呼び出し元がこの解放関数を 2 回呼び出したらどうなるでしょうか? その逆関数が 2 回適用され、コンテキストが壊れてしまいます。そこで論文の規定はシンプルです:
返される解放関数はすべて冪等でなければならない——最大 1 回しか効果を発揮せず、2 回目の呼び出しは何もしない。
鍵となる設計:生成的ハンドル(idem)
この「最大 1 回」はグローバルなスイッチではなく、論文で定義される冪等ガード idem によって実現されます。その核心は生成的(generative)ハンドルです:
function idem(g) {
const h = freshHandle(); // 生成的:ラップのたびに全く新しいハンドル h を得る
return function (γ) {
if (used.has(h)) return γ; // h が「使用済み」とマークされている → 何もせずそのまま返す
used.add(h); // 最初の呼び出し → まず h を「使用済み」にマーク
return g(γ); // その後で初めて逆関数を実際に実行
};
}
3 つのポイントに分解して見ましょう:
- ハンドルは新鮮:idem を適用するたびに全く新しいハンドル h が生成され、それはこの解放関数だけのものであり、他のどこからも取得できません(h は idem の型に現れません)。したがって、任意の 2 つの解放関数は決してハンドルを共有せず、互いに干渉しません。
- 状態は「使ったかどうか」だけを記録:「使用済みハンドル」の記録。h が記録になければ → 有効で、発火すると h が記録に書き込まれ → 無効になる。2 回目に入ってきたとき、h はすでに記録にあるので、そのまま返すだけです。
- 依然として純粋関数:解放関数は、作用する状態 γ と「前提条件」(h が未使用であること)だけに依存し、隠れた可変状態には依存しません——ロールバック可能システムの「すべてが説明可能」という性質は少しも失われていません。
日常生活で言えば、これは使い切りのヒューズや1 回限りの入場券のようなものです——使ったら溶断し、2 回目に発火しようとしても、ドアはすでに閉まっています。実装上は、各 dispose クロージャが新鮮に armed 変数をキャプチャするのが、まさにこの仕組みです。
💡 論文における effect の冪等バリアントが変えるのは 1 か所だけです:返される逆関数を idem でラップすること。順方向の蓄積部分はそのままです(もともと冪等な recover がカバーしているため)。したがって、これまで学んだ準同型や蓄積などの結論はすべて影響を受けません。
3. イテレーション:1 回の Reload を多数のステップに分けられる
現実の Reload が一撃で完了することはめったにありません:コンポーネントをアクティブ化するには、連続していくつかの作業が必要かもしれません——データベースへの接続、ファイルを開く、リスナーの登録、バックグラウンドタスクの起動。これらすべてを一度にアトミックに済ませなければならないとしたら、システムはあまりに鈍重になってしまいます。
論文の答えはエフェクトイテレータ(effect iterator)です:1 回の Reload を多数のステップに分割し、各ステップで少しずつ実行します。各ステップの実行が終わると、3 要素組 (δ, g, o) を返します:
| 成分 | 意味 |
|---|---|
| δ | このステップの実行完了後の新しいコンテキスト |
| g | このステップのエフェクトの逆関数(将来アンロード時に取り消すために使う) |
| o | 継続(continuation):Nothing = ここで終わり。Just(次のイテレータ) = まだ次のステップがある |
遷移全体はこの構造に沿って再帰的に進みます:1 ステップ進むごとに、そのステップの逆関数 g を実行順に合成して累積回復関数に組み込みます。最終的に次のように蓄積されます:
φ ∘ g1 ∘ g2 ∘ … ∘ gk
この合成関数を適用するときは、右端から始めます——最後に実行されたエフェクトが最初に取り消される、教科書どおりの**後入れ先出し(LIFO)**です:後に開けたものを先に閉じ、先に開けたものを後に閉じる。回復順序に抜かりはありません。
ステップとステップの間は、天然の中断ポイント
イテレータの最も素晴らしい点は、2 つのステップの間の境界が、そのまま自然な中断ポイントになることです。
- 2 つのステップの間で目標状態が変わった場合(依存が取り去られた、ユーザーがキャンセルした、コンポーネントが置き換えられた……)、システムはここで止まることができます。
- 止まるときには、その時点までに蓄積された逆関数を再生し、すでに実行済みの部分を安全にすべて取り消します。
- 追加の仕組みは一切不要です——各ステップが返す
Maybeの継続そのものが境界マーカーだからです。
論文によれば、エフェクトイテレータは本質的に「具体化された有界継続」であり、主流の言語における yield がまさにそれです:ジェネレータを書くとき、各 yield の間では安全に中断・キャンセルできます。つまりこのモデルは、あなたがすでに使いこなせるすべてのものに直接マッピングできます。
🎯 粒度のトレードオフ:単一ステップのエフェクト(1 ステップで完了し、すぐに Nothing を返す)は退化ケースです——遷移はアトミックで、中断境界がありません。ステップが細かいほどシステムは目標状態の変化に速く反応できますが、ステップごとに条件をチェックする必要があるため、オーバーヘッドも大きくなります。細かい = 俊敏だが電力を食う、粗い = 手間いらずだが鈍い——これが設計者が天秤にかけるべきトレードオフです。
💡 余談:論文は双方向エフェクトイテレータにも言及しています——逆関数の成分もイテレータに置き換え、Unload もインクリメンタルに進められ、中断して Reload に戻すこともできるようにするものです。残念ながら双方向イテレーションをネイティブにサポートする言語は少ないため、これは主に理論上の拡張にとどまります。
4. 一貫性とエポック:目標状態にバージョン番号を付ける
では「リアクティブ」と「イテレーション」を組み合わせると、新しい問題が衝突して生まれます。
コンポーネントの依存が実行時に置き換えられたとしましょう(例えば、それが読んでいる設定ファイルが差し替えられた場合)。目標状態は一瞬のうちに 3 回ジャンプする可能性があります:
Active → Inactive → Active
厄介なのは、最初の Active が開始した Reload がまだ進行中かもしれないということです(イテレーションがまだ終わっていない)。2 つ目の Active が到着したとき、システムは非常に陰険な状況に直面します:依存の値はすでに変わっているのに、状態はまだ Active——「Active → Active」です。システムがお人好しにも元の Reload を続行すると、コンポーネントは古い依存値にバインドされてしまいます:新しい環境に新しい値が設定されたのに、コンポーネントは古い値で初期化を完了してしまうのです。これが**不整合(incoherence)**です。
エポック:目標状態の「バージョン番号」
論文の解法はエポック(epoch)メカニズム——目標状態にバージョンを付与することです。依存仕様 d に対し、エポック関数は d に含まれるすべての依存キーの現在の値を取り出し、バージョンタグにまとめます:
εd(σ) = ⟨ σ(k) | k ∈ d ⟩
- 2 つのエポックが等しい ⟺ すべての依存値が同じ。1 つでも値が変われば、エポックも変わります。
- Inactive のエポックは特殊な定数 ⊥ で、どのアクティブなエポックとも等しくありません——非アクティブとは「バージョンなし」であり、どの設定とも無関係です。
使い方は 2 ステップだけですが、非常に重要です:
- 各遷移の開始時に、現在の目標状態のエポックを記録し、εinert(慣性エポック)として保存する。
- 各イテレータのステップ境界で、「今」のエポック εtarget と記録した εinert を比較する:
- 一致 → 世界は変わっていない。次のステップへ進む。
- 不一致 → 世界はすでに変わっている。直ちに遷移を中止し、蓄積された逆関数を再生して、作業を終了する。
たとえるなら:作業を始める前に「現在の設定」の写真を撮っておき、1 ステップ終わるごとに写真と照合する。写真と合わなければ即座に作業を止め、すでにやったことをすべて元に戻すのです。
「1 本の線」から「1 つの星」へ
エポックメカニズムにより、ライフサイクルは 1 次元の Inactive/Active から、多次元の星型構造へと拡張されます:
| 状態 | エポック(バージョンタグ) |
|---|---|
| Active · 設定 A | ⟨a:2, b:2⟩ |
| Active · 設定 B | ⟨a:2, b:3⟩ |
| Active · 設定 C | ⟨a:1, b:1⟩ |
| …… | …… |
| Inactive | ⊥(バージョンなし) |
それぞれの依存設定が独立した Active ブランチに対応し、すべてのブランチが Inactive に接続されています。 依存の差し替え = ブランチの切り替え:古いブランチ上の遷移はその場で無効となり、システムは Inactive に戻り、新しいブランチに沿って再度 Reload します。これにより、コンポーネントは常に「現在の」依存と整合し、古い値にしがみついて生きることがないことが保証されます。
5. 非同期と慣性:遷移には実時間がかかる
これまで私たちは、Reload/Unload が瞬時に完了すると暗黙に仮定してきました——目標状態が変われば、遷移は即座に完了する。しかし現実の世界はそうではありません:Reload はデータベースへの接続、ファイルへの書き込み、ネットワークリクエストを必要とするかもしれず、実時間がかかります。
論文の抽象化は端的です:1 回の遷移は Future(A) 型の値を生成し、Future の定義的性質とは——「遷移の送信」と「評価の完了」の間に、外部の状態がすでに変化している可能性があることです。言い換えれば:遷移が半ばまで進んだ時点で、世界はすでに変わっているかもしれないのです。
2 つの脅威
遷移が実時間を占有することは、2 つのリソース安全性の問題をもたらします:
- 進行中の Reload を即座にロールバックする:前のステップのエフェクトがまだ実行し終わっていないのに、逆関数を一気に再生してしまう——LIFO の順序がその場で破壊され、後で本当に取り消す必要があるエフェクトには再生すべき逆関数がなく、すでに解放済みのものは間違ったタイミングで解放されてしまいます。
- 高速な振動(Reload → Unload → Reload → …):目標状態が行ったり来たりすると、同じ逆関数が 2 回呼び出される可能性があります——これは第 2 節の冪等ガードが防ぐべき事態ですが、振動はそれをすり抜けてしまいます。
慣性:一度走り出したら、最後まで走らせる
安全性を回復するため、論文は Reload と Unload を「瞬時の遷移」から**慣性状態(inertia)**へと格上げします:
いったんある遷移に入ったら、その遷移は完了まで実行される。その後で初めて、システムは目標状態のあらゆる変化を処理する。
具体的なセマンティクスは 3 つです:
- コンポーネントが Reload の最中に、目標が Inactive になった → まず Reload が完了し、その後で Unload が始まる。Reload の完了前に目標が Active に戻れば、コンポーネントはそのまま Active に入る(つまりこの遷移は無駄にならなかった)。
- 対称的に、コンポーネントが Unload の最中に、目標が Active になった → まず Unload が完了し、その後で Reload が始まる。再び Inactive に戻れば、コンポーネントは Inactive に入る。
- 保留中の逆方向遷移は遅延され、現在の慣性状態が終了した時点で、システムはその時点の目標状態に基づいて実行するか破棄するかを決定する——目標はまた変わっているかもしれないので、最新の状況に従って処理する。
たとえるなら:エレベーターはドアが閉まって動き出したら、目的の階に着いてからしかドアを開けない——途中で他の階のボタンを押しても即座に方向転換することはなく、到着してから新しい押下に応答します。
慣性 × エポック = 完全なセーフティネット
慣性状態とイテレータ境界は組み合わせて使うことができ、これが 3.3 節全体を締めくくる一筆です:すべてのステップ境界で、システムはエポックが依然として一致するかをチェックします——
- 一致 → 次のステップへ進む。
- 不一致 → 遷移を中止 → 蓄積された逆関数を再生 → 遅延されていた遷移を開始。
これこそが、各慣性遷移の内部で動作する同期的な中断メカニズムです:慣性は「現在の遷移は中断されない」ことを保証し、エポックは「中断される場合は必ず安全な中断ポイントで、かつ最新の状態に基づいて行われる」ことを保証します。2 つのルールがそれぞれの区間を担当し、合わせて滑らかで安全なライフサイクルを構成します。
重要ポイントの振り返り
この課は情報量が多いですが、次の 5 文を覚えておけば十分です:
- コンポーネント = 依存宣言 d(自分が必要なもの)+ エフェクト関数 e(自分が提供するもの)。目標状態 Active ⟺ 「エフェクトが適用済みで逆関数が未呼び出し」かつ「すべての依存が満たされている」。それ以外は Inactive。状態が変われば Reload(e を実行して副作用を蓄積)または Unload(蓄積された逆関数を再生)。
- 冪等:各逆関数は最大 1 回しか効果を発揮しない——idem は生成的ハンドル + 使用済み記録で「使ったら溶断」を実現し、繰り返しのアンロードによるコンテキスト破壊を防ぎつつ、純粋関数の性質を保つ。
- イテレーション:1 回の Reload は多数のステップに分けられる(エフェクトイテレータ)。各ステップは δ、逆関数 g、継続 o を返す。ステップ間は天然の中断ポイントであり、途中で止まって安全にロールバックできる。逆関数は実行順に蓄積され、回復は後入れ先出し(LIFO)。
- エポック:目標状態はバージョン番号(依存値のスナップショット εd(σ))を持つ。各遷移で εinert を記録し、ステップ境界で εtarget と照合し、不一致なら遷移を中止——「依存が置き換えられた後も古い Reload を続行する」という陳腐なバインディング問題を専門に治す。
- 非同期の慣性:Reload/Unload には実時間がかかる。いったん遷移に入ったら完了まで実行してから新しい変化に応答し、保留中の逆方向遷移は慣性状態の終了時点の目標状態に基づいて実行または破棄する——LIFO の破壊と逆関数の重複呼び出しを回避する。
🚀 次課の予告:コンポーネントとライフサイクルを手に入れた今、論文の第 3.4 節では「エフェクトコンテキスト」と「余エフェクトコンテキスト」を 1 つの具体的な構成に統一します——それが第 10 課「コンテキストパラダイム:統一コンテキスト型」です。この仕組みがどのように実際に動くランタイムへと結実するのかを見ていきます。
セルフチェック · コンポーネントのライフサイクル
回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。
