スポンサーLobeHubLobeHub詳しく見る
dshfind

第 11 課:Cordis コアライブラリ:エフェクト追跡とコエフェクト解決

一言でいうと:この課では論文を「数式からコードへ」落とし込みます——Cordis コアライブラリは、あらゆるコンテキスト変更を唯一のプリミティブ ctx.effect(自動追跡、いつでも取り消し可能)に集約し、その上にコエフェクトの読み書き、コンポーネントのロードとアンロードを組み立て、最後に Proxy で「宣言していないものにはそもそも触れられない」ことを保証します。

ステップ 1:まず対応表を確認——理論の記号はコードではどう見えるか

第 10 課では「コンテキストパラダイム」という理論を学びました。この課から論文の第 4 章に入ります:Cordis がこの理論をどうやって実際に実装しているかです。

論文第 3 節の記号(Γ∞、𝔈Γ、ℭΓ……)は怖そうに見えますが、実はどれにも「プログラマーの旧友」に相当するものがあります。コアライブラリが最初にやることは、この対応関係を一枚の表に固定することです——論文の表 1 を、次のように簡略化しました。

理論(第 3 節)実装(第 4 節)一言でいうと
Γ∞(コンテキストタワー)ctx、第一級コンテキストコンポーネントが共有する「公共の黒板」
𝔈Γ / 𝔈Γiter(エフェクト)逆変換を返す、または逐次 yield するエフェクトコールバック「取り消し説明書付き」のコード
effectiter Γ(𝑒)ctx.effect(callback)黒板を変更する「唯一の入口」
Σ / Σiso / Σinterctx[@@store] / ctx[@@isolate] / ctx[@@intercept]黒板の 3 つの引き出し
get(𝑘) / set(𝑘, 𝑣)ctx.get(key) / ctx.set(key, value)値の読み出し / 書き込み
isolate(𝑘, 𝑟)ctx.isolate(key, realm)同じキーに「別の引き出しを開く」
intercept(𝑘, 𝜈)ctx.intercept(key, metadata)値の取得に「フィルターをかける」
ℭΓ(コンポーネントインスタンス)fiber(ファイバー)コンポーネントの「実行時の身分証」
𝑑 ∶ 𝔇Γfiber.injectコンポーネントが「何が必要か」を宣言する
𝑒 ∶ 𝔈Γfiber.applyコンポーネントが「何をするか」を述べる
𝜀𝑑(𝜎)fiber.epoch目標状態の「バージョン番号」
recoverfiber.dispose(蓄積された逆変換)実行待ちの「取り消しリスト」

この表を読んだら、まず 3 つの訳語を覚えてください。以降はすべてこの名前を使います。

  • ctx = 第一級コンテキスト:あの「公共の黒板」であり、すべてのコンポーネントがその上で読み書きします。
  • エフェクトコールバック = エフェクト:「黒板を変更し、同時に取り消し方法も提供する」コード。
  • fiber(ファイバー)= コンポーネントの実行時インスタンス:コンポーネントがインスタンス化された後、メモリ上に生きているステートフルなオブジェクト。

表の 2 箇所の記法について、論文では特に注意が促されています。

  1. @@nameシンボルキーを表します:ctx[@@store] の角括弧は「シンボルキーでコンテキスト内の不透明なスロットにアクセスする」という意味であり、文字列をキーとするマップへのインデックスアクセスではありません。
  2. fiber は 1 つのオブジェクトに 2 種類のものを詰め込んでいます:静的仕様fiber.inject が依存を宣言、fiber.apply がエフェクト関数、さらに fiber.parent が親コンテキスト、fiber.ctx が親から派生した子コンテキスト)とライブな遷移状態fiber.dispose が蓄積された逆変換、fiber.epoch が目標バージョン番号、fiber.inertia が進行中のマイグレーションのハンドル)です。

コアライブラリはボトムアップの 4 階建てです。

① 取り消し可能なエフェクト(ctx.effect)  ← 土台:コンテキストを変更する唯一のプリミティブ
② リアクティブなコエフェクト(get / set)  ← 1 階:エフェクトの上に「読み書き」を作る
③ コンポーネントのライフサイクル(use)    ← 2 階:前の 2 つを組み合わせてコンポーネントの一生にする
④ コンテキストアクセス(Proxy)            ← 最上階:ホスト言語により近い使い方

では、土台から一階ずつ上へ登っていきましょう。

ステップ 2:ctx.effect——あらゆるコンテキスト変更の「唯一の入口」

まず論文 4.1.1 の核心的な主張を覚えましょう。

Cordis では、コンテキストへのすべての変更が同一のプリミティブ ctx.effect を経由します。 コエフェクトの供給(set)、コンポーネントのインスタンス化(use)……コンテキストを変更するすべての操作は、最終的に 1 回の ctx.effect 呼び出しに帰着します。

これは何を意味するでしょうか?コンテキストを通じて実行されるあらゆる操作が自動的に追跡され、コンポーネントのアンロード時にすべて自動的に復元されます。「この変更をどう取り消すか」を覚えておく必要はありません——ctx.effect が代わりに覚えていてくれます。

コールバック = エフェクトイテレータ:各ステップが「逆変換」を産出する

ctx.effect はコールバックを受け取り、それをエフェクトイテレータとして駆動します。コールバックが 1 ステップ yield するたびに、「逆変換」——つまり「このステップをどう取り消すか」の説明書——を 1 つ手渡します。通常のエフェクト関数は「ちょうど 1 つの逆変換しか産出しない退化したイテレータ」にすぎないので、同じ入口が通常の関数とイテレータの両方を区別なく受け付けます。

アルゴリズム 1 の構造を簡略化すると、次のようになります。

// 実行エンジン:コールバックをイテレータとして駆動し、各ステップの逆変換を 1 つの複合逆変換に畳み込む
async function execute(callback, guard) {
  const iter = callback();                 // コールバックがイテレータになる
  let inverse = id;                        // 逆変換は「空操作」から蓄積を開始
  while (guard()) {                        // 1 歩進む前に、まずガードに尋ねる:続けていいか?
    const { value, done } = await iter.next();
    if (value) inverse = compose(value, inverse); // 新しい逆変換を「前置」して蓄積
    if (done) break;
  }
  return inverse;                          // 畳み込み済みの「複合逆変換」を返す
}

// ctx.effect:execute の上の軽量なラッパー
function effect(ctx, callback) {
  let armed = true;                        // ① 武装フラグ:まだ 1 回復元できる
  const task = execute(callback, () => armed); // ガードは armed そのもの

  async function dispose() {
    if (!armed) return;                    // すでに復元済み? 即座に終了(冪等)
    armed = false;                         // まず武装解除:実行中のイテレーションを停止
    const recover = await task;            // ② 蓄積された逆変換を取り出す
    recover();                             // ③ それを呼ぶ = エフェクト全体を一括復元
  }

  ctx.dispose = compose(dispose, ctx.dispose); // ④ 親コンテキストに前置(LIFO)
  return dispose;                          // dispose を呼び出し側に渡す
}

このコードには 3 つの重要な設計があります。1 つずつ分解しましょう。

設計コード内の場所どんな問題を解決するか
コールバックが逆変換を産出yield の value「変更」に「取り消し説明書」を付属させ、何を変えても元に戻せるようにする
dispose クロージャを返すreturn dispose呼べば復元。dispose を誰に渡すかで、取り消し権を誰が握るかが決まる
冪等な自己解放armed フラグ + ガード復元は最大 1 回しか発火せず、繰り返し呼んでも安全な空操作

冪等性:armed という 1 つのフラグが 2 つの役割を担う

armed は最初 true で、ガードスイッチを同時に兼ねています。

  • armedtrue の間は、execute 内のイテレーションが進み続けられます。
  • dispose が呼ばれた瞬間、まず armedfalse にします——一方で進行中のイテレーションをすべて停止し、他方で復元が最大 1 回しか発火しないことを保証します。

これが論文の定義 21 の「冪等」(idem)です:dispose を何回呼んでも 1 回呼んだのと等価です。

親コンテキストへの合成:dispose の前置が LIFO とカスケードを生む

まず記法を 1 つ定めましょう(論文の原文もこう定義しています):a ∘ b は「先に b を実行し、次に a を実行する」合成関数を表します。すると inverse = compose(value, inverse) は、新しい逆変換をそれぞれ蓄積済み逆変換の前に置くことになり、後入れ先出し(LIFO)の復元順序——最後に確立されたエフェクトが最初に復元される——が得られます。

次に ctx.dispose = compose(dispose, ctx.dispose) を見てください:新しく生まれた dispose が外側のコンテキストの蓄積逆変換に前置されます。言い換えると——子エフェクトの逆変換それ自体が、親コンテキスト上の 1 つのエフェクトなのです(論文における ∂²Γ の再帰構造)。入れ子のエフェクトは層ごとに帳簿に記録されるので、次のようになります。

  • 親コンポーネントをアンロード → 親コンテキスト上のエフェクトを復元 → すべての子エフェクトがカスケード復元される
  • この「カスケード」は手書きされたものではなく、合成構造から自然に導かれるものです。

💡 論文にはもう 1 つ伏線が埋められています:コンポーネント層(ステップ 4 で扱う reload / unload)は同じ execute を再利用し、ガードだけが armed から「エポックが安定しているか」に置き換わります。同じエンジンでガードを変えれば、別のセマンティクスになる——この手がかりを覚えておいてください。

ステップ 3:コエフェクト操作——ctx の 3 つの引き出し

ステップ 2 は「土台」でした。このステップではその上に 1 階を建てます:リアクティブなコエフェクト——ctx.set(key, value) で書き込み、ctx.get(key) で読み出しです。

すべてのコエフェクト操作は、各コンテキストが持つシンボルをキーとする 3 つのスロットに作用します。

スロット(シンボルキー)学名何を格納するか一言でいうと
ctx[@@store]値ストア σドメインシンボル → 型付きの値実際に「値を入れる」引き出し
ctx[@@isolate]ドメインテーブル ρコエフェクトキー → ドメインシンボルキーの「リダイレクト表」
ctx[@@intercept]インターセプトテーブル ιキー → メタデータ値取得時の「フィルター」
ctx(上下文)@@store 值存储 σ@@isolate 域表 ρ@@intercept 拦截表 ιget(key)两层解析set(key)一个效应

ctx.set / ctx.get 都是效应:自动被跟踪,卸载即回退

2 段階解決:get は 2 ステップを踏む

ctx.get(key) はストアを直接のぞくのではなく、2 段階の解決を行います。

key → ρ(key) → σ(ρ(key))
① まず @@isolate に尋ねる:このキーはどのドメインに属するか?
② 次に @@store に入る:そのドメインに束縛されている値は何か?

中間にある ρ(ドメインテーブル)は意図的に追加された「間接層」です——分離操作はまさにこの層を書き換えることで、キーを独立した束縛へリダイレクトします。一方 @@intercept は束縛にアクセスする時にだけ参照され、調整するのは「束縛をどう使うか」であり、「束縛が何に解決されるか」ではありません。

この 2 つのスロットの役割分担は、コエフェクト操作の実装上の 2 つの部分にちょうど対応します:(1)供給と通知——束縛の確立または撤去を行い、変化を依存側へ伝播させる。(2)分離とインターセプト——キーの解決方法を作り変える。

供給と通知:set は本質的に「1 回の ctx.effect」

set(k, v) の型は 𝔈Σ なので、コエフェクトの供給はまさに 1 回の ctx.effect 呼び出しです——ステップ 2 の追跡と復元の仕組みを自動的に継承します。アルゴリズム 2 を簡略化すると次の通りです。

// アルゴリズム 2 簡略版:ctx.set —— 値を束縛し、返される dispose が取り消しを担当
function set(ctx, key, value) {
  function callback() {
    const realm = ctx[@@isolate][key];   // ① まず解決:このキーはどのドメインに属するか?
    ctx[@@store][realm] = value;         // ② 値を対応する引き出しに入れる
    notify(ctx, [key]);                  // ③ 依存側に通知:値が変わった!
    return function () {                 // ④ 逆変換 = この束縛の取り消し
      delete ctx[@@store][realm];        //    値を取り出す
      notify(ctx, [key]);                //    もう一度通知:値がなくなった
    };
  }
  return ctx.effect(callback);           // ⑤ すべてを ctx.effect の追跡に委ねる
}

注意:束縛の確立時にも除去時にも notify が呼ばれます——変化を「このキーを気にしている」コンポーネントに伝播させるためです。アルゴリズム 3 を簡略化すると次の通りです。

// アルゴリズム 3 簡略版:notify —— 束縛が変わるたび、気にしているファイバーへブロードキャスト
function notify(ctx, keys) {
  for (const fiber of all_fibers) {
    for (const key of keys) {
      if (key が fiber.inject に含まれ、かつ同じドメインに解決される) {
        refresh(fiber);   // ファイバーに新しい状態で再評価させる
        break;
      }
    }
  }
}

これは論文の定義 15 のリアクティブ分類に対応します:ある変化によって「このファイバーの仕様が満たされている」という真偽値が変わったとき、そのファイバーは活性化または非活性化されます。そして refresh は冪等です——中立的な変化は何の影響も生みません。refresh が具体的に何をするかは、ステップ 4 で明らかにします。

分離とインターセプト:変わるのは「解決方法」、復元は暗黙的

ctx.isolate(key, realm)ctx.intercept(key, metadata) は、構造的には同じ種類の操作です。

  • それぞれが子コンテキストを 1 つ派生し、指定した key について継承されたテーブルの 1 枚を調整します。親コンテキストはそのままです。
  • したがって復元は暗黙的です:子コンテキストを捨てるだけでよく、明示的な逆変換を実行する必要はありません(対照的に、set は値を明示的に削除する必要があります)。
操作どのテーブルを変えるか効果
ctx.isolate(key, realm)ドメインマッピング ρ を realm で上書き(指定しなければ新しいシンボルを生成)異なるシンボルの 2 つのコンテキストでは、同じキーが互いに独立した束縛に解決される
ctx.intercept(key, metadata)メタデータをインターセプトテーブル ι にマージ新しいメタデータは既存のメタデータとマージされ、古いものより優先される

ステップ 4:コンポーネントの一生——ライフサイクルとコンテキストアクセス

ステップ 2、3 は「部品」でした。このステップではそれらをコンポーネントに組み立て、コンポーネントが ctx とどう付き合うかを見ます。

ctx.use:コンポーネントをファイバーに「インスタンス化」する

コンポーネントは ctx.use によってファイバーへインスタンス化されます。コンポーネントはコエフェクト仕様component.inject、何が必要かを宣言)とエフェクト関数component.apply、何をするかを定義)をペアにしたものです。アルゴリズム 4 を簡略化すると次の通りです。

// アルゴリズム 4 簡略版:ctx.use —— コンポーネントを生きた fiber に変える
function use(ctx, component, config) {
  function callback() {
    refresh(fiber);                       // 実行時:子ファイバーのライフサイクルを開始
    return function () {                  // 逆変換:復元 = 子コンポーネントのアンロード
      fiber.epoch = null;                 // 目標状態を Inactive(空)に設定
      unload(fiber);                      // 実際にアンロードを実行
    };
  }
  const fiber = new Fiber({ parent: ctx, inject: component.inject });
  fiber.ctx = deriveChildCtx(ctx);        // 親コンテキストから新しい子コンテキストを派生
  fiber.apply = () => component.apply(fiber.ctx, config); // コンフィグを束縛
  ctx.effect(callback);                   // 親コンテキスト上で追跡されるエフェクトとして登録
  return fiber;
}

コードの最後にある ctx.effect(callback) に注目してください:このコールバックは親ファイバー内で追跡されるよう登録されます。したがって親コンポーネントをアンロードすると、すべての子コンポーネントが自動的にカスケードアンロードされます——親のエフェクトを復元するとき、「子ファイバーのエポックを空にして unload する」という逆変換が実行されるからです。これこそが前の課で言った「親のエフェクトコンテキスト上の ⋄ 合成」です。

refresh とエポック:バージョン番号が変わったときだけ動く

ファイバーはいつリロードすべきでしょうか? 答えはエポックを見ることです:コンポーネントが宣言した各キーを現在のコエフェクトストアで値に解決し、それらを 1 つのタプルにまとめたもの——これが目標状態の「バージョン番号」です(𝜀𝑑(𝜎)。ここで「空」は Inactive を表します)。notify はコエフェクトが変わるたびにエポックを再計算するので、ファイバーはその解決値が変化したときにちょうどリロードされます

アルゴリズム 5 の前半を簡略化すると次の通りです。

// アルゴリズム 5 簡略版:refresh —— 「エポック」で動くかどうかを決める
function refresh(fiber) {
  const epoch = computeEpoch(fiber);      // エポック = 宣言されたキーをすべて値のタプルに解決
  if (epoch === fiber.epoch) return;      // バージョンが変わっていない? 中立的な変化なので何もしない
  fiber.epoch = epoch;                    // 新しい目標バージョンを記録
  if (fiber.inertia) return;              // マイグレーション中? 実行中のものを走り切らせる(慣性!)
  fiber.inertia = epoch !== null
    ? createTask(reload(fiber))           // 値あり → ロード / リロード
    : createTask(unload(fiber));          // 値なし → アンロード
}

慣性:ひとたび始まったマイグレーションは、最後まで走り切る

reloadunload は相互再帰のペアで、これこそが第 9 課の「慣性ステートマシン」の実装です(アルゴリズム 5 の後半)。

// reload:コンポーネントのエフェクト関数を実行し、終わってからバージョンがまだ正しいか確認
async function reload(fiber) {
  const epoch0 = fiber.epoch;                    // 開始時の目標バージョンを記録
  const recover = await execute(fiber.apply, () => fiber.epoch === epoch0);
  fiber.dispose = compose(recover, fiber.dispose); // 蓄積された逆変換を帳簿に記入
  if (fiber.epoch === epoch0) {
    fiber.inertia = null;                        // バージョン不変 → Active で安定
  } else {
    fiber.inertia = createTask(unload(fiber));   // バージョンが変わった → 続けてアンロード!
  }
}

// unload:LIFO ですべての追跡済みエフェクトを復元
async function unload(fiber) {
  await fiber.dispose();                         // 蓄積された「取り消しリスト」を実行
  fiber.dispose = id;                            // 帳簿をクリア
  if (fiber.epoch === null) {
    fiber.inertia = null;                        // Inactive で安定
  } else {
    fiber.inertia = createTask(reload(fiber));   // 新しいバージョンが現れた → 続けてロード!
  }
}

どちらの関数もマイグレーション完了時にエポックを確認し、「安定する」か「次のマイグレーションへ連結する」かを決めます。この相互再帰が論文の慣性プロパティを実装しています。

ひとたびマイグレーションが始まったら、まず完了まで実行し、その後で初めて新しいマイグレーションの開始が許される。

そしてステップ 2 に残した手がかりもここで回収されます:reload は同じ execute を再利用し、ガードが armed から「エポックが開始時のバージョンに等しいままか」に置き換わります——エポックが変わった瞬間にイテレーションは即座に停止し、その時点までに蓄積された逆変換だけが保持されます。こうして仕組み全体が 2 つのレベルで動作します。

レベルどこでエポックを確認するか何を守るか
マイグレーションレベルreload / unload の完了時マイグレーションをまたぐ慣性の連結(走り切ってからギアを切り替える)
ステップレベルexecute の各イテレータステップの境界単一マイグレーション内の部分的ロールバック(途中でバージョンが変わったと気づいたら止まる)

Proxy の門番:宣言したものだけが使える

最後に最上階を見ましょう。ステップ 3 の ctx.get / ctx.setリフレクティブな API(名前をキーとする読み書き)です。その上に、Cordis はホスト言語により近い第 2 の使い方を提供します:プロパティアクセス——コンポーネントは ctx[key] と直接書け、ネイティブな構造体にアクセスするのと同じように、メソッド呼び出しなしで使えます。

TypeScript では、Cordis は Proxy でこの仕組みを実装し、その get トラップがすべてのプロパティアクセスを仲介します。アルゴリズム 6 を簡略化すると次の通りです。

// アルゴリズム 6 簡略版:resolve —— 使用箇所から上へ「誰がこのキーを宣言したか」を探す
function resolve(ctx, key) {
  let fiber = ctx.fiber;                    // アクセスを開始したコンテキストから出発
  while (true) {
    if (key が fiber.inject に含まれる) return get(ctx, key); // 宣言を発見 → 取得を許可
    if (fiber がルートノード) throw UNDECLARED_ACCESS;          // 頂点まで宣言なし → 拒否!
    fiber = fiber.parent.fiber;             // それ以外はファイバーチェーンを上へ進む
  }
}

ファイバーチェーンを上へたどり、inject でその key を宣言している最初のファイバーが見つかれば、get(アルゴリズム 2)を通じて値を取得します。ルートノードまでたどっても見つからなければ、UNDECLARED_ACCESS(未宣言アクセス)をスローします

これこそが「プロキシ」と「ctx.get を直接呼ぶこと」の根本的な違いです。

ctx.get(key)プロパティアクセス ctx[key](Proxy による仲介)
探索方法全域的な探索ファイバーチェーンをたどって「宣言」を探す
見つからないとき空の値を返し、決して失敗しないUNDECLARED_ACCESS をスロー
仕様の強制強制しない使用箇所でコエフェクト仕様 d を強制する

さらにもう 1 つの保証があります:「宣言されているが存在しない」値は発生しません——ファイバーは宣言されたすべてのコエフェクトが満たされて初めて Active に入るからです(第 4.1.3 節)。宣言されていて、かつ Active 状態なら、値は必ずそこにあります。

この拒否はアクセス箇所で実行されるランタイムチェックです。そしてコエフェクト仕様 d は静的に宣言されるので、同じ種類の違反は原理的にはコンパイル時にも検出できます(論文第 5.3 節では、ホスト言語が型階層を使って同じ仲介を実装する方法が論じられています)。

要点の振り返り

この課は情報量が多いですが、次の 5 文だけ覚えれば十分です。

  1. 表 1 は翻訳辞典:ctx はコンテキストタワー、エフェクトコールバックはエフェクト、fiber はコンポーネントインスタンスに対応。@@name はシンボルキーであり、文字列インデックスではありません。
  2. すべての変更は 1 回の ctx.effect に帰着する:コールバックが逆変換を産出し、返される dispose を呼べば復元。armed が復元を最大 1 回に保証し、dispose は親コンテキストに前置されて LIFO とカスケードアンロードを形成します。
  3. ctx には 3 つの引き出しがある@@store(値)、@@isolate(キーからドメインへの「リダイレクト表」)、@@intercept(メタデータフィルター)。get は「まず ρ を調べ、次に σ を調べる」2 段階解決です。
  4. fiber の一生はエポックに駆動されるctx.use がファイバーを作り、refresh がエポックに基づいて reload / unload を決定。ひとたび始まったマイグレーションは最後まで走り切る——これが「慣性」です。
  5. Proxy は使用箇所で仕様を強制する:アクセス箇所からファイバーチェーンを上へたどって宣言を探し、見つからなければ UNDECLARED_ACCESS をスロー。ctx.get は決して失敗しませんが、プロキシはあらゆる未宣言アクセスを拒否します——「宣言していないものにはそもそも触れられない」のです。

🚀 次の課(第 12 課)ではコアライブラリの上の 2 階へ進みます:コンポーネントローダー——Cordis がどうやって設定協調とホットモジュールリプレースメント(HMR:再起動せずにコードを変更)を提供し、Koishi の 4000+ プラグインによる本番検証を経ているかを見ます。

確認テスト · Cordis コアライブラリ

回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。

1. Cordis コアライブラリにおいて、ctx.effect はどんな役割を果たしますか?
2. ctx.effect が返す dispose クロージャの役割は?
3. ctx の 3 つのシンボルスロットについて、正しい説明はどれですか?
4. Proxy 経由のプロパティアクセスで「未宣言」のコエフェクト(ctx.someKey)にアクセスすると、何が起きますか?