第 10 课:上下文范式:统一上下文类型
一句话版:上下文范式(Context Paradigm)把「我在哪(当前状态)、我改了什么(逆函数)、我需要什么(依赖表)」三样东西全部装进同一个可递归的 ctx 实体里,让组件的「插拔」从一句比喻变成真正能落实的结构——撤销和重连的正确性,不再靠开发者自律,而是靠构造本身保证。
第 1 步:一个 ctx 装三样东西——「我在哪、我改了什么、我需要什么」
先回忆前几课的两条主线:
- 效应(effect):组件做事时留下的副作用(改了文件、起了进程……),要求能撤销;
- 余效应(coeffect):组件做事时需要的东西(某个服务、某份配置……),要求能自动接上。
之前我们分别用两种机制处理它们。这一节论文做了一个大胆的统一:把效应、余效应连同「当前状态」一起,塞进同一个实体——一个递归的上下文类型:
定义 24(上下文类型):Γ∞ ≔ μΓ. Γ × (Γ → Γ) × Σ
看不懂这个公式没关系,拆开看就是一句话:一个 ctx 里同时装着三样东西。
| 分量 | 装的是什么 | 大白话 | 一句话类比 |
|---|---|---|---|
| Γ | 当前上下文状态(递归的) | 我现在在哪、世界现在长什么样 | 当前位置 |
| Γ → Γ | 累积逆函数 | 我一路改了什么、怎么一步步退回去 | 撤销日志 |
| Σ | 依赖表 | 我现在需要哪些东西 | 购物清单 |
三个分量合起来,就是组件每次与环境交互前都要回答的三个问题:我在哪?我改过什么?我还缺什么? 每一次交互都只经过这一个 ctx 实体。
一个 ctx 同时记住「我在哪、我改了什么、我需要什么」——效应与余效应合体
为什么叫「递归」?因为第一个分量 Γ 的里面,装的还是同一个 Γ——就像俄罗斯套娃,每一层都是同一类东西,所以想嵌套多深都行。这种「自己套自己」的结构有个专门说法:自相似。论文前面把效应抽象成一个越叠越高的「𝜕 塔」,这里用递归把整座塔压平成了同一个类型。
再往深看一层,这个设计还带来两个额外好处:
- 效应成为 ctx 上的自同态:一次效应就是「拿着一个 ctx 进去,带着新 ctx 和一个逆函数出来」——进出的都是同一个类型,所以任何效应都可以任意拼接。
- Σ 能装下一切共享状态:因为 Σ 底层依赖的类型不受限制,任何想在组件之间共享的全局状态,都可以编码成 Σ 里的一项带类型的依赖。换句话说,Σ 涵盖的不只是「组件间依赖」,而是所有共享可变状态。
第 2 步:层次化组合——插拔真的只是「插」和「拔」
上一课(组件生命周期)里我们说过「组件像插头、可以插拔」。现在有了 Γ∞,这个比喻终于从修辞变成了构造。
由于 ctx 是递归的,子组件的 ctx 天然嵌在父组件的 ctx 里——父上下文聚合多个子层级的效应,形成一棵树状控制结构:
┌───────────────┐
│ 父上下文 │ ← 聚合并管理所有子组件的效应
└───────┬───────┘
┌───────────┼───────────┐
▼ ▼ ▼
[组件 A] [组件 B] [组件 C] ← 各自独立插拔,互不影响
「插拔」直接落实为两个操作:
| 操作 | 做了什么 | 大白话 |
|---|---|---|
| 加载组件 | 执行它的效应(插入) | 把插头插上,效果生效 |
| 卸载组件 | 恢复它的效应(拔出) | 拔掉插头,效果撤销 |
这套设计有三个关键保证:
- 拔出不影响别人:卸载一个组件只是恢复它自己的效应,其他正在运行的组件不受任何影响;
- 层级互不干扰:树里不同层级的组件可以各自独立加载、卸载,不需要全局统一的顺序;
- 任意深度嵌套:父上下文负责聚合并管理它所有子组件的效应,子组件还能再套子组件,想套几层都行。
🎁 打个比方:父上下文像一个「带多个插孔的插排」——拔掉一个设备,别的设备照常供电;而插排本身还能再插进另一个插排,无限延长。
第 3 步:同一个效应,两种实现——原地 or 派生
论文在这里做了一个很关键的概念切割:指称(denotation)与实现(implementation)分离。
把一个操作定型为 Γ∞ 上的效应,固定的是它的指称——「一个后继状态 + 一个对应的逆函数」;但不固定它的实现——这个逆函数到底怎么执行,由实现说了算。
定义 25(效应函数的两种实现):效应函数 f 允许两种实现方式。
| 原地实现(in-place) | 派生实现(derived) | |
|---|---|---|
| 上下文 | 修改原上下文,后继状态是输入的别名 | 保持输入不变,在递归结构中返回一个新上下文 |
| 逆函数 | 返回非平凡的逆函数(真实记录了改动) | 返回恒等函数(什么都没改,无需撤销) |
| 恢复方式 | 运行逆函数,逆转刚才的修改 | 丢弃那个派生出来的新上下文 |
| 类比 | 在合同原件上改,每次改动都记进撤销清单,要退回就按清单倒着改 | 复印一份,只在复印件上改,原件不动;不要了就把复印件扔进碎纸机 |
注意区分「指称」和「实现」:同一个效应,指称固定(后继状态 + 逆函数),但两种实现任选其一。选哪种取决于宿主环境:
- 在纯函数式环境里,没有「原地修改」这回事,两种实现彼此重合(都只能派生);
- 在命令式宿主里,开发者可以逐个操作自由选择:想改得快就用原地,想不碰原数据就用派生。
💡 论文说第 4.1.2 节会给出两种实现的代表性写法——下一课我们就会见到它们长什么样。
第 4 步:它凭什么是一种「范式」?——对比两种旧思路
论文的野心不止于「给出一个类型」,而是主张:这个上下文类型本身就构成一种编程范式。要理解这一点,先看两种旧范式是如何处理副作用的——它们站在同一个谱系的两极。
极左:显式状态传递(函数式)
纯函数式语言为了保持引用透明性,把副作用建模成对状态的显式变换——经典例子是状态单子 S → (A, S),让环境贯穿每一次计算。
- ✅ 好处:效应在类型里可见,可以做等式推理,可追溯性极强;
- ❌ 代价:调用链里每个函数都得接收并返回状态参数,哪怕它只是把状态原样往下传;一旦效应维度变多(日志、配置、I/O),单子堆叠或效应处理器的样板代码就迅速膨胀。
🎁 打个比方:公司规定每个文件都要经手每个员工并签字,哪怕只是路过转交——可追溯,但累死人。
极右:隐式变更(命令式 / OOP)
主流命令式语言允许组件直接修改共享状态、直接取用依赖,调用点上什么都不用声明。
- ✅ 好处:写起来轻松,人体工学极佳;
- ❌ 代价:不可追踪。论文给了两个活生生的例子:
- 效应侧:React 的
useEffect钩子——它在组件内部的纤程上注册持久副作用,但效应目标和注册机制都不是显式参数,其身份靠隐藏运行时状态里的调用顺序位置来确定; - 余效应侧:Java 的服务定位器(比如 Spring 的
getBean(...))——从全局注册表取依赖,每个调用点都要判空、做类型转换,依赖关系隐含且散落在整个代码库。
- 效应侧:React 的
更糟的是:要理解 f() 到底改了系统什么、依赖系统什么,你得顺着调用关系递归地读它的实现;重构也因此变得脆弱——移动或删除某次调用,都可能悄无声息地破坏远处的不变量。
🎁 打个比方:一块公共黑板,谁都能上去改,但没人留名——方便,但出了事查不到是谁改的。
中间:上下文范式——两者兼得
上下文范式把这两极缝合了起来:效应和余效应都通过一个显式的上下文参数来介导。因此:
- 每个操作都能归属到调用它的那个 ctx,进而归属到 ctx 所属的那个组件——像函数式一样可追踪;
- 开发者不用在调用链里手写一堆状态参数——像命令式一样不啰嗦。
而且它不只是「折中」,还额外升级了正确性保证:
| 场景 | 旧做法(靠自律) | 上下文范式(靠结构) |
|---|---|---|
| 可回退效应 | 每个复合操作都要开发者自己写撤销逻辑 | 只需给每个原子操作提供逆函数,复合操作的逆函数由组合自动得到——加载和拆卸从构造上就可逆 |
| 反应式余效应 | 依赖接没接对,全凭小心 | 组件只声明需要的依赖,运行时自动解析并重连——提供者被添加、移除、替换时,连接始终正确 |
两个方向合起来就是这一节的核心结论:原本需要开发者自律来保证的正确性,变成了这种范式的一种结构性属性。
关键点回顾
信息量不小,记住这五句话就够了:
- 统一上下文:Γ∞ ≔ μΓ. Γ × (Γ → Γ) × Σ——一个 ctx 同时装「当前状态 Γ」「累积逆函数 Γ → Γ」「依赖表 Σ」,即「我在哪、我改了什么、我需要什么」。
- 递归自相似:Γ 里套的还是 Γ,像俄罗斯套娃,想嵌套多深都行;效应成为 ctx 上的自同态(ctx 进、ctx 出加逆函数),Σ 能编码所有共享可变状态。
- 层次化组合:父上下文聚合子效应,形成树状结构;加载 = 插入效应,卸载 = 拔出并恢复,互不影响,支持任意深度嵌套。
- 指称与实现分离:效应固定「指称」(后继状态 + 逆函数),不固定实现——原地实现(改 ctx + 非平凡逆)或派生实现(返回新 ctx + 恒等逆,恢复 = 丢弃)。
- 范式定位:函数式显式状态传递可追踪但样板多,命令式隐式变更好用但不可追踪;上下文范式用显式 ctx 兼得两者,正确性从「开发者自律」变成「结构性保证」。
🚀 纸上谈兵到此结束——下一课进入论文第 4 章「实现与案例研究」:Cordis 核心库,看看 Γ∞ 是怎么变成真实代码的,原地 / 派生两种实现到底怎么写。
自测题 · 上下文范式
完成作答后点击「提交答案」,可以查看对错与解析。
