使用延续抽象效果
摘要
本文演示了如何在Gleam编程语言中使用延续来抽象不同的计算效果(如错误处理和异步),从而实现可重用的业务逻辑。
<p><a href="https://lobste.rs/s/rnc3h4/abstracting_effects_with_continuations">评论</a></p>
查看缓存全文
缓存时间: 2026/07/15 23:49
# 用续延抽象效果
来源:https://crowdhailer.me/2026-07-15/abstracting-effects-with-continuations/
将错误表示为值是编写可靠程序的有力工具。结果值包装了期望值,或者包含了关于计算为何失败的信息。显式的结果类型将失败的可能性体现在类型系统中。返回结果的函数强制调用者处理失败。
结果是一个更大模式的一个实例。Promise 表示异步计算。返回一个 Promise 会强制调用者在访问值之前等待计算完成。
结果和 Promise 各自跟踪计算的某个特定细节。要对计算的细节进行泛化,可以使用续延。使用续延意味着:“我不知道你将如何获取值,但当你获取到时,接下来应该做这件事。”
Filinski 的 *Representing Monads* (1994) 证明了续延可以表达任何单子(例如 Result 和 Promise)。我们不深入证明中的数学部分。本文旨在演示如何在 Gleam 中使用续延。
## 一个刻意设计的例子
我们有一个 `fetch` 函数,它根据键返回一个值。该函数由调用者提供,接受一个 `String` 键并返回一个 `String` 值。
将 `fetch` 作为参数接受,使我们能够根据需要从不同的数据源获取值。`fetch` 的一个简单实现可以忽略键,始终返回 `"yes"`:
```gleam
fn fetch(key: String) -> String {
"yes"
}
```
业务逻辑执行以下操作:对于一个固定的键列表,将每个键转换为大写,并返回关联值的长度。这个任务的简单解决方案如下:
```gleam
pub fn simple_func(fetch: fn(String) -> String) -> List(Int) {
let keys = ["a", " b"]
list.map(keys, fn(key) {
let key = string.uppercase(key)
let value = fetch(key)
string.length(value)
})
}
```
## 进入企业级
我们的 `simple_func` 运行得很好,因此业务开始扩张。业务逻辑保持不变,但我们的企业客户将数据存放在各种存储中。某些 fetch 实现不能总是返回值。为了处理这种情况,我们创建了业务逻辑的新版本,其中 `fetch` 返回 `Result(String, Nil)`。
```gleam
pub fn fallible_func(fetch: fn(String) -> Result(String, Nil)) -> Result(List(Int), Nil) {
let keys = ["a", " b"]
list.try_map(keys, fn(key) {
let key = string.uppercase(key)
use value <- result.map(fetch(key))
string.length(value)
})
}
```
新函数取得了巨大成功,不久后又收到了异步数据存储的请求。另一个实现接受返回 `Promise(String)` 的 fetch 解决了这个问题。
```gleam
pub fn async_func(fetch: fn(String) -> Promise(String)) -> Promise(List(Int)) {
let keys = ["a", "b"]
list.map(keys, fn(key) {
let key = string.uppercase(key)
use value <- promise.map(fetch(key))
string.length(value)
})
|> promise.await_list
}
```
到目前为止,我们有三个实现来支持所有不同的需求。在每个版本中,业务逻辑相同,函数也相似。但我们并没有在实现之间复用业务逻辑。
更糟糕的是,一个既可能失败又异步的实现——fetch 返回 `Promise(Result(String, Nil))`——正在等着我们。我们无法复用可失败或异步的实现,需要为函数编写第四个实现。
## 缺失的抽象
各个实现之间的区别只在于 `fetch` 执行的计算类型:直接的、可失败的、异步的或其他。每个实现中的共同部分是如何创建键以及当值可用时如何处理它。
续延允许我们表示这种“前与后”的关系,同时泛化于计算的具体类型。
```gleam
pub type Continuation(t, a) =
fn(fn(a) -> t) -> t
```
我们续延包装的值的类型是 `a`。(尚未明确的)计算细节的类型是 `t`。
我们创建一个新版本,接受一个返回 `Continuation(t, String)` 的 `fetch`。
```gleam
import midas/continuation.{type Continuation as K}
pub fn task(fetch: fn(String) -> K(t, String)) -> K(t, List(Int)) {
let keys = ["a", "b"]
continuation.each(keys, fn(key) {
let key = string.uppercase(key)
use value <- continuation.then(fetch(key))
continuation.return(string.length(value))
})
}
```
这个 `task` 现在是对计算类型泛化的。调用者将通过其提供的 `fetch` 实现选择 `t` 的具体类型。为了提取最终值,调用者需要提供一个最终回调。基于续延的任务的调用者通常被称为运行器或解释器。
### 简单运行器
在简单情况下,`fetch` 没有额外效果,并且总是用字符串恢复。它仍然返回一个续延,因此最终值用 `continuation.return` 包装。因为最终值被解包,所以将恒等函数作为最终回调传递。
这个简单运行器返回 `List(Int)`,反映 `fetch` 本身是不可失败且同步的。
```gleam
import midas/continuation
fn run_simple(task) -> List(Int) {
let fetch = fn(_key) { continuation.return("yes") }
task(fetch)(fn(x) { x })
}
fn run_simple_test() {
let assert [3, 3] = run_simple(task)
}
```
### 可失败运行器
当 `fetch` 返回 `Result(String, Nil)` 时,我们的最终回调也必须返回一个结果。因此最终回调用 `Ok` 包装值。
`then` 是恢复任务的回调,如果值存在则回调被调用。
```gleam
fn run_fallible(keys, task) -> Result(List(Int), Nil) {
let fetch = fn(key) {
fn(then) {
case dict.get(keys, key) {
Ok(value) -> then(value)
Error(Nil) -> Error(Nil)
}
}
}
task(fetch)(Ok)
}
fn run_fallible_test() {
let assert Error(Nil) =
run_fallible(dict.from_list([#("A", "Apple")]), task)
let assert Ok([5, 6]) =
run_fallible(dict.from_list([#("A", "Apple"), #("B", "Banana")]), task)
}
```
### 异步运行器
当 `fetch` 是异步的时,运行器选择 `t` 为 `Promise(List(Int))`。最终回调使用 `promise.resolve` 来匹配 `t`。
```gleam
fn run_async(task) {
let fetch = fn(_key) {
fn(then) {
use Nil <- promise.await(promise.wait(100))
then("slow")
}
}
task(fetch)(promise.resolve)
}
fn run_async_test() {
use v <- promise.await(task |> run_async())
// [slow, slow]
let assert [4, 4] = v
}
```
### 结论
凭借 `task`,我们有了业务逻辑的单一定义。它描述无论值如何获取都应该如何使用它们。
运行器决定如何获取值。任何以这种风格编写的任务都可以使用相同的运行器。这种逻辑与效果的分离正是续延带给我们的抽象。
---
我正在构建 EYG (https://eyg.run/),一个尝试构建更好语言和工具的试验;这里“更好”有某种定义。
所有进展都通过我不定期发布的新闻通讯报告。
相似文章
代数效应:给普通人的解释
这是一篇教育性博客文章,通过类比 try/catch 和 async/await 来解释编程中的代数效应概念,并讨论了它们与 React 及未来编程范式的潜在关联。
通过延续传递数据库
本文解释了如何利用延续传递风格(CPS)来融合数据库操作符,避免中间结果物化并提升性能,作为向量化和编译的替代方案。作者通过一个数值列表操作的简化示例进行说明,并描述了其灵感来源于一篇短文。
递归模式的隐秘历史
一场演讲,追溯从goto面条代码到结构化循环,再到递归模式的演化历程,展示控制流抽象如何映射数据结构,以及为何大多数语言仍把最好的组合子藏起来。
我尝试将BEAM风格的并发模型应用于代码智能体——结果令人惊讶
一项将BEAM风格并发(Erlang VM模型)应用于代码智能体的实验得到了令人惊讶的结果,暗示了在智能体协调和容错方面的潜在改进。
捕获子句作为效果
本文探讨了Rust中显式捕获子句作为move表达式的替代方案,提出了一种将引用转换为所有权值的一等语言特性,并分析了多种闭包捕获模式。