可逆的插件系统
副作用与可逆性
副作用的封装
现实中的程序往往要与各种各样的副作用打交道。
假设它含有副作用,我们把所有可能的副作用用类型
如果忽略
其中的任何一个函数
- 封闭性:
也是 到自身的变换。 - 结合律:
。 - 单位元:存在
,使得 。
我们还希望
什么样的函数才有副作用?
- 打开一个文件 → 占用此文件 → 关闭文件
- 创建子进程 → 占用此进程号 → 杀死进程
- 监听端口 → 占用此端口 → 取消监听端口
- 添加回调函数 → 占用特定场景 → 删除回调函数
- 分配内存空间 → 占用内存 → 回收内存空间
副作用就是对资源的占用。
计算机内的资源设计出来就是可以重复使用的,所以这些副作用一定是可逆的。
副作用的回收
一个创建服务器的例子。
const serve = (port: number) => {
const server = createServer().listen(port)
return () => server.close()
}
const dispose = serve(80) // 监听端口 80
dispose() // 回收副作用
- 在这个例子中,
serve()
函数将会创建一个服务器并且监听port
端口。 - 同时,调用该函数也会返回一个新的函数,用于取消该端口的监听。
你可能很难将这段 TypeScript 代码与上面的数学定义对应起来,这是因为 TypeScript 并不是一个纯函数式语言。
具体而言,这段代码以如下的方式与
建立对应关系:
对应着全局环境 (我们稍后会提到全局环境的坏处,但不影响这里的理解)。 对应于代码中的 port
,由于我们可以使用柯里化,所以在数学模型中并不需要考虑它。对应于代码中的 () => server.close()
,它的类型是。
通过
可以证明
定义
现在你就可以使用 restore()
来回收副作用了:
const serve = effect((port: number) => {
const server = createServer().listen(port)
return () => server.close()
})
serve(80) // 监听端口 80 并记录副作用
serve(443) // 监听端口 443 并记录副作用
restore() // 回收所有副作用
上下文与插件
全局环境
当副作用被记录到全局环境时,
我们可以这样定义:
每个
上下文对象
前面的示例并没有显式地写出
然而这种设计并不适合插件化和规模化的场景:
- 所有插件都使用相同的全局函数,意味着不同插件的副作用完全无法区分。
- 因此我们无法动态卸载某个插件,只能重启整个应用。
为此,我们引入了上下文对象的概念。
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
会显得很麻烦,有什么更好的写法吗?
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,它们都是某个函数的版本。 - 开发者只需要调用上下文上的方法,就可以确保插件的副作用是可回收的。
- 在大多数场景下,开发者甚至完全不需要手动调用
ctx.effect()
,就能编写出资源安全的插件。
Cordis 对于框架开发的两个优势:
- 无感性:只要框架将领域中的所有方法都妥善封装,开发者就可以自然地编写出资源安全的插件。
- 渐进性:可以逐步地替换现有的框架中的 API,而不需要一次性地将所有方法都实现为资源安全的。