第 9 课:组件生命周期:幂等、迭代、纪元、异步
一句话版:组件 = 「依赖声明 d(我需要什么)」+「效应函数 e(我贡献什么)」;它只有两个目标状态——Active(效应已应用且依赖全满足)和 Inactive(缺一即非)。而让它在真实世界里安全地来回切换,靠四个机制:幂等(每个逆函数最多生效一次)、迭代(一次 Reload 可分很多步、随时可中断)、纪元(给目标状态打版本号、识破过时)、异步惯性(转换要花真实时间,一旦开跑就让它跑完)。
1. 组件与目标状态:一个组件,两副面孔
前两课我们分别认识了两个「半边天」:
- 效应(第 6~7 课):组件激活时做什么,以及做了之后怎么撤销;
- 余效应(第 5 课、第 8 课):组件需要环境提供什么,以及环境变化时怎么反应。
这节课把两边拼成一个运行时实体——组件(component)。论文定义 20 说得很干脆:
组件 ℭ = 𝔇 × 𝔈,一个二元组 (d, e):
- d(依赖声明,余效应规格):组件要求环境提供的依赖——「我需要什么」;
- e(效应函数):组件处于活跃状态时向上下文贡献的效应——「我贡献什么」。
打个比方:装一个插件之前,先看它的「安装说明」——它要接哪些接口、读哪些配置(d);装好激活之后,它开始干活(e)。声明和行动,是两个不同的东西。
目标状态:Active 还是 Inactive?
组件的两个侧面共同决定它的目标状态。论文给出的判定是两个条件同时成立:
| 侧 | 条件 | 白话 |
|---|---|---|
| 效应侧 | 组件「存活」:效应已应用到上下文,且对应的逆函数尚未被调用 | 我干过的活还挂着,没被撤销 |
| 余效应侧 | 所有已声明的依赖都满足(𝜎 ⊨ d) | 我要的东西环境都给了 |
- 两个都成立 → 目标状态是 Active(激活)
- 否则 → 目标状态是 Inactive(未激活)
注意是「且」不是「或」:哪怕依赖全齐了,只要效应还没应用,组件就不是 Active;反过来,效应挂上了但依赖被抽走,组件也立刻掉回 Inactive。
Reload 与 Unload:状态一变就转换
每当目标状态发生变化(不管哪一侧引起的),系统都会发起一次转换:
- Reload(装载):执行组件的效应函数 e,把副作用累积到上下文上——「激活,开始干活」;
- Unload(卸载):播放累积的逆函数,把上下文恢复原样——「收工,把干过的活全部撤销」。
最简单的生命周期,就是下面这张双状态机:
生命周期 = 可回退效应与反应式余效应「相遇」的地方
Reload 和 Unload 就是第 8 课里「反应式」的落地动作:环境一变,组件自动重装或卸载,一切可回退、不留痕。 但现实世界比双状态机复杂得多——论文接着用四个小节回答四个问题,也就是本课标题里的四个词。
2. 幂等恢复:逆函数最多生效一次
先看一个细思极恐的问题:Unload 要「播放累积的逆函数」,可谁保证同一个逆函数不会被播放两次?
如果某个逆函数被调用两次,就等于把没做过的事也撤销了一遍——上下文被二次破坏,这是不可回退系统绝不能接受的。论文指出,风险不在「整体恢复」这一层(recover 层面由构造直接保证安全:恢复后累积函数被重置为恒等,第二次恢复天然无作用),而在一个更隐蔽的地方——局部释放函数。
回忆第 6 课:effect 把一个效应的释放函数交还给调用方(论文里那个「𝜕 → 𝜕²」分量),由调用方自己决定何时「放掉」这个效应。问题来了:如果调用方调用这个释放函数两次呢? 它会把这个逆函数应用两次,把上下文搞坏。所以论文的规定很简单:
每个返回的释放函数必须是幂等的——至多生效一次,第二次调用什么都不做。
关键设计:生成式句柄(idem)
这个「至多生效一次」不是靠一个全局开关,而是靠论文定义的幂等防护 idem,它的核心是生成式(generative)句柄:
function idem(g) {
const h = freshHandle(); // 生成式:每次包装都拿到一个全新的句柄 h
return function (γ) {
if (used.has(h)) return γ; // h 已被标记「用过」→ 什么都不做,原样返回
used.add(h); // 第一次调用 → 先把 h 标记为「已用」
return g(γ); // 然后才真正执行逆函数
};
}
拆开看三个要点:
- 句柄是新鲜的:每次应用 idem 都会生成一个全新的句柄 h,它只属于这一个释放函数,别处拿不到(h 不出现在 idem 的类型里)。所以任意两个释放函数绝不共享句柄,互不干扰。
- 状态只记「用过没有」:一个「已用句柄」的记录;h 不在记录里 → 有效,触发后把 h 写进记录 → 失效。第二次进来看到 h 已在记录里,直接原样返回。
- 依然是纯函数:释放函数仍然只依赖它作用的状态 γ 和「前置条件」(h 未被使用),不依赖隐藏的可变状态——可回退系统「一切可解释」的性质一点没丢。
用生活中的话说:它就像一次性保险丝或单次使用的入场券——用过即熔断,第二次想再触发,门已经关了。实现上,每个 dispose 闭包新鲜捕获一个 armed 变量,就是这个机制。
💡 论文里 effect 的幂等变体只改了一处:返回的逆函数用 idem 包装,正向的累积部分原封不动(它本来就由幂等的 recover 兜底)。所以之前学的同态、累积等结论统统不受影响。
3. 迭代:一次 Reload,可以分很多步
现实中的 Reload 很少是一步到位的:激活一个组件可能要连续做好几件事——连数据库、打开文件、注册监听器、启动后台任务。如果这些事必须一次性原子地做完,那系统就太迟钝了。
论文的答案是效应迭代器(effect iterator):把一次 Reload 拆成很多步,每一步做一点。每一步执行完,返回一个三元组 (δ, g, o):
| 分量 | 含义 |
|---|---|
| δ | 这一步执行完之后的新上下文 |
| g | 这一步效应的逆函数(将来卸载时用来撤销它) |
| o | 延续(continuation):Nothing = 到此为止;Just(下一个迭代器) = 还有下一步要做 |
整个转换就是沿着这个结构递归推进:每做一步,把这一步的逆函数 g 按执行顺序复合进累积恢复函数里。最后累积成
φ ∘ g1 ∘ g2 ∘ … ∘ gk
应用这个复合函数时,从最右边开始——最后执行的效应最先被撤销,也就是教科书式的后进先出(LIFO):先关后开的,后关先开的,恢复顺序滴水不漏。
步与步之间,是天然的中断点
迭代器最妙的地方在于:每两步之间的边界,就是一个自然的中断点。
- 如果目标状态在两步之间变了(依赖被抽走、用户取消、组件被替换……),系统可以在这里停下来;
- 停下时,播放截至当前累积的逆函数,把已经做掉的部分安全地全部撤销;
- 不需要任何额外机制——每一步返回的
Maybe延续本身就是边界标记。
论文说,效应迭代器本质上是「具体化的有界延续」,主流语言里的 yield 就是它:写生成器时,每次 yield 之间都可以安全地挂起或取消。所以这套模型能直接映射到你已经会用的一切。
🎯 粒度权衡:单步效应(一步做完、立即返回 Nothing)是退化情形——转换是原子的,没有中断边界。步骤越细,系统响应目标状态变化越快,但每步都要检查一次条件,开销也越大。细 = 敏捷但费电,粗 = 省事但迟钝,这就是设计者要权衡的。
💡 彩蛋:论文还提了双向效应迭代器——把逆函数分量也换成迭代器,让 Unload 也能增量推进、能被中断转回 Reload。可惜很少语言原生支持双向迭代,所以它更多是理论上的扩展。
4. 连贯性与纪元:给目标状态打上版本号
现在把「反应式」和「迭代」放在一起,会撞出一个新问题。
假设一个组件的依赖在运行时被替换了(比如它读的配置文件被换掉)。目标状态可能在一瞬间连跳三次:
Active → Inactive → Active
糟糕的是,第一个 Active 发起的 Reload 可能还在进行中(迭代还没走完)。第二个 Active 一到,系统面对的是一个很阴险的局面:依赖值已经变了,但状态还是 Active——「Active → Active」。如果系统傻傻地继续原来的 Reload,组件就会绑定到陈旧的依赖值上:新环境配了新值,组件却拿旧值完成了初始化。这就是不一致(incoherence)。
纪元:目标状态的「版本号」
论文的解法是纪元(epoch)机制——给目标状态赋予一个版本。对依赖规格 d,纪元函数取出 d 里所有依赖键当前的值,打包成一个版本标签:
εd(σ) = ⟨ σ(k) | k ∈ d ⟩
- 两个纪元相等 ⟺ 每一个依赖值都相同。只要有一个值变了,纪元就变了。
- Inactive 的纪元是一个特殊常量 ⊥,它不等于任何活跃纪元——未激活就是「无版本」,跟任何配置都不相干。
用法只有两步,但非常关键:
- 每次转换开始时,把当前目标状态的纪元记下来,存为 εinert(惯性纪元);
- 在每个迭代器步骤边界,比较「现在」的纪元 εtarget 和记下的 εinert:
- 一致 → 世界没变,继续走下一步;
- 不一致 → 世界已经变了,立即中止转换,播放累积的逆函数,收工。
打个比方:开工前先给「当前配置」拍一张照片;每干完一步就对一下照片,照片对不上就立刻停工、把已做的全部还原。
从「一条线」变成「一颗星」
纪元机制让生命周期从一维的 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 可能要去连数据库、写文件、请求网络,要花真实时间。
论文对此的抽象很干脆:一次转换产生一个类型为 Future(A) 的值,而 Future 的定义性质就是——在「提交转换」和「求值完成」之间,外部状态可能已经发生变化。换句话说:转换进行到一半时,世界可能已经变了。
两个威胁
转换占据真实时间,会带来两个资源安全问题:
- 立即回滚进行中的 Reload:前几步的效应还没执行完,你就把逆函数一股脑播放出来——LIFO 顺序直接被打乱,后面真正需要撤销的效应没有逆函数可放,已经放掉的又放错了时机。
- 快速振荡(Reload → Unload → Reload → …):目标状态来回横跳,可能导致同一个逆函数被调用两次——这是第 2 节幂等防护要防的事,但振荡会绕开它。
惯性:一旦开跑,就让它跑完
为了恢复安全,论文把 Reload 与 Unload 从「瞬时转换」提升为惯性状态(inertia):
一旦进入某个转换,该转换就运行至完成;之后,系统才处理目标状态的任何变化。
具体语义三条:
- 组件在 Reload 中、目标变为 Inactive → Reload 先跑完,随后才开始 Unload;如果 Reload 完成前目标又恢复 Active,组件就正常进入 Active(等于这趟没白跑)。
- 对称地,组件在 Unload 中、目标变为 Active → Unload 先跑完,随后才开始 Reload;如果又变回 Inactive,组件进入 Inactive。
- 待处理的反向转换会延迟到当前惯性状态结束;届时系统根据当时的目标状态决定执行还是丢弃它——目标可能已经又变了,那就按最新情况办。
类比:电梯一旦关门启动,就开到目标楼层才开门——中途按别的楼层不会让它立刻掉头;等它到站了,再按新的楼层它才会响应。
惯性 × 纪元 = 完整的安全网
惯性状态和迭代器边界可以组合使用,这也是整个 3.3 节收尾的一笔:在每一个步骤边界,系统检查纪元是否仍然匹配——
- 匹配 → 继续下一步;
- 不匹配 → 中止转换 → 播放累积逆函数 → 开始那个被延迟的转换。
这正是每个惯性转换内部运作的同步中断机制:惯性保证「当前转换不被打断」,纪元保证「被打断时一定是在安全的中断点上、并且依据的是最新状态」。两条规则各管一段,合成一个既流畅又安全的生命周期。
关键点回顾
这一课信息量很大,记住这五句话就够了:
- 组件 = 依赖声明 d(我需要什么)+ 效应函数 e(我贡献什么);目标状态 Active ⟺ 「效应已应用且逆函数未调用」且「所有依赖满足」,否则 Inactive;状态一变就 Reload(执行 e 累积副作用)或 Unload(播放累积逆函数)。
- 幂等:每个逆函数最多生效一次——idem 用生成式句柄 + 已用记录实现「用过即熔断」,防止重复卸载破坏上下文,且保持纯函数性质。
- 迭代:一次 Reload 可以分很多步(效应迭代器),每步返回 δ、逆函数 g、延续 o;步与步之间是天然中断点,可中途停下安全回滚;逆函数按执行顺序累积,恢复时后进先出(LIFO)。
- 纪元:目标状态带版本号(依赖值快照 εd(σ));每次转换记录 εinert,步骤边界比对 εtarget,不一致就中止转换——专治「依赖被替换后还继续旧 Reload」的过时绑定问题。
- 异步惯性:Reload/Unload 要花真实时间;一旦进入某转换就运行至完成,再响应新变化,待处理的反向转换延迟到惯性状态结束时按当时目标状态执行或丢弃——避免破坏 LIFO 和重复调用逆函数。
🚀 下一课预告:有了组件和生命周期,论文第 3.4 节要把「效应上下文」和「余效应上下文」统一成一种具体构造——也就是第 10 课「上下文范式:统一上下文类型」,看这套机制如何落地成一个真正能跑的运行时。
自测题 · 组件生命周期
完成作答后点击「提交答案」,可以查看对错与解析。
