← 返回信息流
AI 资讯Hacker News·12 小时前

用续延抽象副作用的新思路

原标题:Abstracting Effects with Continuations

速览

本文介绍了一种利用续延(continuations)来抽象和管理程序中副作用的技术方案。该方法通过控制流重定向,将副作用与核心逻辑分离,从而提升代码的模块性与可测试性。这一思路对函数式编程、编译器设计及异步编程等领域具有理论参考价值,虽然并非直接冲击AI主流,但可为AI系统中的状态管理提供新视角。

AI 深度解读

背景

在编写可靠程序时,将错误作为值表示是一种强大工具。Result 类型封装了期望值或失败原因,在类型系统中显式标记计算可能失败,迫使调用者处理错误。类似地,Promise 表示异步计算,返回 Promise 的函数强制调用者等待计算完成才能访问值。ResultPromise 各自跟踪计算的某一特定细节,当需要泛化计算细节时,可以使用 continuation(延续)。Continuation 的核心思想是:“我不知道你将如何获取值,但当你获取到时,下一步应该做什么。”1994 年,Filinski 在《Representing Monads》中证明了 continuation 可以表达任何 monad(如 ResultPromise)。本文不涉及数学证明,而是演示如何在 Gleam 语言中使用 continuation 实现效果抽象。

核心内容

一个虚构的例子

有一个 fetch 函数,它接受一个字符串键并返回字符串值。调用者提供该函数,允许从不同数据源获取值。最简单的实现如下:

fn fetch(key: String) -> String {
  "yes"
}

业务逻辑:对固定键列表,将每个键转为大写,然后返回关联值的长度。简单实现:

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)

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

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 执行的计算类型:直接、可失败、异步或其他。公共部分是如何创建键以及如何处理可用值。Continuation 允许我们表示这种“前后”关系,同时泛化计算类型。

pub type Continuation(t, a) = fn(fn(a) -> t) -> t

其中 a 是 continuation 包装的值的类型,t 是尚未指定的计算细节。新版本接受返回 Continuation(t, String)fetch

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 类型。要提取最终值,调用者需要提供最终回调。这种基于 continuation 的任务的调用者通常称为 runner 或 interpreter。

简单 runner

在简单情况下,fetch 无额外效果,总是用字符串恢复。它仍然返回 continuation,因此最终值被 continuation.return 包装。最终回调使用恒等函数,runner 返回 List(Int),反映 fetch 本身不可失败且同步。

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)
}

可失败 runner

fetch 返回 Result(String, Nil) 时,最终回调也必须返回结果,因此用 Ok 包装值。then 是恢复任务的回调,如果存在值则调用它。

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)
}

异步 runner

fetch 异步时,runner 选择 tPromise(List(Int))。最终回调使用 promise.resolve 匹配 t

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,我们得到业务逻辑的单一定义。它描述无论值如何获取都应如何使用。Runner 决定值的获取方式。任何以这种风格编写的任务都可以使用同一个 runner。这种逻辑与效果的分离正是 continuation 提供的抽象。

关键要点

  • ResultPromise 是特定效果的具体实例,它们各自强制调用者处理某种计算细节(失败或异步),但无法统一处理多种效果组合。
  • Continuation 可以泛化任何 monad,基于 Filinski 1994 年的理论证明,允许将计算细节(直接、可失败、异步等)抽象为类型参数 t
  • Gleam 中的 Continuation 类型定义Continuation(t, a) = fn(fn(a) -> t) -> t,其中 a 是值类型,t 是计算效果类型。
  • 业务逻辑与效果分离task 函数只描述如何转换值(大写、长度计算),不关心值的获取方式;调用者通过不同的 runner 注入具体效果(同步、可失败、异步)。
  • Runner 扮演解释器角色:它提供最终回调,将 continuation 计算后的结果转换为具体类型(如 List(Int)Result(List(Int), Nil)Promise(List(Int)))。
  • 避免重复代码:原本需要为每种效果组合编写
查看原文 →crowdhailer.me