第 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):最后执行的效应,最先被撤销。
⋄ 运算:任意复合效应的逆,都能按 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 纪律的「效应迭代器」。
关键点回顾
- 逆函数不能预知:必须由调用者在应用效应时现场提供;recover 全有或全无,所以需要能部分撤销的模型。
- 效应函数 𝔈Γ = Γ → Γ×(Γ→Γ):输入当前状态,返回「新状态 + 本次的逆函数」。
- 严格效应函数 𝔈Γ∗:多一条约束 g(δ) = γ,保证逆是真的逆。
- 效应复合 f ⋄ g:先执行 g、再执行 f;组合逆 = 先撤销 f、再撤销 g——后进先出(LIFO)。
- 定理 11:effect 保持 ⋄ 运算(「组合的跟踪 = 跟踪的组合」),任意复合效应的逆由组合自动推导,不用手写。
- 加载 = 累积逆,卸载 = 播放逆:恢复顺序自动 LIFO;可交换的效应可独立共存,不可交换的靠顺序纪律约束。
🚀 下一课预告:既然「不交换的效应必须按顺序来」,谁来保证这个顺序?答案是一个在组件内部强制「后进先出纪律」的结构——效应迭代器(第 3.3.2 节)。
自测题 · 效应组合
完成作答后点击「提交答案」,可以查看对错与解析。
