赞助商LobeHubLobeHub了解更多
ddshfind
GitHub

第 7 课:效应函数与组合:逆变换自动组合

一句话版:上一课我们假设「逆函数已经有人替我们写好」;这一课我们让每个效应自己随身带着逆函数,并规定复合时逆按后进先出自动拼装——从此装什么都能拆,还能只拆一半。

1. 为什么「上一课的逆函数已给出」不够?

还记得上一课的模型吗:一个状态变换 f,配一个逆变换 f⁻¹;要撤销时,recover 一步把所有累积的效应整体还原。这个模型有两个不现实的地方(论文 3.1.2 节开头的原话):

问题一:逆函数不是预先知道的。 现实中,一个效应的逆必须由调用者现场提供。比如「打开弹窗」这个效应——窗口开在哪儿、显示什么内容,只有发起者知道,所以「怎么把它关掉」也只有发起者说得清。别人没法提前替你写好。

问题二:recover 是全有或全无的。 它要么全撤、要么不撤,不能只撤销其中一个效应、同时保留其他效应。可现实里我们经常想要部分撤销:比如撤销「第 2 步的修改」,但保留「第 1 步」的结果。

对比上一课(track / recover)这一课(效应函数)
逆函数由谁提供假设「已给出」调用者现场提供
撤销粒度全有或全无可以部分撤销
恢复方式原子恢复全部后进先出,按需播放

怎么办?论文的做法很朴素:在输入和输出两端同时给模型「加料」——

  • 输入端:不只返回变换结果,还把本次的逆函数一起返回(Γ → Γ×(Γ→Γ)),获得「手动跟踪」能力;
  • 输出端:在带跟踪的上下文 ∂Γ 上同样返回逆函数(∂Γ → ∂²Γ),获得「部分恢复」能力。

一句话:把「逆」从系统的假设,变成每个效应随身携带的行李。

2. 效应函数 𝔈Γ:状态 + 逆函数一起交回来

先看形式化定义(论文定义 6):

𝔈Γ ≔ Γ → Γ × (Γ → Γ)

翻译成人话:一个效应函数拿到当前的上下文 γ(伽马),交回一个二元组

  • 第一个分量 δ(delta):变换后的新状态
  • 第二个分量 g:本次效应的逆函数——把它作用在新状态上,就能撤销本次效应。

用伪代码写出来,形状就是:

function 打开弹窗(当前状态) {
  const 新状态 = 当前状态 + ',弹窗已打开';
  const 撤销函数 = (新状态) => 去掉弹窗(新状态); // 这就是本次的逆
  return [新状态, 撤销函数]; // 关键:逆函数和状态一起返回
}

注意这个「一起返回」:谁发起效应,谁就必须当场交出撤销办法。 系统不再假设逆是现成的——这正是上一节说的「调用者现场提供」。

严格效应函数 𝔈Γ∗:逆必须是真的逆

光「带个逆函数」还不够,万一调用者交了个假逆呢?比如「打开弹窗」的逆函数其实是「什么都不做」,那撤销就是骗人的。

所以论文又加了一条约束(定义 6 的第二行):对任意 γ,若 e(γ) = (δ, g),则必须满足

g(δ) = γ

也就是说:把逆函数作用在新状态上,必须精确回到旧状态。 满足这条约束的效应函数叫严格效应函数(𝔈Γ∗)。论文用一张交换图保证:返回的第二个分量确实是变换本身的逆,而不是随便一个函数。

🎁 打个比方:普通效应函数像「退货承诺」,严格效应函数像「货到检查」——必须先验证退回去的东西真的变回原来的样子,承诺才算数。

好消息是(定理 8):严格性在复合中不会丢失——严格效应函数经过 effect 提升到 ∂Γ 之后依然是严格的,质量保证全程有效。

3. 效应复合 ⋄:复合的逆自动按 LIFO 拼好

效应函数返回的是二元组,不再是「状态 → 状态」的普通函数,所以不能像普通函数那样直接复合。论文为此定义了一个新运算 ⋄(读作「菱形」)。

看定义 9 的结构——给定两个效应函数 f、g:

function 复合(f, g) {
  return (当前状态) => {
    const [中间状态, s] = g(当前状态);      // 第一步:先执行 g
    const [最终状态, t] = f(中间状态);      // 第二步:再执行 f
    return [最终状态, (状态) => s(t(状态))]; // 组合逆 = 先 t 后 s
  };
}
  • 执行顺序:先 g、后 f(和函数复合 f∘g 的书写习惯一致:⋄ 左边写的是后执行的);
  • 组合逆:s 是 g 的逆、t 是 f 的逆,组合逆是 s∘t = 先应用 t(f 的逆)、再应用 s(g 的逆)——也就是先撤销后执行的 f、再撤销先执行的 g。

这就是大名鼎鼎的后进先出(LIFO):最后执行的效应,最先被撤销。

先装 f(外层)记录逆 f⁻¹后装 g(内层)记录逆 g⁻¹复合变换 = f ⋄ g组合后:记录逆 = g⁻¹ 先、f⁻¹ 后先拆 g → 应用 g⁻¹(后装的先拆)再拆 f → 应用 f⁻¹(先装的后拆)恢复顺序 = 加载顺序的反转:后进先出(LIFO)复合效应的逆,由组合自动推导——不用手写

⋄ 运算:任意复合效应的逆,都能按 LIFO 自动组合出来

上图把同一个道理画成了「先装 f、后装 g」:卸载时先应用 g 的逆、再应用 f 的逆——恢复顺序正好是加载顺序的反转。(图中的 f、g 只是示意标签,关键是「后装的先拆」这条原则。)

🎁 类比:就像叠盘子或穿外套——后放上去的盘子先拿走,后穿上的外套先脱掉。谁最后一个进来,谁第一个出去。

定理 11 才是这节的重头戏

effectΓ(f) ⋄ effectΓ(g) = effectΓ(f ⋄ g)

翻译成人话:「组合的跟踪 = 跟踪的组合」。 你可以先把 f 和 g 复合好、再整体跟踪;也可以分别跟踪、再复合——两条路结果完全一样。

这意味着什么?任意复合效应的逆,都由组合自动推导出来,根本不用手写。 你只需要为每个「原子」效应提供一个逆函数,复合出来的整体逆会自动按 LIFO 拼好。

💡 这就是「逆变换自动组合」的含义:搭积木时每块积木自带拆法,拼出来的整体也自动会拆——你永远不用为「整体」单独写拆法。(定理 10 还保证:两个严格效应复合出来依然是严格的,质量保证不丢。)

4. 加载 = 累积逆,卸载 = 播放逆

最后回到组件场景,把这些零件装起来。论文说得很美:时间可组合性——

  • 加载组件:相当于依次应用一系列效应函数,把各自的逆累积进一个「累积逆函数」φ;
  • 卸载组件:相当于应用 φ,把上下文恢复成组合前的状态

伪代码:

let 当前状态 = 初始状态;
let 累积逆 = (状态) => 状态; // 一开始:什么都不用撤销

function 加载组件(效应函数) {
  const [新状态, 本次逆] = 效应函数(当前状态);
  累积逆 = (状态) => 累积逆(本次逆(状态)); // 新逆叠在外层:先撤最新的
  当前状态 = 新状态;
}

function 卸载组件() {
  当前状态 = 累积逆(当前状态); // 播放累积逆 = 按 LIFO 自动倒放
  累积逆 = (状态) => 状态;     // 清空
}

系统完全不需要记住「先装了谁、后装了谁」——顺序被编码在累积逆的嵌套结构里,卸载时自然按后进先出播放。论文里这一步就是 effectΓ 在 ∂Γ 上做的事:输入 (γ, φ),应用 e 得到 (δ, g),新上下文变成 (δ, φ∘g)——新逆叠在旧逆外层。

那「两个组件会不会互相干扰」呢?论文给出的判据是 ⋄ 下的交换性

  • 若 f ⋄ g = g ⋄ f(两个效应可交换),说明先拆谁都一样、互不干扰,它们可以安全地住在各自独立的组件里;
  • 若不可交换,系统就必须施加顺序约束:组件内部靠「后进先出纪律」(第 3.3.2 节的效应迭代器),组件之间靠「依赖驱动的激活顺序」(第 3.2 节的反应式余效应)。

⚠️ 记住这条线索:「可交换」决定拆的顺序有没有讲究,「自动推导」保证逆一定存在。 下一课我们就去看那个在组件内部强制 LIFO 纪律的「效应迭代器」。

关键点回顾

  1. 逆函数不能预知:必须由调用者在应用效应时现场提供;recover 全有或全无,所以需要能部分撤销的模型。
  2. 效应函数 𝔈Γ = Γ → Γ×(Γ→Γ):输入当前状态,返回「新状态 + 本次的逆函数」。
  3. 严格效应函数 𝔈Γ∗:多一条约束 g(δ) = γ,保证逆是真的逆。
  4. 效应复合 f ⋄ g:先执行 g、再执行 f;组合逆 = 先撤销 f、再撤销 g——后进先出(LIFO)。
  5. 定理 11:effect 保持 ⋄ 运算(「组合的跟踪 = 跟踪的组合」),任意复合效应的逆由组合自动推导,不用手写。
  6. 加载 = 累积逆,卸载 = 播放逆:恢复顺序自动 LIFO;可交换的效应可独立共存,不可交换的靠顺序纪律约束。

🚀 下一课预告:既然「不交换的效应必须按顺序来」,谁来保证这个顺序?答案是一个在组件内部强制「后进先出纪律」的结构——效应迭代器(第 3.3.2 节)。

自测题 · 效应组合

完成作答后点击「提交答案」,可以查看对错与解析。

1. 一个效应函数 e(γ) 返回什么?
2. 「严格效应函数」比普通效应函数多出来的约束是什么?
3. 按论文定义复合 f ⋄ g(先执行 g、再执行 f)时,恢复(撤销)的顺序是?
4. 为什么「复合效应的逆」不用手写?