代数效应:给普通人的解释
摘要
这是一篇教育性博客文章,通过类比 try/catch 和 async/await 来解释编程中的代数效应概念,并讨论了它们与 React 及未来编程范式的潜在关联。
暂无内容
查看缓存全文
缓存时间: 2026/05/30 07:24
# 代数效应:面向我们其余的人 — overreacted
来源:https://overreacted.io/algebraic-effects-for-the-rest-of-us/
你听说过*代数效应*吗?
我最初试图搞懂它们是什么或者我为什么要在意它们,但并不成功。我找到了几份 (https://www.eff-lang.org/handlers-tutorial.pdf) pdf (https://www.microsoft.com/en-us/research/wp-content/uploads/2016/08/algeff-tr-2016-v2.pdf),但它们只会让我更困惑。(学术 PDF 总有种让我昏昏欲睡的特质。)
但我的同事 Sebastian 一直 (https://mobile.twitter.com/sebmarkbage/status/763792452289343490) 引用 (https://mobile.twitter.com/sebmarkbage/status/776883429400915968) 它 (https://mobile.twitter.com/sebmarkbage/status/776840575207116800)们 (https://mobile.twitter.com/sebmarkbage/status/969279885276454912) 作为我们在 React 内部某些工作的心智模型。(Sebastian 在 React 团队工作,想出了不少点子,包括 Hooks 和 Suspense。)到后来,这成了 React 团队的一个老梗,我们很多对话的结尾都是:
“代数效应” 配上 “Ancient Aliens” 那哥们儿的表情包
结果发现,代数效应是个很酷的概念,并不像那些 PDF 让我感觉的那么可怕。**如果你只是用 React,你不需要知道任何关于它们的东西——但如果你像我一样好奇,那就往下读吧。**
*(免责声明:我不是编程语言研究者,解释中可能有些地方出错。我不是这方面的权威,如果错了请告诉我!)*
### 还没准备好投产 (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#not-production-ready-yet)
*代数效应*是一种编程语言研究的特性。这意味着**不同于`if`、函数,甚至`async / await`,你很可能还不能在生产环境中真正使用它们。** 它们只被少数几种 (https://www.eff-lang.org/) 语言 (https://www.microsoft.com/en-us/research/project/koka/) 支持,这些语言是专门为了探索这个想法而创建的。在 OCaml 中将其产品化的工作仍在进行 (https://github.com/ocaml-multicore/ocaml-multicore/wiki) 中。换句话说,现在还碰不得 (https://www.youtube.com/watch?v=otCpCn0l4Wo)。
> 编辑:有几位提到 LISP 语言确实提供了类似 (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#learn-more) 的东西,所以如果你写 LISP,是可以在生产环境使用的。
### 那我为什么要关心? (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#so-why-should-i-care)
想象一下,你正用`goto`写代码,然后有人给你展示了`if`和`for`语句。或者你深陷回调地狱,然后有人给你展示了`async / await`。挺酷的,是吧?
如果你是那种喜欢在某个编程想法流行前几年就了解它的人,那么现在也许是时候对代数效应产生好奇了。不要觉得你*必须*去了解。这有点像在 1999 年思考`async / await`。
### 好吧,那代数效应是什么? (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#okay-what-are-algebraic-effects)
这个名字可能有点吓人,但想法很简单。如果你熟悉`try / catch`块,你会很快理解代数效应。
我们先回顾一下`try / catch`。假设有一个函数会抛出错误。可能它和`catch`块之间隔着一串函数:
``
function getName(user) {
let name = user.name;
if (name === null) {
throw new Error('A girl has no name');
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.push(getName(user2));
user2.friendNames.push(getName(user1));
}
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
try {
makeFriends(arya, gendry);
} catch (err) {
console.log("Oops, that didn't work out: ", err);
}
``
我们在`getName`里`throw`,但错误“冒泡”直穿`makeFriends`到达最近的`catch`块。这是`try / catch`的一个重要特性。**中间的函数不需要关心错误处理。**
不像 C 语言中的错误码,用`try / catch`,你不用手动把错误传过每一层中间函数,生怕丢失它们。它们会自动传播。
### 这跟代数效应有什么关系? (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#what-does-this-have-to-do-with-algebraic-effects)
在上面的例子中,一旦我们遇到错误,就无法继续了。当我们进入`catch`块时,没办法再继续执行原来的代码。
我们完了。太晚了。我们能做的最多是失败后恢复,也许尝试重试刚才做的事,但你不能神奇地“回到”之前的地方,然后做点别的。**但有了代数效应,*我们可以*。**
下面是一个用假设的 JavaScript 方言(为了好玩我们叫它 ES2025)写的例子,它允许我们从缺少的`user.name`中*恢复*:
``
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.push(getName(user2));
user2.friendNames.push(getName(user1));
}
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
``
*(我向所有在 2025 年搜索“ES2025”并找到这篇文章的读者道歉。如果到那时代数效应已经成了 JavaScript 的一部分,我很乐意更新它!)*
我们没用`throw`,而是用假设的关键字`perform`。同样,我们用了假设的`try / handle`代替`try / catch`。**具体的语法在这里不重要——我只是为了说明想法而编了些东西。**
那么发生了什么呢?让我们仔细看看。
我们不抛出错误,而是*执行一个效应*。就像我们可以`throw`任何值一样,我们也可以向`perform`传递任何值。在这个例子中,我传了一个字符串,但也可以是一个对象或任何其他数据类型:
``
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
``
当我们`throw`一个错误时,引擎会沿着调用栈向上寻找最近的`try / catch`错误处理器。类似地,当我们`perform`一个效应时,引擎会沿着调用栈寻找最近的`try / handle`*效应处理器*:
``
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
``
这个效应让我们决定如何处理名字缺失的情况。这里与异常的新奇之处就是假设的`resume with`:
``
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
``
这是你用`try / catch`做不到的部分。它让我们**跳回到执行效应的位置,并从处理器那里传回一个值**。🤯
``
function getName(user) {
let name = user.name;
if (name === null) {
// 1. 我们在这里执行一个效应
name = perform 'ask_name';
// 4. ...然后回到这里(name 现在是 'Arya Stark')
}
return name;
}
// ...
try {
makeFriends(arya, gendry);
} handle (effect) {
// 2. 我们跳到处理器(类似 try/catch)
if (effect === 'ask_name') {
// 3. 然而,我们可以用一个值恢复(不同于 try/catch!)
resume with 'Arya Stark';
}
}
``
这需要花点时间习惯,但从概念上讲,它跟“可恢复的`try / catch`”真的没有太大区别。
不过,请注意**代数效应比`try / catch`灵活得多,可恢复的错误只是众多可能用例之一。** 我先从这个讲起,只是因为我觉得这样最容易理解。
### 函数没有颜色 (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#a-function-has-no-color)
代数效应对异步代码有着有趣的影响。
在使用`async / await`的语言中,函数通常有“颜色” (https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/)。例如,在 JavaScript 中,我们不能仅仅让`getName`变成异步,而不“感染”`makeFriends`以及它的调用者,使它们也变成`async`。如果*一段代码有时需要同步,有时需要异步*,这就会非常痛苦。
``
// 如果我们想让它变成异步...
async getName(user) {
// ...
}
// 那么这也必须变成异步...
async function makeFriends(user1, user2) {
user1.friendNames.push(await getName(user2));
user2.friendNames.push(await getName(user1));
}
// 以此类推...
``
JavaScript 的生成器也是类似 (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*):如果你在处理生成器,中间的函数也必须知道生成器。
那么这跟代数效应有什么关系呢?
我们先暂时忘记`async / await`,回到我们的例子:
``
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.push(getName(user2));
user2.friendNames.push(getName(user1));
}
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
``
如果我们的效应处理器不能同步地知道“后备名字”呢?如果我们需要从数据库获取它呢?
结果,我们可以从效应处理器异步地调用`resume with`,而无需对`getName`或`makeFriends`做任何修改:
``
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.push(getName(user2));
user2.friendNames.push(getName(user1));
}
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
setTimeout(() => {
resume with 'Arya Stark';
}, 1000);
}
}
``
在这个例子中,我们一秒后才调用`resume with`。你可以把`resume with`看作一个只能调用一次的回调。(你也可以用“一次性分隔的 continuation”来给朋友留下深刻印象。)
现在代数效应的机制应该更清晰了。当我们`throw`一个错误时,JavaScript 引擎会“展开堆栈”,在这个过程中销毁局部变量。然而,当我们`perform`一个效应时,我们的假设引擎会*创建一个回调*,包含函数的剩余部分,然后`resume with`调用这个回调。
**再次提醒:本文中的具体语法和特定关键字都是虚构的。它们不是重点,重点在于机制。**
### 关于纯度的注记 (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#a-note-on-purity)
值得指出,代数效应来源于函数式编程研究。它们解决的一些问题是纯函数式编程所特有的。例如,在*不*允许任意副作用的语言(如 Haskell)中,你必须使用像 Monad 这样的概念来将效应贯穿整个程序。如果你读过 Monad 教程,你知道它们有点难以理解。代数效应有助于以更少的仪式感完成类似的事情。
这就是为什么很多关于代数效应的讨论对我来说难以理解。(我不懂 (https://overreacted.io/things-i-dont-know-as-of-2018/) Haskell 及其同类。)但我确实认为,即使在像 JavaScript 这样不纯的语言中,**代数效应也可以成为将代码中的*做什么*与*怎么做*分离的一个非常强大的工具。**
它们让你编写专注于*做什么*的代码:
``
function enumerateFiles(dir) {
const contents = perform OpenDirectory(dir);
perform Log('Enumerating files in ', dir);
for (let file of contents.files) {
perform HandleFile(file);
}
perform Log('Enumerating subdirectories in ', dir);
for (let directory of contents.dir) {
// 我们可以递归或调用带有效应的其他函数
enumerateFiles(directory);
}
perform Log('Done');
}
``
然后将其包裹在指定*怎么做*的代码中:
``
let files = [];
try {
enumerateFiles('C:\\');
} handle (effect) {
if (effect instanceof Log) {
myLoggingLibrary.log(effect.message);
resume;
} else if (effect instanceof OpenDirectory) {
myFileSystemImpl.openDir(effect.dirName, (contents) => {
resume with contents;
});
} else if (effect instanceof HandleFile) {
files.push(effect.fileName);
resume;
}
}
// `files` 数组现在包含了所有文件
``
这意味着这些部分甚至可以做成库:
``
import { withMyLoggingLibrary } from 'my-log';
import { withMyFileSystem } from 'my-fs';
function ourProgram() {
enumerateFiles('C:\\');
}
withMyLoggingLibrary(() => {
withMyFileSystem(() => {
ourProgram();
});
});
``
不同于`async / await`或生成器,**代数效应不需要让“中间”的函数变得复杂**。我们的`enumerateFiles`调用可能深埋在`ourProgram`内部,但只要*在某个上层*存在一个效应处理器来处理它可能执行的每个效应,我们的代码就能正常工作。
效应处理器让我们可以将程序逻辑与其具体的效应实现解耦,而不需要太多仪式或样板代码。例如,我们可以在测试中完全覆盖其行为,使用虚拟文件系统并快照日志而不是输出到控制台:
``
import { withFakeFileSystem } from 'fake-fs';
function withLogSnapshot(fn) {
let logs = [];
try {
fn();
} handle (effect) {
if (effect instanceof Log) {
logs.push(effect.message);
resume;
}
}
// 快照输出的日志。
expect(logs).toMatchSnapshot();
}
test('my program', () => {
const fakeFiles = [/* ... */];
withFakeFileSystem(fakeFiles, () => {
withLogSnapshot(() => {
ourProgram();
});
});
});
``
因为没有“函数颜色” (https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/)(中间代码不需要知道效应),而且效应处理器是*可组合的*(你可以嵌套它们),你可以用它们创建非常具有表现力的抽象。
### 关于类型的注记 (https://overreacted.io/algebraic-effects-for-the-rest-of-us/#a-note-on-types)
因为代数效应来自于静态类型语言,关于它们的很多讨论都集中在如何在类型中表达它们。这无疑很重要,但也可能增加理解概念的难度。这就是为什么本文完全不谈类型。不过,我应该指出,通常一个函数能否执行某个效应会被编码到它的类型签名中。所以你不应该遇到随机效应发生而无法追踪来源的情况。
你可能会争辩说,在静态类型语言中,代数效应在技术上确实会“给函数涂上颜色” (https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/),因为效应是类型签名的一部分。没错。但是,为中间函数添加一个包含新效应的类型注解,本身并不是一个语义变化——不像添加`async`或将函数变成生成器。类型推断也有助于避免级联变化。一个重要的区别是,你可以通过提供无操作或模拟实现(例如,为异步效应提供同步调用)来“封装”一个效应,从而阻止它到达外层代码。
相似文章
使用延续抽象效果
本文演示了如何在Gleam编程语言中使用延续来抽象不同的计算效果(如错误处理和异步),从而实现可重用的业务逻辑。
Prism:一种带类型效应的非纯函数式语言
Prism 是一种新型函数式语言,它结合了代数效应与类型系统,允许在没有单子的情况下使用可变状态及其他效应,同时从外部保持纯函数性。其目标是让效应成为类型系统的一等公民,从而实现优化和安全使用。
Effectful 递归方案
《Effekt》编程语言博客文章演示了如何利用效应和处理器实现递归方案(特别是 catamorphisms),以此取代传统的基于函子的方法,从而避免了对无限递归类型的依赖。
Show HN: Pure Effect – 无需数据库,在笔记本上复现生产环境bug
Pure Effect 是一个零依赖的 JavaScript/TypeScript 效应库,通过将副作用表示为纯数据来分离业务逻辑与 I/O,无需数据库即可复现生产环境 bug。
Xavier Leroy关于编程中控制结构的新书
Xavier Leroy宣布了一本关于编程语言中控制结构的新书,涵盖从goto到代数效应的内容,提供CC许可下的免费预览。