overreactedby Dan Abramov

给我们这些普通人的代数效应

July 21, 2019

你听说过代数效应吗?

我一开始试图搞明白它们到底是什么、为什么值得关心,但并不顺利。我找到了一些PDF 资料,但只让我更加困惑。(学术PDF总有种让人犯困的魔力。)

不过,我的同事 Sebastian 经常提到 代数效应 作为 我们在 React 内部做某些事情时的思维模型。(Sebastian 是 React 团队成员,提出了很多点子,包括 Hooks 和 Suspense。)后来,这甚至成了 React 团队的一个内部笑话,很多讨论最后都以这样一句话收尾:

"Algebraic Effects" caption on the "Ancient Aliens" guy meme

事实证明,代数效应其实是个很酷的概念,远没有那些 PDF 看起来那么可怕。如果你只是用 React,完全不需要了解它们——但如果你像我一样好奇,不妨继续读下去。

(免责声明:我不是编程语言研究者,解释中可能有错误。我不是这方面的权威,如果有问题欢迎指正!)

还没准备好用于生产环境

代数效应 目前还是编程语言研究领域的特性。也就是说,if、函数,甚至 async / await 不同,你现在大概率还用不到它们。 目前只有极少数专门为探索这一思想而生的语言 支持它们。OCaml 社区正在推动将其用于生产,但进展……还在持续中。换句话说,你还碰不到它

补充:有些人提到 LISP 语言确实有类似机制,所以如果你用 LISP,是可以在生产环境用上的。

那我为什么要关心它?

假设你一直用 goto 写代码,突然有人给你展示了 iffor 语句。或者你深陷回调地狱,有人给你展示了 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);
}

我们在 getNamethrow 了一个异常,但它会一路“冒泡”穿过 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,而不用改动 getNamemakeFriends

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

Edit on GitHub