异常、Promise、生成器、async/await——主流语言里的控制流特性越来越多,但“未处理的异常”和“被错误处理器截获的异常”依然防不胜防。滑铁卢大学助理教授 Yizhou Zhang 在 SPLASH 2020 上提出的双向代数效应给出了一个统一答案:它在涵盖现有控制流模式的同时支持双向控制转移,并通过类型系统静态保证所有声明的效应都被妥善处理。

本文译自 InfoQ 对 SPLASH 2020 演讲的报道,完整演讲见 YouTube 视频,相关论文见滑铁卢大学技术报告。

以下为全文翻译。

滑铁卢大学助理教授 Yizhou Zhang 提出了双向代数效应(bidirectional algebraic effects):一种新的编程抽象,它涵盖了现有的控制流模式(如异常、Promise、生成器),同时支持双向的控制流。这个带类型的抽象保证所有声明的效应都会被处理,且没有效应会被意外处理(例如被错误的处理器截获)。

在 SPLASH(一门关注编程语言应用的会议)的演讲中,Zhang 首先回顾了众多语言对日益复杂的控制流特性的采纳。以 JavaScript 为例:ECMAScript 3 引入了异常;ECMAScript 6(即 ES6 或 ES2015)加入了可能抛出异常的 Promise 和生成器;ECMAScript 8(ES2017)随后又加入了 async 函数,ECMAScript 9(ES9)则补充了 async 生成器。

Zhang 接着解释道:

软件已经变得越来越事件驱动。回调函数是事件驱动编程的传统模式,但随着应用规模增长,不受约束的回调会变得复杂且难以推理。因此,编程语言内置对生成器、async–await 这类高级控制转移特性的支持,正成为风尚。这些特性让异步、事件驱动代码能够以更结构化的方式编写。

代数效应(algebraic effects)作为一种强大的替代方案应运而生,它允许程序员自定义控制效应;……涵盖了包括异常、生成器、async–await 在内的众多特性;……并在语法(即一组效应操作)与语义(即这些操作的处理方式)之间提供了漂亮的分离。

然而,即使手握这些高级语言特性,今天的程序员依然觉得某些复杂的控制流模式难以驾驭。

具体而言,Zhang 指出常见的控制流语言特性表达力不足:它们无法刻画双向的控制转移,同时也无法保证所有效应确实都被处理。Zhang 将主流语言中现有控制流构造的问题总结如下:

现有控制流构造的问题

(来源:SPLASH 演讲)

程序员们确实已经注意到,用 JavaScript 的 Promise 和 async/await 处理异步计算时存在潜在的” footgun “(容易误伤自己的特性)。Ian Segers 在一篇博文中详述了他在对初级开发者做代码评审时发现的错误处理问题:在 try...catch 块中,Promise 必须被 await,catch 块才能处理被拒绝(rejected)的 Promise(相关文章)。

async function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

try {
    thisThrows();
} catch (e) {
    console.error(e);
} finally {
    console.log('We do cleanup here');
}

// 输出:
// We do cleanup here
// UnhandledPromiseRejectionWarning: Error: Thrown from thisThrows()

比如上面这段代码就捕获不到抛出的异常,UnhandledPromiseRejectionWarning 警告只会在运行时才出现。Jake Archibald 解释过 await vs. return vs. return await 之间的微妙差异。CatchJS 团队则探讨了异步抛出异常的陷阱:它们是在另一个调用栈中抛出的——这意味着错误可能会传播到应用代码之外:

function fails3() {
    return new Promise((resolve, reject) => {
        setTimeout(function() {
            throw new Error();
        }, 100);
    });
}

async function myFunc3() {
    try {
        await fails3();
    } catch (e) {
        console.log("that failed", e); // <-- 永远不会被调用
    }
}

因此,JavaScript 的 onunhandledrejection 全局处理器对某些异常来说可能是错误的处理器,改名为 onPossiblyUnhandledRejection 也许更贴切——Q 和 Bluebird 这两个 Promise 库就是这么做的。CatchJS 团队指出:

处理 Promise 时,你无法知道一个错误将来某个时刻是否会被处理。Promise 可能调用了 reject(),而某段代码可能在 10 分钟后才对这个 Promise 调用 .catch(() => {}),这样一来错误就被处理了。

Zhang 提到 C# 中也存在类似问题:

static byte[] HttpGet(String url);
static async Task<Json> HttpGetJson(String url) {
  Task<Json> t = Task.Run(() => HttpGet(url));
  byte[] bytes = await t;
  return JsonParse(bytes);
}
static async Task Main() {
  Task<Json> t = HttpGetJson("xyz.org");
  ... // 做一些不依赖查询结果的事情
  Json json = await t; // 阻塞执行,直到查询完成
  ...
}

C# 编译器并不要求提供异常处理器就能运行上面的程序。然而,一旦异步查询真的产生异常,程序就会崩溃。Zhang 在相关论文中写道:

JavaScript 的情况更糟:异步抛出的异常如果没有被捕获,就会被悄无声息地吞掉。这类未处理的异常已被认定为 JavaScript 程序中的一种常见漏洞。

除了上述问题(未处理的效应、被意外处理的效应),生成器也有自身的缺陷。生成器允许生成器与其客户端之间的双向控制流,但不允许客户端在迭代过程中并发修改被迭代的数据。Zhang 详细描述了一些用例:

遍历优先队列的客户端可能想要修改某个已收到元素的优先级;类似地,遍历数据库记录流的客户端可能想从数据库中删除其中的某条记录。

在剖析了现有控制流构造的种种取舍之后,Zhang 给出了一个可能的解决方案:对代数效应的推广,称为双向代数效应。双向代数效应在允许双向控制流的同时,静态保证不存在未处理或被意外处理的效应。

代数效应支持为控制效应定义签名(signature),并由处理器(handler)实现这些签名。在这种模型下,含效应的代码抛出效应,效应沿动态调用栈向上传播到其处理器。生成器的功能可以用代数效应复刻如下(以链表节点迭代器为例):

// 效应签名
effect Yield[X] {
  def yield(X) : void
}

// 迭代器
class Node[X] {
  var head : X
  var tail : Node[X]
  ...
  def iter() : void raises Yield[X] {
    yield(head)
    if (tail != null)
      tail.iter()
  }
}

这个 Node 迭代器可以这样使用:

try { node.iter() }
with yield(x) {
  print(x)
  resume()
}

try... with 块把可能抛出效应的代码与处理效应的代码分离开来。效应声明和迭代器声明处的类型签名,则为这些构造提供了类型安全性。

双向效应更进一步:它让迭代器的客户端也能通过抛出 Zhang 所称的含效应的效应(effectful effects)来修改迭代器的状态。借助 effectful effects,效应处理器可以抛出后续效应,这些效应沿着与初始效应相反的方向传播回发起效应的位置,从而在程序片段之间传递信息与控制。

双向代数效应

上图展示了一个 Async 效应、一个 Exn(异常)效应和一个 Yield 效应,三者用于实现一个可以抛出异常的异步生成器。Yield 这个 effectful effect 可以被一个会抛出 Async 效应或异常的函数处理。图中展示了用这些效应实现的三方控制流:它是类型安全的,也不会出现 JavaScript 中那种未处理异常的情况。

Zhang 解释道:

我们可以让一个迭代器调度异步计算并产出(yield)Promise,这些 Promise 被 await 时又可以抛出异常,交给迭代器处理。底层的控制流很复杂,但依然可控,因为效应的静态检查会指引我们在何处施加效应处理。我们也可以把含效应的效应的签名,看作是在编排两个进程之间的通信。

在相关论文中,Zhang 证明了双向代数效应能保证所有声明的效应都被处理,且没有效应会被意外处理(例如被错误的处理器截获)。

Yizhou Zhang 教授的完整演讲可在线观看,其中包含更多细节、示例与图示。SPLASH 是一门关注编程语言应用的会议,涵盖软件构建与交付的方方面面。