给我们这些普通人的代数效应
July 21, 2019
你听说过代数效应吗?
我一开始试图搞明白它们到底是什么、为什么值得关心,但并不顺利。我找到了一些PDF 资料,但只让我更加困惑。(学术PDF总有种让人犯困的魔力。)
不过,我的同事 Sebastian 经常提到 代数效应 作为 我们在 React 内部做某些事情时的思维模型。(Sebastian 是 React 团队成员,提出了很多点子,包括 Hooks 和 Suspense。)后来,这甚至成了 React 团队的一个内部笑话,很多讨论最后都以这样一句话收尾:
事实证明,代数效应其实是个很酷的概念,远没有那些 PDF 看起来那么可怕。如果你只是用 React,完全不需要了解它们——但如果你像我一样好奇,不妨继续读下去。
(免责声明:我不是编程语言研究者,解释中可能有错误。我不是这方面的权威,如果有问题欢迎指正!)
还没准备好用于生产环境
代数效应 目前还是编程语言研究领域的特性。也就是说,和 if
、函数,甚至 async / await
不同,你现在大概率还用不到它们。 目前只有极少数专门为探索这一思想而生的语言 支持它们。OCaml 社区正在推动将其用于生产,但进展……还在持续中。换句话说,你还碰不到它。
补充:有些人提到 LISP 语言确实有类似机制,所以如果你用 LISP,是可以在生产环境用上的。
那我为什么要关心它?
假设你一直用 goto
写代码,突然有人给你展示了 if
和 for
语句。或者你深陷回调地狱,有人给你展示了 async / await
。是不是很酷?
如果你喜欢在某个编程思想流行之前提前几年了解它,也许现在正是对代数效应产生好奇的好时机。当然,你完全不必强迫自己了解它。这有点像 1999 年时去思考 async / await
。
好吧,代数效应到底是什么?
名字听起来有点吓人,其实思想很简单。如果你熟悉 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
让你不用担心每一层都手动传递错误,错误会自动向上传递。
这和代数效应有什么关系?
在上面的例子里,一旦出错,我们就无法继续执行了。到了 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 / catch
也变成了 try / handle
。具体语法不重要——这里只是为了说明思想。
那到底发生了什么?我们仔细看看。
我们不是抛出异常,而是执行一个效应。就像 throw
可以传递任意值,perform
也可以传递任意值。在这个例子里我传了个字符串,其实也可以是对象或其他类型:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
当我们 throw
一个异常时,JS 引擎会在调用栈上寻找最近的 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
灵活得多,可恢复的错误只是众多用例之一。**我之所以用这个例子,是因为它最容易理解。
函数不再有“颜色”
代数效应对异步代码有很有趣的影响。
在有 async / await
的语言里,函数通常有“颜色”。比如在 JavaScript 里,你不能只让 getName
变成异步,还得让 makeFriends
及其所有调用者都变成 async
。如果有些代码有时需要同步,有时需要异步,这会非常麻烦。
// 如果我们想让它变成异步……
async getName(user) {
// ...
}
// 那这个也得是 async……
async function makeFriends(user1, user2) {
user1.friendNames.push(await getName(user2));
user2.friendNames.push(await getName(user1));
}
// 依此类推……
JavaScript 的生成器也类似:只要用到生成器,中间所有函数都得知道它们是生成器。
那这和代数效应有什么关系?
我们暂时忘掉 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
理解为只能调用一次的回调。(你还可以用“单次限定续延” impress 你的朋友。)
现在,代数效应的机制应该更清晰了。当我们 throw
一个错误时,JS 引擎会“展开调用栈”,销毁局部变量。而当我们 perform
一个效应时,假想的引擎会创建一个回调,保存函数剩下的部分,然后 resume with
去调用它。
再次提醒:本文所有的语法和关键字都是为了说明思想而虚构的,不是重点,重点在于机制。
关于纯函数的一点说明
值得一提的是,代数效应起源于函数式编程研究。它们解决的一些问题在纯函数式编程语言中尤为突出。例如,在不允许任意副作用的语言(如 Haskell)里,你必须用 Monad 之类的概念把效应串联起来。如果你看过 Monad 教程,就知道那玩意儿确实难懂。代数效应能用更少的仪式感实现类似的效果。
这也是为什么很多关于代数效应的讨论我都看不懂。(我不懂 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();
});
});
});
因为没有“函数颜色”(中间代码无需关心效应),且效应处理器可组合(可以嵌套),你可以用它们创造非常强大的抽象。
关于类型的一点说明
由于代数效应起源于静态类型语言,很多讨论都集中在如何用类型表达它们。这当然很重要,但也让概念变得难以理解。所以本文完全没谈类型。不过一般来说,函数能否执行效应,通常会体现在类型签名里。这样你就不会遇到“莫名其妙发生了效应却找不到来源”的情况。
你可能会说,在静态类型语言里,代数效应其实也会“给函数上色”,因为效应是类型签名的一部分。没错。不过,给中间函数类型加个新效应注解本身并不改变语义——不像加 async
或变成生成器那样。而类型推断也能帮你避免连锁更改。更重要的是,你可以通过提供空实现或 mock(比如同步实现异步效应)“封装”一个效应,防止它传递到外层,或者把它转成另一个效应。
我们要不要把代数效应加进 JavaScript?
说实话,我不知道。它们很强大,也许对 JavaScript 这种语言来说甚至太强大了。
我觉得它们很适合那些很少有可变操作、标准库完全拥抱效应的语言。如果你主要写 perform Timeout(1000)
、perform Fetch('http://google.com')
、perform ReadFile('file.txt')
,而你的语言又有模式匹配和效应的静态类型,那应该会是很棒的编程体验。
也许这样的语言还能编译成 JavaScript!
这和 React 有什么关系?
其实关系不大。甚至可以说有点牵强。
如果你看过我关于 Time Slicing 和 Suspense 的演讲,第二部分涉及组件从缓存读取数据:
function MovieDetails({ id }) {
// 如果还在加载怎么办?
const movie = movieCache.read(id);
}
(演讲用的 API 稍有不同,但不影响理解。)
这依赖于 React 的一个叫“Suspense”的特性,目前正用于数据获取场景。关键在于,数据可能还没在 movieCache
里——这时我们必须做点什么,因为无法继续往下执行。技术上讲,这时 read()
会抛出一个 Promise(没错,抛出 Promise——好好体会下)。这会“挂起”执行。React 捕获这个 Promise,并在 Promise resolve 后重新渲染组件树。
这本身不是代数效应,尽管这个技巧受到了它的启发。但它实现了同样的目标:调用栈下层的代码可以把控制权交给上层(这里是 React),而中间所有函数都不必知道,也不会被 async
或生成器“污染”。当然,JS 里我们无法真正恢复执行,但对 React 来说,Promise resolve 后重新渲染组件树其实效果差不多。当你的编程模型假设幂等性时,这样“作弊”也没问题!
Hooks 也是一个让人联想到代数效应的例子。很多人第一反应会问:useState
怎么知道自己属于哪个组件?
function LikeButton() {
// useState 怎么知道自己在哪个组件里?
const [isLiked, setIsLiked] = useState(false);
}
我已经在这篇文章结尾解释过:React 对象上有个“当前 dispatcher”可变状态,指向你现在用的实现(比如 react-dom
里的)。还有个“当前组件”属性,指向 LikeButton
的内部数据结构。useState
就靠这个来工作。
很多人刚接触时觉得这有点“不干净”,原因很明显:依赖了共享可变状态。(顺便问一句:你觉得 JS 引擎里的 try / catch
是怎么实现的?)
不过,从概念上讲,你可以把 useState()
看作 perform State()
效应,由 React 在执行组件时处理。这就“解释”了为什么 React(调用你组件的那个东西)能为它提供状态(它在调用栈上层,可以提供效应处理器)。事实上,实现状态是代数效应教程里最常见的例子之一。
当然,这不是 React 实际 的实现方式,因为 JS 没有代数效应。实际上我们有个隐藏字段保存当前组件,还有个字段指向当前的 “dispatcher”,即 useState
的实现。作为性能优化,甚至有挂载和更新时不同的 useState 实现。但如果你眯着眼看这些代码,确实能把它们看作效应处理器。
总之,在 JS 里,抛出异常可以粗略模拟 IO 效应(只要代码可安全重执行,且不是 CPU 密集型),而用 try / finally
恢复可变的 “dispatcher” 字段,可以粗略模拟同步效应处理器。
你也可以用生成器实现更高保真的效应机制,但那意味着你得放弃 JS 函数的“透明性”,所有东西都要变成生成器。那就……嗯。
延伸阅读
对我个人来说,代数效应让我很惊喜地觉得“豁然开朗”。我一直搞不懂 Monad 这种抽象,但代数效应一下子就明白了。希望这篇文章也能帮你“开窍”。
我不知道它们会不会进入主流。如果到 2025 年还没被主流语言采纳,我会有点失望。五年后记得提醒我回来看!
我相信代数效应能做的事情远不止这些——但不亲自用这种方式写代码,很难体会它的威力。如果本文让你产生了兴趣,下面这些资源值得一看:
很多人还指出,如果不考虑类型(正如本文这样),其实 Common Lisp 的条件系统早就有类似机制。你也许会喜欢 James Long 的续延(continuation)文章,讲解了如何用 call/cc
原语在用户空间实现可恢复异常。
如果你发现了适合有 JS 背景读者的代数效应资料,欢迎在 Twitter 上告诉我!
Pay what you like