algebrai-effects-for-the-rest-of-us

注意, 这是一篇摘抄文, 仅记录一些概念跟规范

原文出处写给哪些搞不懂代数效应的我们(翻译)

你听说过代数效应吗?

我的第一次尝试理解大失败.我找了一些PDF, 但是学术论文就是具有某种令我犯困的魔力.

不过后来我的大佬哦同事塞巴斯蒂安(他搞出了ReactHooks)老是发表”啊其实我们在React里干的事就是代数效应”这样的言论,以至于有段时间代数效应已经变成了我们讨论React时的一个梗了.

事实上,代数效应是一个很coool的概念, 也没有哪些PDF上写得那么吓人. 如果你只是一个普通的Reaact用户,那什么都不用指导,用就行了;如果你是个像我一个的好奇宝宝,那就请读下去把.

尚未生产化

代数效应是一个学术概念. 所以说, 不像if, 函数, 甚至是async, 你现在还没办法在生成环境中用到它. 目前只有一些专门为了探索代数效应的玩具语言实现了它. OCmal还在努力把它生产化.不过有人说LISP已经提供了类似的功能,所以如果你用LISP那么你有机会在生产环境下试试看.

那我管它干什么

想象一下你正在用goto写程序, 这时候突然有个人给你介绍if和for. 或者你正在写回调地狱的时候有人给你看了一眼async/await. 超牛逼对不对?

如果你是一个喜欢思想领先于编程业界潮流的人, 现在开始理解代数效应再合适不过. 当然这并不是必须的;这就有点是在1999年尝试理解async.

好吧, 那么什么是代数效应?

这个名字逼格可能提高但是道理很简单. 如果你很熟悉try/catch块, 那么理解代数效应很容易.

我们先从try/catch为基础将起. 你有一个函数要抛出异常, 经过千山万水以后它另外一个函数接住了这个异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getName(user){
let name = user.name;
if (name == null) {
throw new Error('A girl has now name');
}
return name;
}

function makeFriends(user1, user2){
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}

const arya = {name: null};
const gendry = {name: 'Gendry'}
try {
makeFriends(arya, gendry);
}catch(err){
console.log("Ooops, that didn't work out: ", err);
}

我们在getName里抛出了异常, 穿过了makeFriends, 最后异常在它遇到的最近的catch块中被处理. 这是异常机制的一个重要特性, 中间的代码不需要关心有关异常处理的事.

不像C里的错误码机制, 用来try/catch, 你就不用手动将异常一层层地传递了. 它们会自动向上层传递, 不用担心会弄丢.

这和代数效应有什么关系呢?

在上面的例子中, 一旦我们一遇到一个错误, 我们就不能继续执行下去了. 我们会从catch块中继续执行, 我们任何机会回去执行出错以后的代码.

凉凉.我们也许可以在catch中修正错误并重试一次, 但是我们不能回到之前的那次执行中去了. 但是如果有 代数效应, 我们就可以这样做.

这是一个用虚构的javascript方言编写的例子(就叫它ES2025吧):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function getNmae(user){
let name = user.name;
if (name === null){
name = perform 'ask_name';
}
return name;
}

function makeFriends(user1, user2){
user1.friendNames.add(getNamee(user2));
user2.friendNames.add(getName(user1));
}

const arya = { name: null};
const gendry = { name: 'Gendry'};
try{
makeFriends(arya, gendry)
}handle(effect){
if (effect === 'ask_name') {
resume with 'Arya Stark'
}
}

我们这回没有用try, 而是使用了perform关键字. 同样, 我们虚构的try/handle替换了try/catch. 语法不重要, 重要的是背后的原理.

所以说发生了上面? 我们仔细研究一下.

这回我们没有抛出一个异常, 而是执行(perform)了一个效应(effect). 就像我们可以吧任何值使用throw抛出, 我们也可以用perform把它执行. 在这个例子中, 我传了一个字符串, 但是它也可以是对象或者别的数据
结构:

1
2
3
4
5
6
7
function getNmae(user){
let name = user.name;
if (name === null){
name = perform 'ask_name';
}
return name;
}

当我们抛出一个异常时, 运行引擎会寻找调用栈中最近的try/catch块处理异常. 同样, 当我们执行一个效应时, 引擎也会寻找最近的try/handle来处理效应

1
2
3
4
5
6
7
try{
makeFriends(arya, gendry);
}handle(effect){
if (effect === 'ask_name'){
resume with 'Arya Stark';
}
}

例子中的效应允许我们决定这么处理缺失的name.相比异常机制, 这里出现了一个很有趣的新概念: 虚构的resume with关键字

这就是异常机制做不到的部分了. 它允许我们跳回执行效应的代码继续执行, 并且还可以夹带一点私货.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 == 'ask_name'){
// 2. 我们被跳转到这里(就像try/catch)
if (effect == 'ask_name') {
// 3. 然而, 我们可以带着一个值继续执行(不想try/catch!)
return with 'Arya Stark'
}
}

想适应这个概念可能要花点功夫, 但是本质上着就是一个’可以继续执行的try/catch’

然而, 代数效应应远比try/catch要来的灵活, 可恢复异常只是其中一个使用场景. 我从这里开始讲只是因为这个例子讲起来最容易.

没有颜色的函数

代数效应对异步代码带来了很多的潜移默化.

如果一个语言有async/await关键字, 那么它的函数通常是有颜色的. 比如说, 如果在avascript中getname是一个异步函数, 那么它的调用者makefirend就没办法是同步的. 结果就是调用者以及调用者的调用者全都被async了. 如果一个函数又需要是同步的又需要是异步的, 情况就是变得很痛苦.

1
2
3
4
5
6
7
8
9
10
11
12
// 如果我们希望它是异步的...
async getNmae(user){
// ...
}

// 那么它也必须是异步的...
async function makeFriends(user1, user2){
user1.friendNames.add(await getName(user2));
user2.friendNames.add(await getName(user1));
}

// 下同...

JavaScript生成器也是差不多. 如果你最后会调用一个生成器, 那么整个调用链条就一定会受到这个生成器的影响.

所以这和我们又能扯上上面关系呢?

暂且忘掉async/await, 让我们回到之前的例子中来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function getName(user){
let name = user.name;
if (name === null){
name = perform 'ask_name';
}
return name;
}

function makefirends(user1, user2){
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}

const arya = {name: null};
const gendry = {name: "Gendry"}

try{
makeFriends(arya, gendry);
}handle(effect){
if (effect === 'ask_name') {
resume with 'Arya Stark'
}
}

如果我们的效应处理块不能同步地获取默认名字这么办, 比如说, 需要先从数据库里抓.

事实上, 我们什么都不用改, resume with 完成可以和异步代码一起用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}

function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}

const arya = { name: null };
const gendry = { name: 'Gendry' };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
setTimeout(() => {
resume with 'Arya Stark';
}, 1000);
}
}

在这个例子里, 我们等了一秒才执行resume with. 你可以把它想象成一个只能执行一次的回调函数.

现在代数效应的模型清楚多了. 当我们抛出一个异常的时候, 执行引擎会摧毁所有上层调用栈以及其中的局部变脸(回卷(unwind)); 但是当我们执行一个效应是, 虚构的引擎回创建一个回调函数, 这个回调函数的函数体是正在执行的函数体的剩余部分, 并且在我们执行resume with时之心这个回调函数.

在说一遍, 所有的语法都是编出来的. 语法不重要, 重要的是原理.

关于副作用

值得指出的一点, 代数效应是从函数式编程中脱胎的. 它所解决的一部分问题只在纯函数式编程中才存在. 比方说, 如果一个语言不允许任何种类的副作用(比如Haskell), 你就需要用Monad把副作用封起来. 如果你读过Monad教程你就会发现那玩意理解起来很麻烦. 代数效应可以解决类似的问题, 但是少了很多繁文缛节.

所以说, 很多关于代数效应的讨论我都不太理解(我对Haskell系语言并不熟). 但是, 哪怕在一门如JavaScript这样的有副作用的语言里, 我还是决定代数效应相当有用. 它可以把做什么和怎么做完全分离.

它可以让你写代码的时候先把注意力都放在(做什么)上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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');
}

然和再把上面的代码用怎么做包裹起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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`数组里现在有所有的文件了

这甚至意味着上面的函数可以被封装成代码库了:

1
2
3
4
5
6
7
8
9
10
11
12
import { withMyLoggingLibrary } from 'my-log';
import { withMyFileSystem } from 'my-fs';

function ourProgram() {
enumerateFiles('C:\\');
}

withMyLoggingLibrary(() => {
withMyFileSystem(() => {
ourProgram();
});
});

与async/await不同的是, 代数效应不会把中间代码搞负责. enumerateFiles可能位于outprogram地下相当深的调用链条中, 但是只要它的上方某处存在效应处理块, 我们的代码就能运行.

代数效应同样允许我们不用写太多脚手架代码就能把业务逻辑和实现它的效应的具体代码分离开.
比如说, 我们可以在测试中用一个伪造的文件系统和日志系统来代替上面的生产环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { withFakeFileSystem } from 'fake-fs';

function withLogSnapshot(fn) {
let logs = [];
try {
fn();
} handle (effect) {
if (effect instanceof Log) {
logs.push(effect.message);
resume;
}
}
// Snapshot emitted logs.
expect(logs).toMatchSnapshot();
}

test('my program', () => {
const fakeFiles = [/* ... */];
withFakeFileSystem(fakeFiles, () => {
withLogSnapshot(() => {
ourProgram();
});
});
});

意味这里没有颜色问题(夹在中间的代码不需要管代数效应), 并且代数效应是可组合的(你可以把它嵌套起来), 你可以用它创建表达能力超强的抽象.

关于类型

代数效应的概念是从静态类型语言中来的, 所以关于它的讨论很多都集中在效应可以被类型系统表达出来这一点上. 其重要性毋庸置疑, 但是易容它可能会阻碍对概念带来iie, 所以这篇文章完全没有提类型的事. 但是, 我还是应该提醒读者, 如果一个函数可以执行某种效应, 它的函数签名应该体现出这一点. 这样你就可以避免陷入”满地都是效应但不知道它们是从哪里来的”的困境.

你可能会说, 把效应嵌入函数签名相当于给函数染上了颜色. 没错, 但是, 给一个中间层的函数引入了新的效应, 这件事本身并不是一个语义层面的改动–不像给一个同步函数添加async使它变成异步函数那样. 例外, 自动推导也可以起到一定的帮助. 代数效应和其他颜色还有以恶重要的区别,你是可以把它封装在一定的边界范围内的, 比如说对于一个具有异步效应的函数, 只要在它的外面包裹一个异步效应的处理块就可以了. 所以在必要的时候, 用户有机会把效应对外额隐藏, 或者转换为另一种效应进行暴露.