赞助商LobeHubLobeHub了解更多
ddshfind
登录

第 10 课:上下文范式:统一上下文类型

一句话版:上下文范式(Context Paradigm)把「我在哪(当前状态)、我改了什么(逆函数)、我需要什么(依赖表)」三样东西全部装进同一个可递归的 ctx 实体里,让组件的「插拔」从一句比喻变成真正能落实的结构——撤销和重连的正确性,不再靠开发者自律,而是靠构造本身保证。

第 1 步:一个 ctx 装三样东西——「我在哪、我改了什么、我需要什么」

先回忆前几课的两条主线:

  • 效应(effect):组件做事时留下的副作用(改了文件、起了进程……),要求能撤销
  • 余效应(coeffect):组件做事时需要的东西(某个服务、某份配置……),要求能自动接上

之前我们分别用两种机制处理它们。这一节论文做了一个大胆的统一:把效应、余效应连同「当前状态」一起,塞进同一个实体——一个递归的上下文类型:

定义 24(上下文类型):Γ∞ ≔ μΓ. Γ × (Γ → Γ) × Σ

看不懂这个公式没关系,拆开看就是一句话:一个 ctx 里同时装着三样东西

分量装的是什么大白话一句话类比
Γ当前上下文状态(递归的)我现在在哪、世界现在长什么样当前位置
Γ → Γ累积逆函数我一路改了什么、怎么一步步退回去撤销日志
Σ依赖表我现在需要哪些东西购物清单

三个分量合起来,就是组件每次与环境交互前都要回答的三个问题:我在哪?我改过什么?我还缺什么? 每一次交互都只经过这一个 ctx 实体。

统一上下文类型 Γ∞(ctx)Γ · 当前状态现在环境是什么样(递归嵌套)Γ→Γ · 逆函数怎么把改动撤销(累积恢复变换)Σ · 依赖表我需要什么(键 → 有类型的值)

一个 ctx 同时记住「我在哪、我改了什么、我需要什么」——效应与余效应合体

为什么叫「递归」?因为第一个分量 Γ 的里面,装的还是同一个 Γ——就像俄罗斯套娃,每一层都是同一类东西,所以想嵌套多深都行。这种「自己套自己」的结构有个专门说法:自相似。论文前面把效应抽象成一个越叠越高的「𝜕 塔」,这里用递归把整座塔压平成了同一个类型。

再往深看一层,这个设计还带来两个额外好处:

  • 效应成为 ctx 上的自同态:一次效应就是「拿着一个 ctx 进去,带着新 ctx 和一个逆函数出来」——进出的都是同一个类型,所以任何效应都可以任意拼接。
  • Σ 能装下一切共享状态:因为 Σ 底层依赖的类型不受限制,任何想在组件之间共享的全局状态,都可以编码成 Σ 里的一项带类型的依赖。换句话说,Σ 涵盖的不只是「组件间依赖」,而是所有共享可变状态

第 2 步:层次化组合——插拔真的只是「插」和「拔」

上一课(组件生命周期)里我们说过「组件像插头、可以插拔」。现在有了 Γ∞,这个比喻终于从修辞变成了构造

由于 ctx 是递归的,子组件的 ctx 天然嵌在父组件的 ctx 里——父上下文聚合多个子层级的效应,形成一棵树状控制结构

        ┌───────────────┐
        │   父上下文     │  ← 聚合并管理所有子组件的效应
        └───────┬───────┘
    ┌───────────┼───────────┐
    ▼           ▼           ▼
 [组件 A]    [组件 B]    [组件 C]   ← 各自独立插拔,互不影响

「插拔」直接落实为两个操作:

操作做了什么大白话
加载组件执行它的效应(插入把插头插上,效果生效
卸载组件恢复它的效应(拔出拔掉插头,效果撤销

这套设计有三个关键保证:

  1. 拔出不影响别人:卸载一个组件只是恢复它自己的效应,其他正在运行的组件不受任何影响;
  2. 层级互不干扰:树里不同层级的组件可以各自独立加载、卸载,不需要全局统一的顺序;
  3. 任意深度嵌套:父上下文负责聚合并管理它所有子组件的效应,子组件还能再套子组件,想套几层都行。

🎁 打个比方:父上下文像一个「带多个插孔的插排」——拔掉一个设备,别的设备照常供电;而插排本身还能再插进另一个插排,无限延长。

第 3 步:同一个效应,两种实现——原地 or 派生

论文在这里做了一个很关键的概念切割:指称(denotation)与实现(implementation)分离

把一个操作定型为 Γ∞ 上的效应,固定的是它的指称——「一个后继状态 + 一个对应的逆函数」;但不固定它的实现——这个逆函数到底怎么执行,由实现说了算。

定义 25(效应函数的两种实现):效应函数 f 允许两种实现方式。

原地实现(in-place)派生实现(derived)
上下文修改原上下文,后继状态是输入的别名保持输入不变,在递归结构中返回一个新上下文
逆函数返回非平凡的逆函数(真实记录了改动)返回恒等函数(什么都没改,无需撤销)
恢复方式运行逆函数,逆转刚才的修改丢弃那个派生出来的新上下文
类比在合同原件上改,每次改动都记进撤销清单,要退回就按清单倒着改复印一份,只在复印件上改,原件不动;不要了就把复印件扔进碎纸机

注意区分「指称」和「实现」:同一个效应,指称固定(后继状态 + 逆函数),但两种实现任选其一。选哪种取决于宿主环境:

  • 在纯函数式环境里,没有「原地修改」这回事,两种实现彼此重合(都只能派生);
  • 在命令式宿主里,开发者可以逐个操作自由选择:想改得快就用原地,想不碰原数据就用派生。

💡 论文说第 4.1.2 节会给出两种实现的代表性写法——下一课我们就会见到它们长什么样。

第 4 步:它凭什么是一种「范式」?——对比两种旧思路

论文的野心不止于「给出一个类型」,而是主张:这个上下文类型本身就构成一种编程范式。要理解这一点,先看两种旧范式是如何处理副作用的——它们站在同一个谱系的两极。

极左:显式状态传递(函数式)

纯函数式语言为了保持引用透明性,把副作用建模成对状态的显式变换——经典例子是状态单子 S → (A, S),让环境贯穿每一次计算。

  • 好处:效应在类型里可见,可以做等式推理,可追溯性极强
  • 代价:调用链里每个函数都得接收并返回状态参数,哪怕它只是把状态原样往下传;一旦效应维度变多(日志、配置、I/O),单子堆叠或效应处理器的样板代码就迅速膨胀。

🎁 打个比方:公司规定每个文件都要经手每个员工并签字,哪怕只是路过转交——可追溯,但累死人

极右:隐式变更(命令式 / OOP)

主流命令式语言允许组件直接修改共享状态、直接取用依赖,调用点上什么都不用声明

  • 好处:写起来轻松,人体工学极佳
  • 代价不可追踪。论文给了两个活生生的例子:
    • 效应侧:React 的 useEffect 钩子——它在组件内部的纤程上注册持久副作用,但效应目标和注册机制都不是显式参数,其身份靠隐藏运行时状态里的调用顺序位置来确定;
    • 余效应侧:Java 的服务定位器(比如 Spring 的 getBean(...))——从全局注册表取依赖,每个调用点都要判空、做类型转换,依赖关系隐含且散落在整个代码库。

更糟的是:要理解 f() 到底改了系统什么、依赖系统什么,你得顺着调用关系递归地读它的实现;重构也因此变得脆弱——移动或删除某次调用,都可能悄无声息地破坏远处的不变量。

🎁 打个比方:一块公共黑板,谁都能上去改,但没人留名——方便,但出了事查不到是谁改的

中间:上下文范式——两者兼得

上下文范式把这两极缝合了起来:效应和余效应都通过一个显式的上下文参数来介导。因此:

  • 每个操作都能归属到调用它的那个 ctx,进而归属到 ctx 所属的那个组件——像函数式一样可追踪
  • 开发者不用在调用链里手写一堆状态参数——像命令式一样不啰嗦

而且它不只是「折中」,还额外升级了正确性保证:

场景旧做法(靠自律)上下文范式(靠结构)
可回退效应每个复合操作都要开发者自己写撤销逻辑只需给每个原子操作提供逆函数,复合操作的逆函数由组合自动得到——加载和拆卸从构造上就可逆
反应式余效应依赖接没接对,全凭小心组件只声明需要的依赖,运行时自动解析并重连——提供者被添加、移除、替换时,连接始终正确

两个方向合起来就是这一节的核心结论:原本需要开发者自律来保证的正确性,变成了这种范式的一种结构性属性。

关键点回顾

信息量不小,记住这五句话就够了:

  1. 统一上下文:Γ∞ ≔ μΓ. Γ × (Γ → Γ) × Σ——一个 ctx 同时装「当前状态 Γ」「累积逆函数 Γ → Γ」「依赖表 Σ」,即「我在哪、我改了什么、我需要什么」。
  2. 递归自相似:Γ 里套的还是 Γ,像俄罗斯套娃,想嵌套多深都行;效应成为 ctx 上的自同态(ctx 进、ctx 出加逆函数),Σ 能编码所有共享可变状态。
  3. 层次化组合:父上下文聚合子效应,形成树状结构;加载 = 插入效应,卸载 = 拔出并恢复,互不影响,支持任意深度嵌套。
  4. 指称与实现分离:效应固定「指称」(后继状态 + 逆函数),不固定实现——原地实现(改 ctx + 非平凡逆)或派生实现(返回新 ctx + 恒等逆,恢复 = 丢弃)。
  5. 范式定位:函数式显式状态传递可追踪但样板多,命令式隐式变更好用但不可追踪;上下文范式用显式 ctx 兼得两者,正确性从「开发者自律」变成「结构性保证」。

🚀 纸上谈兵到此结束——下一课进入论文第 4 章「实现与案例研究」:Cordis 核心库,看看 Γ∞ 是怎么变成真实代码的,原地 / 派生两种实现到底怎么写。

自测题 · 上下文范式

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

1. 统一上下文类型 Γ∞ 里同时装着哪三样东西?
2. 关于层次化组合,下面哪个说法最准确?
3. 关于原地实现与派生实现的区别,哪个说法正确?
4. 上下文范式相比「函数式显式状态传递」和「命令式隐式变更」的优势是什么?