TL;DR:简而言之,代数效应是一种机制,允许在不破坏函数式编程纯度的前提下,更加结构化和组合化地处理副作用。
引入 ¶
本学期的 Rust 课程项目大作业是写一个 git。总而言之,Git 有一个对象存储系统,允许将 Tree
和 Blob
和 Commit
作为三种不同的 Object
存储在 .git/objects/[object hash]
这个文件中。
作为一个浸淫于实践需求中的人,你很容易看出来,git 项目的几乎所有东西都依赖实现计算好的 .git
文件夹的位置,所以我们可以抽象一个数据类型
pub struct Repository {
/// .git dir for the repository
pub root: PathBuf,
}
这很好。然后你发现,我们需要带着这个 Repository
的引用(指针)到处跑,所有需要从磁盘中读取和保存的场景我们全都需要 Repository
提供的 root
路径。一个不太熟练( 不太被腌入味了 )的人可能会写出
impl Object {
fn save(self, repo: Repository) -> io::Result<()> { ... }
fn load(repo: Repository) -> io::Result<Self> { ... }
}
这当然好!……但是有没有可能,到处都要显式传递 repo 这件事显得很繁琐很增加键盘输入量呢?
所以你脑袋一拍,我们构造一个数据结构吧,
pub struct WithRepo<'r, T> {
pub repo: &'r Repository,
inner: T,
}
impl<'r, T> WithRepo<'r, T> {
pub fn new(repo: &'r Repository, inner: T) -> Self {
WithRepo { repo, inner }
}
/// Wrap the storeable object with the repository path
pub fn wrap<To>(&self, inner: To) -> WithRepo<'r, To> {
WithRepo {
repo: self.repo,
inner,
}
}
/// unpack self, and get the inner object
pub fn unwrap(self) -> T {
self.inner
}
/// A `WithRepo<T>` is actually a functor, you can apply a T -> U function to `WithRepo<T>` to get` WithRepo<U>` by mapping it.
pub fn map<F, U>(self, f: F) -> WithRepo<'r, U>
where
F: FnOnce(T) -> U,
{
WithRepo {
repo: self.repo,
inner: f(self.inner),
}
}
}
WithRepo
是一个 Functor,它有一个 map 方法,在 Haskell 里是 fmap
,用来把一个 a -> b
的函数 apply 到 WithRepo<a>
上得到一个 WithRepo<b>
现在好多了,我们可以直接在 WithRepo<Object>
之类的东西上做文章,Repository
的指针在被创建的时候被包了进去作为依赖。我们可以写出这样优雅的代码:
let repo = Repository::load()?;
// stage: WithRepo<'_, MutableTree>
let mut stage = repo.stage()?.into_muter();
for path in &self.paths {
let path = env::current_dir()?.join(path);
stage.add_path(&path)?;
}
/// impl<'a> WithRepo<'a, MutableTree> pub fn freeze(self) -> WithRepo<'a, Tree>
stage.freeze().map(Stage).save()?;
repo
这个参数只在最开始被使用,之后的 stage 以及 WithRepo<'a, MutableTree>
这个东西的所有内部用到的东西都很自然地被提供了 repo
这个上下文,因此 save
不需要额外的参数指明 .git
所在文件夹的路径,直观而优雅。
问题出在 add_path
里面。add_path
在文件系统中找到路径,测试它是文件还是文件夹,如果是文件把它读取并存储为 Blob
,如果是文件夹把它 dump 成一个 Tree
并存储。
这是我们的代码。
pub fn add_file(&mut self, path: &Path) -> io::Result<&mut Self> {
let filename = path
.file_name()
.ok_or(io::Error::new(
io::ErrorKind::InvalidInput,
format!("file name is invalid: {}", path.display()),
))?
.to_string_lossy();
self.debug_util(path, "Adding file")?;
let ctnt = fs::read(path)?;
let blob = Object::Blob(
String::from_utf8(ctnt)
.map(|str| str.into())
.unwrap_or_else(|e| e.into_bytes().into()),
);
let blob = self.wrap(blob);
if self.save_object {
blob.save()?;
}
let line = TreeLine {
kind: TreeLineKind::File,
name: filename.to_string(),
sha1: blob.sha1().into(),
};
self.data.insert(filename.to_string(), line);
Ok(self)
}
问题所在是什么呢?它很难测试。当然你可以直接 fs::tmp_dir()
直接在系统里创一个临时文件夹,然后灌一些临时文件进去,但是这当然不够优雅。更何况,副作用 不止有 文件 IO 一种。还有控制台 IO。如果你要测试 git commit
这个命令,希望它产生一个正确的屏幕输出,怎么做到?只能将 println!
又换成自己的宏,也做参数传进去,为了保持完美的可测试性和可组合性,久而久之你的函数可能会变成这样:
impl GitAdd for WithRepo<WithFs<WithConsole<WithService1<WithService2<.....>>>>>
这完全就是地狱……于是人们发明了 DI : 依赖注入
但我们这里不是讲依赖注入,所以请看代数效应
Effect,Side Effect ¶
要讲 Algebraic Effect 让我们先讲讲 Haskell 的 IO Monad。写过 Haskell 的人都知道,Haskell 里面的 putStrLn 是
putStrLn :: String -> IO ()
你会注意到一个看上去很像 Rust 的 io::Result
的 Monad,也就是 IO
。但 Haskell 与 Rust 不一样的一个地方是,IO Monad 并不是“保存”了执行结果,而是“说明”了执行顺序。在 Rust 中,如果你写一个 fs::write(...)
得到了一个 io::Result<T>
,这个副作用真的已经被执行了,返回的只是成功与否;而 Haskell 中返回的是这个副作用本身。它可能在未来的某一个被某个 IO a -> a
的东西执行,这时候才真正执行了副作用;但在此之前它只是一个顺序。
对于一个完全没有什么函数式经验的人来说这句话可能很难理解。让我们引用一下 https://zhuanlan.zhihu.com/p/1892390136857228808 Haskell:优秀的过程式语言
副作用作为一等公民 (first class values)
在 Haskell 中,有副作用的计算被当作一等值。这意味着我们可以将它们存储在变量或数据结构中以备后用。有一个 Haskell 函数:
randomRIO :: (Int, Int) -> IO Int
当传入两个整数作为参数时,它会在这两个数之间随机选出一个整数。我们可以将对这个函数的调用放入一个列表中,像这样:
some_dice = [ randomRIO(1, 6), randomRIO(1, 6) ]
这是一个包含两次对
randomRIO
调用的列表。令非 Haskell 程序员感到惊讶的是,当这个列表被创建时,并不会生成任何随机数。来自其他编程语言的我们习惯于副作用(如随机生成)在调用带有副作用的函数时立即执行。[6]我们可以在列表中加入更多的随机生成操作:
more_dice = some_dice <> [ randomRIO(1, 6) ]
依然不会生成随机数。我们可以任意操作这个列表,依旧不会生成随机数。
需要明确的是,
randomRIO
函数确实可能会被调用[7]。而当它被调用时,它返回的值类型是IO Int
。只不过这个值并不是一个整数。如果要说的话,我们可以把它看作是一组最终能获得整数的指令。它并非实际的整数,而是一个封装了副作用的对象。当这个副作用对象执行时,会产生一个随机整数,但这个对象本身仅仅描述了计算过程,并不是一个整数。换句话说,在 Haskell 中,仅仅调用一个带有副作用的函数是不足以执行其副作用的。当我们调用一个带副作用的函数时,它产生了一个封装了副作用的对象,这个对象可以在未来某个时刻被执行,从而产生副作用的结果[8]。
如果它难以理解,不妨想象一下你在写一个前端项目。—— 是的,Promise
也(几乎)是一个 Monad。关于 Monad 是什么,可以看看我的 这篇文章
Promise 和 IO 的共同点是,它们都封装了一个执行顺序。有经验的前端开发者一眼就看得出来
fetch("https://example.com/example-api")
.then((res) => res.json())
.then((res) => {
balabala(res);
dosomething(res);
return fetch(foo(res));
})
.then((res) => {
bar(res);
});
Promise 提供了一套很好的接口,允许我们顺序地写出一些先后发生的事情,而无需关心回调的细节。在这里,所有的 .then
函数都不是马上执行的:它们只有在前一个 Promise fulfilled 的时候才会被自动地回调。因此,你可能会看到这样的代码:
function runDecorateResult(promise) {
return promise.then((res) => foo(res)).then((res) => bar(res));
}
const decorated = await runDecorateResult(
sendSomeApi().then((res) => baz(res))
);
这里,sendSomeApi().then((res) => baz(res))
创建了一个顺序,先 sendSomeApi
,再基于结果 baz
。然后 runDecorateResult
又进一步的在顺序上而非结果上添加了新的顺序,先 foo
再 bar
。这些与值无关,JS 引擎和 Promise 内部跑的循环将会执行这个顺序。
或者我们这样说:then
允许我们把顺序组合起来,交给某个特定的无关的执行者。
Algebraic Effect ¶
理解了 Monad 如何组合副作用之后,我们就可以理解 Algebraic Effect 了。
代数效应的核心思想是:我们可以把副作用进行传递和组合。让我们考察几种最常见的效应:
total
: 没有任何效应对应数学意义上的全函数,总是能给定相同输入返回一个相同输出。exception
: (后面在 Koka 中叫exn
) 可能抛出异常div
: 可能不停机,也就是发散 (divergence)ndet
: 非决定性的函数,比如random
,对于相同输入可能返回多个不同的值io
: 这是最差的效应,这意味着程序可以引发异常、不终止、非确定性、读取和写入堆以及执行任何输入/输出作用。
exn
和 div
的组合是 pure,直接对应于 Haskell 的纯度概念。一个函数可能具有多个效应。比如如下 Koka 代码:
// combine-effects: forall<a> () -> <pure,ndet> a
fun combine-effects()
val i = srandom-int() // non-deterministic
throw("oops") // exception raising
combine-effects() // and non-terminating
分配给 combine-effects
的效应是 ndet
、div
和 exn
。
algebraic effects 的做法是,我们把副作用抽象为 操作符(operations) 和 处理器(handlers) 两部分。以最经典的一种 effect 也就是异常为例:
effect raise
ctl raise( msg : string ) : a
这定义了一个 effect 类型 raise 和一个 (msg : string) -> raise a
类型的 operation raise
。声明 effect 的签名后,我们已经可以使用这些 operations 了:
fun safe-divide( x : int, y : int ) : raise int
if y==0 then raise("div-by-zero") else x / y
其中我们看到 safe-divide 函数获得了 raise 效应(因为我们在函数体内使用了 raise 操作符)。这样的效应类型意味着我们只能在处理 raise 的上下文中 evaluate 这个函数(换句话说,它是 “动态绑定”的,或者我们 “具有 raise 能力”) 的上下文。
我们可以通过为 raise 给出具体定义来处理这种效果。例如,我们可能总是返回一个默认值:
fun raise-const() : int
with handler
ctl raise(msg) 42 // 哦,宇宙的终极答案是 42
8 + safe-divide(1,0)
// 返回 42 而不是 50
调用 raise-const()
的计算结果为 42,不是 50。当调用 raise
时(在 safe-divide
中),它将 yield
于其最内层的 handler,展开堆栈,然后才 evaluate 操作符的定义 – 这个例子中,只是从定义 handler 的点直接返回 42。现在我们可以看到为什么它被称为 ctl operator
(控制操作符),因为 raise 改变了常规的线性控制流,并从原始调用点直接 yield 到其最内层的处理程序。还要注意 raise-const
现在又是一个 total
function 了,handler 抵消了 raise 的效果。
看到 effect 的写法后,我觉得你应该大体就能理解代数效应解决了什么了。
作者:Malcolm Yu
链接:https://www.zhihu.com/question/300095154/answer/1744221759
来源:知乎假如我们有这样一段代码,其主要目的是进行一大段精妙的运算:
async function biz(id) { const infoId = /* do some calc */ id; // 这里可以理解为是一大段计算逻辑 const info = await getInfo(infoId); // 副作用,与 server 通信 const dataId = /* do some calc */ info.dataId; // 这里可以理解为是一大段计算逻辑 const data = getData(dataId); // 副作用,非幂等操作 return /* do some calc */ data.finalCalcData; // 这里可以理解为是一大段计算逻辑 }
尽管运算逻辑很优美,但美中不足的是有两段副作用,导致它不能成为一个干净的纯函数被单元测试。而且这里会导致严重的逻辑耦合:『做什么』与『怎么做』没有拆的很干净:
- 你的一大段计算逻辑是在处理「做什么」;
- 两个副作用更关心「怎么做」:比如线上是接口调用,单测里是 mock 数据直接怼;
- 但是由于这两块副作用代码,导致整个糅杂的逻辑都无法复用。
看到这里你可能会一拍大腿:函数在 JS 里不是一等公民嘛,我直接把两个副作用传进来不就行了?
async function biz(id, getInfo, getData) { const infoId = /* do some calc */ id; // 这里可以理解为是一大段计算逻辑 const info = await getInfo(infoId); // 副作用,与 server 通信 const dataId = /* do some calc */ info.dataId; // 这里可以理解为是一大段计算逻辑 const data = getData(dataId); // 副作用,非幂等操作 return /* do some calc */ data.finalCalcData; // 这里可以理解为是一大段计算逻辑 }
是的,这样确实可以复用,但还有一个叫函数染色的问题没有解决:明明是一大段干净的同步运算逻辑,因为
getInfo
是异步的,导致整个函数都得加个async
。而且很有可能在我单元测试里,这个getInfo
是直接同步取内存数据,还得因此弄个Promise
……
没错,代数效应就能非常简单的解决这个问题。比如这个 biz,我们把它在 Koka 中写成
effect useInfoData
fun getInfo(id : int) : info
fun getData(id : int) : data
然后我们就可以在 biz
里面使用 getInfo
和 getData
了:
fun biz(id : int) : useInfoData data
val infoId = /* do some calc */ id; // 这里可以理解为是一大段计算逻辑
val info = getInfo(infoId); // 副作用,与 server 通信
val dataId = /* do some calc */ info.dataId; // 这里可以理解为是一大段计算逻辑
val data = getData(dataId); // 副作用,非幂等操作
/* do some calc */
data.finalCalcData // 这里可以理解为是一大段计算逻辑
你说 getInfo 是 async
的,那咋了?在代数效应里,这种函数染色可以直接被削去:
fun production_run_biz(id: int) : data
with handler
fun getInfo(id)
some_async_task(id) fn (res) { resume (res); }
fun getData(id)
some_sync_task(id)
biz(id)
fun test_run_biz(id: int) : data
with handler
fun getInfo(id)
some_sync_test_task(id)
fun getData(id)
some_sync_test_task(id)
biz(id)
这样,我们复用了一大段精妙的逻辑 biz
,而且没有函数染色的问题。我们可以在 production_run_biz
里面使用异步的 getInfo
和 getData
,而在 test_run_biz
里面使用同步的 getInfo
和 getData
。这就是代数效应的魅力所在。
代数效应允许我们把副作用抽象成操作符和处理器,允许我们在不改变函数签名的情况下,使用不同的副作用实现。这样,我们就可以在测试中使用不同的副作用实现,而不需要修改函数本身。
现在回到最初的场景:代数作用如何解决我们之前提到的 WithRepo
, fs
, console
的问题呢?
我们可以把 fs
和 console
抽象成代数效应:(下面的代码是伪代码,不是 Koka 真的能运行的程序)
effect fs
fun read(path : string) : bytes
fun write(path : string, data : bytes) : unit
fun remove(path : string) : unit
fun exists(path : string) : bool
....
effect console
fun log(msg : string) : unit
fun error(msg : string) : unit
fun warn(msg : string) : unit
fun info(msg : string) : unit
....
effect repo
val path : string
...
fun production-fs(action: () -> <fs|e> a) : e a
with handler
fun read(path) = std.fs.read(path)
fun write(path, data) = std.fs.write(path, data)
fun remove(path) = std.fs.remove(path)
fun exists(path) = std.fs.exists(path)
...
action()
fun test-fs(action: () -> <fs|e> a) : e a
with handler
fun read(path) = test.fs.read(path)
fun write(path, data) = test.fs.write(path, data)
fun remove(path) = test.fs.remove(path)
fun exists(path) = test.fs.exists(path)
...
action()
fun production-console(action: () -> <console|e> a) : e a
with handler
fun log(msg) = std.console.log(msg)
fun error(msg) = std.console.error(msg)
fun warn(msg) = std.console.warn(msg)
fun info(msg) = std.console.info(msg)
...
action()
fun test-console(action: () -> <console|e> a) : e a
with handler
fun log(msg) = test.console.log(msg)
fun error(msg) = test.console.error(msg)
fun warn(msg) = test.console.warn(msg)
fun info(msg) = test
...
action()
...
最终我们可以非常自然地组合出我们真正需要的 git_add 函数:
fun some_perfect_git_add_logic(files: List<string>) -> fs (console (repo unit))
var stage := repo.stage()
....
stage.save()
fun git_add()
with production-fs
with production-console
with production-repo
some_perfect_git_add_logic()
fun test_git_add()
with test-fs
with test-console
with test-repo
some_perfect_git_add_logic()
同时方便了 production 和 test,也让 git add 的 logic 变得可以充分的复用了起来。