简单接触 Algebraic Effects

Menu

TL;DR:简而言之,代数效应是一种机制,允许在不破坏函数式编程纯度的前提下,更加结构化和组合化地处理副作用。

引入

本学期的 Rust 课程项目大作业是写一个 git。总而言之,Git 有一个对象存储系统,允许将 TreeBlobCommit 作为三种不同的 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 又进一步的在顺序上而非结果上添加了新的顺序,先 foobar。这些与值无关,JS 引擎和 Promise 内部跑的循环将会执行这个顺序

或者我们这样说:then 允许我们把顺序组合起来,交给某个特定的无关的执行者。

Algebraic Effect

理解了 Monad 如何组合副作用之后,我们就可以理解 Algebraic Effect 了。

代数效应的核心思想是:我们可以把副作用进行传递和组合。让我们考察几种最常见的效应:

  • total: 没有任何效应对应数学意义上的全函数,总是能给定相同输入返回一个相同输出。
  • exception: (后面在 Koka 中叫 exn) 可能抛出异常
  • div: 可能不停机,也就是发散 (divergence)
  • ndet: 非决定性的函数,比如 random,对于相同输入可能返回多个不同的值
  • io: 这是最差的效应,这意味着程序可以引发异常、不终止、非确定性、读取和写入堆以及执行任何输入/输出作用。

exndiv 的组合是 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 的效应是 ndetdivexn

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 里面使用 getInfogetData 了:

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 里面使用异步的 getInfogetData,而在 test_run_biz 里面使用同步的 getInfogetData。这就是代数效应的魅力所在。

代数效应允许我们把副作用抽象成操作符和处理器,允许我们在不改变函数签名的情况下,使用不同的副作用实现。这样,我们就可以在测试中使用不同的副作用实现,而不需要修改函数本身。

现在回到最初的场景:代数作用如何解决我们之前提到的 WithRepo, fs, console 的问题呢?

我们可以把 fsconsole 抽象成代数效应:(下面的代码是伪代码,不是 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 变得可以充分的复用了起来。

Previous  Next

Loading...