algebraic effect 推导(上)

2021-08-08

上一篇里面提到了,algebraic effect 一定是有某种可以直接转换成基本的 lambda calcalus 的方式的,只是我还不知道如何转。 读论文找答案一方面不确定我应该找哪一篇,另一方面好多论文有些太复杂了,读起来很头大。 今天静下来的时候,独自好好想了想这个转换过程,有点灵感,记录下来。

首先,algebraic effect 可以理解成一种特殊的 try catch,跟 try catch 不同的是,它是可以从 throw 的位置 recover 的。

(begin 1 2
	(throw 3)
	4 5 6)

由于可以 recover,catch 函数的形式,并不是只接受一个 error 参数,它还会接受一个 continuation 参数,以恢复之前的执行:

handler = (lambda (v k)
	...)

上面那段 begin 的代码,假如我们 throw 3 之后,如何恢复 4 5 6 的执行呢? 这个东西必须包装在一个函数里面变成连续才有可能。这个函数其实就是传给 handler 的连续 k,由 handler 去调用它以恢复执行。

(begin 1 2
	...
	(lambda (v)
	4 5 6))

然后我们换一个角度看 catch 的 handler 过程。它其实可以看成是一个回调。throw 的时候会让这个回调函数做一些处理,处理完之后,还是返回来:

(begin 1 2
	(handle 3 (lambda (v)
		4 5 6)))

所以我们把中间这一行不看,代码的形状就是这样子:

(begin 1 2
	...
	4 5 6)

这段代码会抛出一个 3,然后让 handler 把 3 处理一下,返回另一个值,然后代码继续执行下面的 4 5 6 的逻辑。 传入 3 传出另一个值的过程,就是副作用处理的过程。这个副作用怎么发生的现在不用管,从整个函数的角度,依然是一个纯的函数。

handler 做的事情,就是把传入的 3 转化成另一个值,再传出去。所以 handler 的定义是这样子:

(defun handler (v1 k)
	;; v2 = v1 + 1
	(k v2))

最上面的那一段展开之后,就是

(begin 1 2
	(handle 3 (lambda (v)
		4 5 6)))

这个写法不太好看,我们如果用 ==< 之类的给它做一下转换,易读一些,比如用 do notation 的形式:

(begin 1 2
	(do
		v (handle 3)
		4 5 6))

大概就相当于

(begin 1 2
	v = (handle 3)
	4 5 6)

上面这样子是不是就很好读了呢?对比一下完整的形式

(try (begin 1 2
			(throw 3)
			4 5 6)
	 (catch (v k)
		 (k (+ v 1))))

等价于另一种写法(相当于宏展开吧):

((lambda ( handle)
	(begin 1 2
		(do
			_ (handle 3)
			4 5 6))
 (lambda (v k)
	(k (+ v 1)))))

其实叫 handle 3 或者叫 throw 3 都是一个样子,只是从命名上,区分一个是直接抛出结果,还是从抛出的位置继续往下面执行。

这个过程已经推导出来了。其实就是几个点:

  • throw 之后的部分,全部打包成一个连续。
  • 这个连续会作为参数 k 传给 effect 的 handler。
  • throw 后面的本来是 lambda (k)...,用一个 do 的 monad 变换让代码读起来更友好

假设 handler 里面,不调用 k,而是直接返回,会怎么样? 效果就等价于普通异常了

(defun handler (v k)
	42)

相当于直接从 try 的那个 block 里面返回去了。

(begin 1 2
	(handle 3 (lambda (v)
		4 5 6)))

再看一下栈变化过程,跟 runtime 拷贝栈的方式做一下对比。

首先是在 try 的 block 的栈里面,

try ...
...

然后调用 handle,假设语言是实现了尾递归的,那么并不是

try ...
...
handle ...
...

而是直接在 try 的那个栈上恢复 handle:

handle ...
...

如果 handle 不调用 k,也就是等价于抛异常的场景,就直接返回了。而如果继续处理呢,就相当于

handle ...
k ...
...

也就是继续执行 try 的从 throw 之后的内容,这是正想要的效果。也就是说,这个变换并不需要特殊的 runtime 栈的支持,而达到的效果是一样的。 其中关键的点在于,连续的保存。如果是 runtime 栈,保存那个连接需要把栈拷贝一份出去,供将来恢复。 而在我们的推导变换过程中,这个连续 k 只是一个闭包,而已!

(lambda (v)
	4 5 6)

这个闭包接受了 handler 处理之后的值,就可以继续往下执行了。

algebraic effect 它的表达能力跟 delimited continuation 是等价的,可以实现异常,generator,async/await,协程等等基础设施。 现在我知道了怎么样直接转成 lambda calcalus,这将会非常有用。

又发现了一个问题,前面的场景想得太简单了,只是 handle effect 的某一种特殊形式。假设嵌套了多层函数,就不太一样了:

try (begin 1 2
	        (f ...)
			4 5 6)

f = (lambda (...)
	    (g ...))

g = (lambda ...
	   (throw 3))

f 调用 g,g 调用 h,然后在里面才做 throw,这里如果只能 g 里面 throw 之后部分提取成连续,那效果并不是回到 try 的那一层,在那个位置重新 recover。 它会从 g 做 throw 之后的地方 recover,再一级一级返回 g,返回 f,返回到 try 的调用位置,这显然跟 algebraic effect 的要求是对不上的。

跨函数调用栈之后,连续的处理就变了。需要用 cps 的形式处理:

(begin 1 2
       (f ... (lambda (v)
                  4 5 6))

f = (lambda (x .. k)
    	    (g .. k.))

g = (lambda (...k)
    	    (throw 3)
	    ...)

g = (lambda (...  k)
    	    (handle v1 (lambda (v2)
	    	           (k v2))))
(begin 1 2
       (do
	 v  (f ...)
	 4 5 6))

函数 f 必须是 cps 形式,函数 g 必须是 cps 形式,直到 throw 的位置。也就是从 try 开始的点,到中间的多层调用,都需要变成 cps 形式。这就跟 delimited continuation 一个样子了。 如果把前面一篇 continuation monad 跟这一篇结合起来,也不是不能做。

...就是仍然有点麻烦。需要自动地决定是否需要改写成 cps 形式,改写取决于某个函数有没有被 try throw 的调用栈路径上面使用过,如果是,就需要转换。

algebraic effect

HNS.to is a highly insecure way of browsing Handshake domains and should only be used for demo or educational purposes. Click to see preferable resolutions methods