Skip to content

可逆的插件系统

副作用与可逆性

副作用的封装

现实中的程序往往要与各种各样的副作用打交道。

fimpure:XY

假设它含有副作用,我们把所有可能的副作用用类型 C 封装起来,则该函数可以被转化为:

f:C×XC×Y

如果忽略 f 本身的入参和出参,只考虑副作用,那么可以定义函数空间 F=CC

其中的任何一个函数 f:CC 都是 C 到自身的变换,不难看出它们在函数结合 下构成幺半群:

  1. 封闭性:fg 也是 C 到自身的变换。
  2. 结合律:(fg)h=f(gh)
  3. 单位元:存在 id,使得 fid=idf=f

我们还希望 f 的副作用是可以回收的。换言之,如果额外要求 f 存在逆元 f1,此时 F 就构成一个群。

什么样的函数才有副作用?

  • 打开一个文件 → 占用此文件 → 关闭文件
  • 创建子进程 → 占用此进程号 → 杀死进程
  • 监听端口 → 占用此端口 → 取消监听端口
  • 添加回调函数 → 占用特定场景 → 删除回调函数
  • 分配内存空间 → 占用内存 → 回收内存空间

副作用就是对资源的占用。

计算机内的资源设计出来就是可以重复使用的,所以这些副作用一定是可逆的。

副作用的回收

一个创建服务器的例子。

ts
const serve = (port: number) => {
  const server = createServer().listen(port)
  return () => server.close()
}
const dispose = serve(80)       // 监听端口 80
dispose()                       // 回收副作用
  • 在这个例子中,serve() 函数将会创建一个服务器并且监听 port 端口。
  • 同时,调用该函数也会返回一个新的函数,用于取消该端口的监听。

你可能很难将这段 TypeScript 代码与上面的数学定义对应起来,这是因为 TypeScript 并不是一个纯函数式语言。

具体而言,这段代码以如下的方式与 C×XC×Y 建立对应关系:

  • C 对应着全局环境 (我们稍后会提到全局环境的坏处,但不影响这里的理解)。
  • X 对应于代码中的 port,由于我们可以使用柯里化,所以在数学模型中并不需要考虑它。
  • Y 对应于代码中的 () => server.close(),它的类型是 F=CC

通过 effect 显式地提供逆函数。

effect:(CC)C×(CC)C×(CC)effect=f(c,h)(f(c),hf1)

可以证明 effect 是一个 CCC×(CC)C×(CC) 的同态:

effect (fg)(c,h)=((fg)(c),h(fg)1)=(f(g(c)),hg1f1)=effect f(g(c),hg1)=(effect feffect g)(c,h)

effect 的作用是将副作用从函数的返回值中分离出来,从而实现副作用的回收。

定义 restore 变换 (不难发现它确实是 effect 的逆操作):

restore:C×(CC)C×(CC)restore=(c,h)(h(c),id)

现在你就可以使用 restore() 来回收副作用了:

ts
const serve = effect((port: number) => {
  const server = createServer().listen(port)
  return () => server.close()
})
serve(80)               // 监听端口 80 并记录副作用
serve(443)              // 监听端口 443 并记录副作用
restore()               // 回收所有副作用

上下文与插件

全局环境

当副作用被记录到全局环境时,C×(CC) 也就变成了一个更大的 C

我们可以这样定义:

C1=C0×(C0C0)C2=C1×(C1C1)Cn+1=Cn×(CnCn)

每个 C 包含了上一层的状态,同时记录了上一层的副作用。

上下文对象

前面的示例并没有显式地写出 C 参数和返回值。对 C 的变换存在于所有全局函数的闭包中。

然而这种设计并不适合插件化和规模化的场景:

  • 所有插件都使用相同的全局函数,意味着不同插件的副作用完全无法区分。
  • 因此我们无法动态卸载某个插件,只能重启整个应用。

为此,我们引入了上下文对象的概念。

ts
function serve(ctx: Context, port: number) {
  effect(ctx, () => {
    const server = createServer().listen(port)
    return () => server.close()
  })
}
serve(ctx1, 80)         // 监听端口 80 并记录副作用
serve(ctx1, 443)        // 监听端口 443 并记录副作用
serve(ctx2, 1234)       // 监听端口 1234 并记录副作用
restore(ctx1)           // 仅回收 ctx1 上的副作用

插件系统

在不同的函数之间传递 ctx 会显得很麻烦,有什么更好的写法吗?

ts
function serve(ctx: Context, port: number) {
  ctx.effect(() => {
    const server = createServer().listen(port)
    return () => server.close()
  })
}

ctx1.plugin(serve, 80)      // 监听端口 80 并记录副作用
ctx1.plugin(serve, 443)     // 监听端口 443 并记录副作用
ctx2.plugin(serve, 1234)    // 监听端口 1234 并记录副作用
ctx1.restore()              // 仅回收 ctx1 上的副作用
  • 当一个插件被加载时,将会从当前上下文对象上派生出一个新的上下文实例。
  • 子级上下文将管理插件内的全部副作用,而插件整体将作为一个副作用被父级上下文收集。

可以将上下文比作一个副作用的插座,而副作用就是上面的插头。

当上下文被卸载时,它将会将所有的副作用一一回收。

插件就是连接到另一个插座的插头,管理着子级上下文的全部副作用。

框架与资源安全

重新理解框架、库和插件。

  • 不产生副作用的是库 (Library)
  • 会产生副作用的是插件 (Plugin)
  • 能管理副作用的是框架 (Framework)

任何一个函数,它要么是纯函数,要么存在副作用,而这个副作用本身就是函数对外占用的资源。

这些资源可以是底层的内存、文件、进程,也可以是上层的各种封装。

如果插件或框架提供了完整回收副作用的能力,就可以称为是「资源安全」的。

内存安全也是一种资源安全。

  • Rust - 所有权机制 (语言层) - 内存安全 (强制保证)
  • Cordis - 上下文机制 (应用层) - 资源安全 (非强制)

框架方法通过上下文提供,而开发者实现的则称为插件。

  • 除了 ctx.plugin() 外,上下文对象上还有许多 API,它们都是某个函数的 effect 版本。
  • 开发者只需要调用上下文上的方法,就可以确保插件的副作用是可回收的。
  • 在大多数场景下,开发者甚至完全不需要手动调用 ctx.effect(),就能编写出资源安全的插件。

Cordis 对于框架开发的两个优势:

  • 无感性:只要框架将领域中的所有方法都妥善封装,开发者就可以自然地编写出资源安全的插件。
  • 渐进性:可以逐步地替换现有的框架中的 API,而不需要一次性地将所有方法都实现为资源安全的。