跳到主要内容

架构-代数效应

代数效应有点类似像try/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.add(getName(user2));
user2.friendNames.add(getName(user1));
}

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

不同的是, catch在捕获到错误之后, 就无法回到原来的代码继续执行.

而代数效应, 可以"奇迹"般的回到原来的地方, 然后继续运行原来的代码.

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') {
resume with 'Arya Stark';
}
}

不像之前, 我们执行了一个'效应'. 就像我们可以throw任何值一样, 我们也可以传给preform任何值. 这个例子中, 传入的是一个字符串.

当我们perform一个效应的时候, 引擎会在调用推展中寻找最近的try/handle效应处理:

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

然后, 我们可以跳回到我们执行效应的地方, 并通过这个处理语句传回一些东西:

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灵活的多, 错误恢复只是其中一种用法而已.

没有颜色的函数

代数效应为异步代码提供了一些有趣的提示.

在实现了async/await的语言中, 函数常常是有颜色的. 举个例子, 在js中, 我们不能单独将getname 变成异步的, 而makeFriend以及其他的上层调用者不变成异步的. 他们都要变成async的.

// 如果我们要把它变成异步的...
async getName(user) {
// ...
}

// 那么这里也要变成异步的...
async function makeFriends(user1, user2) {
user1.friendNames.add(await getName(user2));
user2.friendNames.add(await getName(user1));
}

// 以此类推...

对于js的generators也是类似的. 但是对于我们刚才的例子来说, 却不需要那么做.

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);
}
}

在这个例子中, 我们会在1s后调用resume with. 你可以任务resume with是一个只能调用一次的回调, 也可以叫他单次限定延续(continuation)

现在, 代数的机制应该清晰一点了. 当我们throw一个错误的时候, js引擎释放堆栈, 销毁运行中的局部变量. 然而, 当我们perform一个效应, 我们假定的引擎会用余下的函数创建一个回调, 然后用resume with去调用它.

关于纯净

抛开函数式编程研究代数效应是没有意义的, 因为它们解决的一些问题是函数式编程中特有的.

举个例子来说, 在不允许产生副作用的语言中, 必须使用想monads这样的概念来将效应引入你的程序. 如果你读过monad的教程, 就会知道他们理解起来有点奇怪.

代数效应可以让你在写代码的时候关注你在做什么, 从而将whathow分离:

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) {
// 递归或调用其他函数时,也可使用 effects。
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或者Generators, 代数效应不会复杂化中间的函数(感染上层调用者).

关于类型

代数效应源于静态类型语言, 所以关于他们的大部分争论集中在他们类型表达的方式.

如果一个函数可以执行效应, 通常来说这回被编码进它的类型签名中. 所以你不能在一个随机效应正在发生的情况下结束, 否则你无法追踪他们的来源.

这样的话, 相当于在技术上代数效应也为函数"赋予了颜色", 因为在静态类型语言中效应是类型签名的一部分. 但是为了引入新效应而修复中间函数的类型注释, 这件事本身不是语义的的更改. 这个推断还可以帮助避免级联更改.

代数效应和JS

适合那些变化不常见,且标准库完全拥抱effect的语言.

代数效应和异步的区别

async function getName(user) {
let name = user.name;
if (name === null) {
name = await getDefaultNameFromServer();
}
return name;
}

async function makeFriends(user1, user2) {
user1.friendNames.push(await getName(user2));
user2.friendNames.push(await getName(user1));
}

const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };

makeFriends(arya, gendry)
.then(() => console.log('done!'));

异步性会感染所有的上层调用者

现在可以发现, makeFriends现在变成异步的了. 这是因为异步性会感染所有的上层调用者. 如果要将某个函数改成async函数, 是非常困难的, 因此它的所有上层调用者都需要修改

而在前面的Algebric Effects中, 中间调用者makeFriends对Algebraic Effect是完全无感的. 只要在某个上层调用者提供了effect handle就可以了.

可复用性的区别

getName直接耦合了副作用方法getDefaultNameFromServer. 而在前面的Algebraic Effects的例子中, 副作用的执行逻辑是"在运行时", "通过调用关系", "动态的"决定的. 这大大增强了getName的可复用性.

这里可以通过依赖注入的方式来达到相似的可复用性.

与Generator Functions的区别

Generator Function的调用者在调用Generator Function时也是有感的。Generator Function将程序控制权交给它的直接调用者,并且只能由直接调用者来恢复执行、提供结果值。

直接调用者也可以将程序控制权继续沿着执行栈继续向上交, 直到遇到能提供结果的调用者

function* getName(user) {
let name = user.name;
if (name === null) {
name = yield 'ask_name'; // perform an effect to get a default name!
}
return name;
}

function* makeFriends(user1, user2) {
user1.friendNames.push(yield* getName(user2));
user2.friendNames.push(yield* getName(user1));
}

async function main() {
const arya = { name: null, friendNames: [] };
const gendry = { name: 'Gendry', friendNames: [] };

let gen = makeFriends(arya, gendry);
let state = gen.next();
while(!state.done) {
if (state.value === 'ask_name') {
state = gen.next(await getDefaultNameFromServer());
}
}
}

reudx-saga就是用Generator Function, 将副作用的执行从saga中抽离, saga只需要向调用者发出副作用请求, 并将执行权交给调用者, 而不自己执行副作用.

// 这是一个saga
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: "USER_FETCH_SUCCEEDED", user: user});
} catch (e) {
yield put({type: "USER_FETCH_FAILED", message: e.message});
}
}

理论可以用generator function的控制权转移来实现Algebraic Effect. 但是无法避免感染调用者的问题, 无法向真正的Algebric Effects那样让调用者无感知.

你需要将所有的Functional Component、custom hooks都改造成generator function。并且generator function只能从上次yield的地方恢复执行,而不能恢复到更早的yield状态。

React 中的代数效应

js自己并不支持Algebraic Effect. 得益于React自己实现的Fiber模型, 可以提供捷径Algebric Effects的能力.

React Fiber架构:可控的"调用栈"

Suspend

Suspend就是一个例子, React在渲染的过程中遇到尚未就绪的数据时, 能够暂停渲染. 等到数据就绪的时候再继续.

// cache相关的API来自React团队正在开发的react-cache:
// https://github.com/facebook/react/tree/master/packages/react-cache
const cache = createCache();
const UserResource = createResource(fetchUser); // fetchUser is async

const User = (props) => {
const user = UserResource.read( // 用同步的方式来编写异步代码!
cache,
props.id
);
return <h3>{user.name}</h3>;
}

function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<User id={123} />
</Suspense>
</div>
);
}

react-cache是React团队正在开发的特性, 将Suspense用于数据获取的场景, 让需要等待数据的组件暂停渲染.

目前已经发布的React.lazy来暂停渲染的能力, 其实也是类似的原理

在心智模型中, UserResource.read可以看做发起了一个 Algebraic Effect. User发出这个effect以后, 控制权就暂时交给了React(React是User的调用者). React scheduler提供了对应的effect handler, 检查cache中是否有对应id的user:

  • 如果在cache中, 则立即将控制权交还给User, 并提供对应的user数据
  • 如果不在cache中, 则调用fetchUser从网络请求对应id的user, 在这个过程中, 渲染暂停, suspense渲染fallback视图. 得到结果以后, 将控制权交还给User, 并提供对应的数据.

在实际实现中, 他通过throw来模拟Algebraic Effect. 如果数据尚未准备好, UserResource.read会抛出一个特殊的Promise, 得益于React Fiber架构, 调用栈并不是React scheduler=>App=>User, 而是先React scheduler=>App, 然后React scheduler=>User.

因此User组件抛出的错误会被React scheduler接住, 然后将渲染暂停在User组件. 这意味着前面的App组件的工作不会丢失, 等到promise解析到数据以后, 将从User Fiber继续渲染. 继续渲染的方式: React scheduler从上次暂停的组件开始, 调用render进行渲染, 这次渲染的时候User组件能够用从cache立即拿到数据

参考链接