使用延续抽象效果

Lobsters Hottest 工具

摘要

本文演示了如何在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/),一个尝试构建更好语言和工具的试验;这里“更好”有某种定义。 所有进展都通过我不定期发布的新闻通讯报告。

相似文章

代数效应:给普通人的解释

Hacker News Top

这是一篇教育性博客文章,通过类比 try/catch 和 async/await 来解释编程中的代数效应概念,并讨论了它们与 React 及未来编程范式的潜在关联。

通过延续传递数据库

Hacker News Top

本文解释了如何利用延续传递风格(CPS)来融合数据库操作符,避免中间结果物化并提升性能,作为向量化和编译的替代方案。作者通过一个数值列表操作的简化示例进行说明,并描述了其灵感来源于一篇短文。

递归模式的隐秘历史

Lobsters Hottest

一场演讲,追溯从goto面条代码到结构化循环,再到递归模式的演化历程,展示控制流抽象如何映射数据结构,以及为何大多数语言仍把最好的组合子藏起来。

捕获子句作为效果

Lobsters Hottest

本文探讨了Rust中显式捕获子句作为move表达式的替代方案,提出了一种将引用转换为所有权值的一等语言特性,并分析了多种闭包捕获模式。