代数效应:给普通人的解释

Hacker News Top 新闻

摘要

这是一篇教育性博客文章,通过类比 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`或将函数变成生成器。类型推断也有助于避免级联变化。一个重要的区别是,你可以通过提供无操作或模拟实现(例如,为异步效应提供同步调用)来“封装”一个效应,从而阻止它到达外层代码。

相似文章

使用延续抽象效果

Lobsters Hottest

本文演示了如何在Gleam编程语言中使用延续来抽象不同的计算效果(如错误处理和异步),从而实现可重用的业务逻辑。

Prism:一种带类型效应的非纯函数式语言

Lobsters Hottest

Prism 是一种新型函数式语言,它结合了代数效应与类型系统,允许在没有单子的情况下使用可变状态及其他效应,同时从外部保持纯函数性。其目标是让效应成为类型系统的一等公民,从而实现优化和安全使用。

Effectful 递归方案

Lobsters Hottest

《Effekt》编程语言博客文章演示了如何利用效应和处理器实现递归方案(特别是 catamorphisms),以此取代传统的基于函子的方法,从而避免了对无限递归类型的依赖。