延续抽象副作用:Gleam 中延续的应用及业务逻辑抽象实现

用延续(Continuations)抽象副作用

将错误表示为值是编写可靠程序的强大工具。结果值可以包装所需的值,也可以包含计算失败原因的信息。显式的结果类型在类型系统中体现了失败的可能性,返回结果的函数会迫使调用者处理失败情况。结果类型是一个更广泛模式的具体实例,Promise 表示异步计算,返回一个 Promise 时,调用者必须等待计算完成才能访问值。结果类型和 Promise 各自跟踪计算的特定细节,若要对计算细节进行泛化,可以使用延续。使用延续意味着:“我不清楚你将如何获取值,但当你获取到之后,接下来应该这样处理。”Filinski 在 1994 年发表的论文《Representing Monads》中证明,延续可以表示任何单子,比如结果类型和 Promise。本文不会深入探讨证明的数学原理,而是展示如何在 Gleam 中使用延续。

一个示例

我们使用一个 `fetch` 函数,它根据键返回对应的值。这个函数由调用者提供,接受一个 `String` 类型的键,并返回一个 `String` 类型的值。将 `fetch` 作为参数传入,能让我们根据需要从不同数据源中获取值。一个简单的 `fetch` 实现可以忽略键,始终返回 "yes"。业务逻辑如下:对于一个固定的键列表,将每个键转换为大写,然后返回关联值的长度。

企业级需求

我们的 `simple_func` 运行良好,业务开始拓展。业务逻辑保持不变,但企业客户的数据存储方式多种多样。有些 `fetch` 实现无法总是返回值。为了处理这种情况,我们创建了一个新的业务逻辑版本,其中 `fetch` 返回一个 `Result(String, Nil)`。这个新函数取得了不错的效果,不久后又有了对异步数据存储的需求。另一个接受返回 `Promise(String)` 的 `fetch` 函数的实现解决了这个问题。此时,我们有了三个实现来满足所有不同的需求。每个版本的业务逻辑相同,函数也很相似,但我们没有在不同实现之间复用业务逻辑。更糟糕的是,我们还面临着对可能失败的异步实现的需求,即 `fetch` 返回 `Promise(Result(String, Nil))`。我们无法复用之前的可能失败或异步的实现,需要为函数编写第四个实现。

缺失的抽象

不同实现之间的差异仅在于 `fetch` 执行的计算类型:直接计算、可能失败的计算、异步计算或其他类型。每个实现的共同点是如何创建键,以及在值可用时如何处理它。延续允许我们表示这种“前后”关系,同时对计算类型进行泛化。我们创建一个新的版本,接受返回 `Continuation(t, String)` 的 `fetch`。这个任务现在对计算类型进行了泛化。调用者将通过提供的 `fetch` 实现来选择 `t` 的具体类型。为了提取最终值,调用者需要提供一个最终回调函数。基于延续的任务的调用者通常被称为运行器或解释器。

简单运行器

在简单情况下,`fetch` 没有额外的副作用,并且总是返回一个字符串。它仍然返回一个延续,因此最终值用 `continuation.return` 包装。由于最终值会被解包,所以将恒等函数作为最终回调函数传入。这个简单运行器返回一个 `List(Int)`,表明 `fetch` 本身是可靠且同步的。

可能失败的运行器

当 `fetch` 返回 `Result(String, Nil)` 时,我们的最终回调函数也必须返回一个结果。因此,最终回调函数用 `Ok` 包装值。`then` 是恢复任务的回调函数,它在值存在时被调用。

异步运行器

当 `fetch` 是异步时,运行器将 `t` 选为 `Promise(List(Int))`。最终回调函数使用 `promise.resolve` 来匹配 `t`。

结论

有了 `task`,我们对业务逻辑有了单一的定义。它描述了如何使用值,而不考虑值是如何获取的。运行器决定如何获取值。任何以这种方式编写的任务都可以使用相同的运行器。这种逻辑和副作用的分离就是延续为我们提供的抽象。

我正在构建 EYG,这是一个关于构建更好的语言和工具的实验;这里的“更好”是有一定衡量标准的。所有进展都会在我的不定期时事通讯中汇报。