和我同乘时光机。
本文要点
你将收获
- 了解
Continuation
的概念。 - 了解
CPS
(Continuation Passing Style
)的概念与优势。 - 有机会搭乘
Call With Current Continuation
这台时光机。
所需基础
- 熟悉Haskell的Monad用法。
Continuation
什么是Continuation
为了了解何谓Continuation
,我们先来考虑这样一个表达式:4 * 3
如果把焦点放在3
上,在这里给出两种求值风格:
(4*) 3
($3) (4*)
它们的结果都是一样的:12
。
前者,3
被函数(4*)
调用。后者,3
选择让(4*)
调用自己。
这期间微妙的区别,便是CPS(Continuation Passing Style
)的来由。后者是典型的CPS,(4*)
就是3
的Continuation
。
从CPS
的角度看,($3)
要做的事情就是等待。它的类型是:(a -> r) -> r
,意味着,它需要一个函数作为参数,才能求出最后的结果。它所等待的函数,类型为a -> r
,被称为Continuation
,Continuation
指定了最终值应该如何被求得。
在实践中,我们可以简单地通过flip ($)
,将一个值转换成等待应用的形式。另外,可以通过传递id
函数作为其参数,把原始值返回。
Continuation的好处
Continuation
的内涵可不仅仅是这样简单地调换应用顺序,更重要的是,它带给了我们显式操控和动态选择程序控制流的可能。比如,实现程序的提前返回,异常的传递和处理可以通过Continuation
实现 —— 一个Continuation
用于处理正常情况,另一个Continuation
用于处理异常情况,以及实现简单的并发(Concurrency
)。
而且,当我们所有的函数都严格按照CPS
来编写,那么所有的函数调用都会是尾调用(Tail Call
)的形式。使用尾调用形式可以进行尾调用优化(TCO
, Tail Call Optimization
),使得程序不再需要运行时栈(Run-time Stack
),在现在的许多解释器和编译器中,都能找到这样一种技术。
实例
想要采用Continuation
的简单方法是修改所有函数,让函数返回待应用的形式。下面将演示两个实例。
pythagoras
|
|
将返回值修改成待应用形式,pythagoras
将变成:
|
|
pythagoras_cps
执行过程:
- 把
x
求平方,把结果丢到\x_squared -> ...
这个Continuation
里。 - 把
y
求平方,把结果丢到\y_squared -> ...
这个Continuation
里。 - 把
x_squared
和y_squared
加起来,把结果丢到k
这个Continuation
里。
在GHCi里运行一下,把id
作为参数/Continuation
传给最终程序:
从这里我们已经可以看出,CPS
这种风格最突出的特征是:所有函数调用都要把未来需要做的事情显式地传递给它。函数不止关注自己的值如何求出,还关注求出的值如何被使用。
thrice
|
|
|
|
像thrice
这样的高阶函数,转换成CPS
时,它接受的函数参数也要改成相应的CPS
版本。因此f :: a -> a
将变成f_cps :: a -> ((a -> r) -> r)
,最终类型变成thrice_cps :: (a -> ((a -> r) -> r)) -> a -> ((a -> r) -> r)
。
|
|
Cont Monad
有了这些continuation-passing
函数,下一步应该做的是提供一种更简洁的组合它们的方法,而不是像上面那样嵌套那么多lambda
。
先来尝试写出用于应用(apply
)待应用函数的组合子(Combinator
)。它的类型应该是这样的:
实现如下:
调用者首先提供一个待应用形式的值s
,通过嵌套的lambda
把a
类型的值拿到,让f
应用于它,最后再用新的Continuation
即k
把这一切包裹起来。
没错,相信聪明的读者已经看出来,上面的函数类型和Monad
里的(>>=)
函数类型相当相似。而且,flip ($)
也可以充当return
的角色。
一个新的Monad
诞生了。
我们可以定义Cont r a
来包裹这种待应用形式的值,再实现包装和解包函数:
Monad
的实现上面已经介绍过,不同的只是包装和解包:
Monad
实例把Continuation
的传递隐藏起来,拥有了Monad
绑定的种种好处,把上面的pythagoras
重写一下:
callCC
实现了可爱的Monad
固然是值得高兴的,可是别忘了我们想要CPS
的目的:用Continuation
精确操控程序控制流。如果把Continuation
都隐藏在Monad
背后,这种控制将不复存在。为了纠正这个问题,我们引入了callCC
函数。
callCC
是一个非常神奇的函数,举个例子:
传递给callCC
的是一个函数,这个函数的返回值是一个待应用形式的值(即类型为Cont r a
)。重点来了:让callCC
如此特殊的原因就在于k
,是k
让精确控制程序成为可能。
那么k
究竟是何方神圣?
事情是这样的:
程序调用callCC
的瞬间,在此处就放置了一个传送门
、或者说存档点
、或者说时光机
。业界所说的放置了坑
,我认为不甚准确。k
就是这个传送门
在callCC
内部的另一端。一旦内部有任何一个值调用了k
,k
就会无条件地把这个值传递回去,内部此后任何对k
的调用都将被无情抛弃。
从另一个角度来看,k
也可以说是调用callCC
以后的所有计算,一旦callCC
内部有值调用了k
,k
就将外面的整个世界传递给这个值,毕竟callCC
的全称是call with current continuation
。
随读者怎么理解,我更倾向于前者。
下面我们就来探索callCC
究竟可以有怎样的可能性。
决定何时使用k
callCC
允许我们决定让什么值传递回去,以及何时传递。下面举个例子:
|
|
如果y
的值大于20
,k
就会带着"over twenty"
回到Continuation
,否则,直到最终才让show $ y - 4
返回到Continuation
中。
从这里看,k
有点像命令式语言里的return
,但实际上k
远远强大于return
,因为它是头等公民,你可以把k
传递给其他函数,比如when
,把k
存储在Reader
里等等。
同样,你可以在do
里面嵌入callCC
:
|
|
从这里看,k
又有点像其他语言里的goto
,调用了k
就会返回到msg <- ...
这一行。
k
后面的无用的行:
这个函数会把5
返回到Continuation
中,return 25
是完完全全没有作用的。
舞台背后
如此神奇的callCC
究竟如何实现。经过上面的举例,我们可以得出callCC
的类型为:
总体返回应该和参数函数的返回类型一致(即Cont r a
),因为在没有调用k
的情况下,得出的返回值应该一致。
那么k
的类型呢?
正如上面所演示的,k
的参数将被传到calCC
调用点处。所以,k
的参数类型是a
。
不过k
的返回类型就有点意思了,b
代表着任意类型。因为上面提过,k
意味着跟在callCC
以后的任何Continuation
,所以k
的返回类型是任意的。
注意:
1234567 -- 有错误的代码quux :: Cont r Intquux = callCC $ \k -> dolet n = 5when True $ k nk 25
由于when
,k n
的类型已经被约束为Cont r ()
,因此后面的k 25
与quux
的返回类型不符合,应该改成return 25
。
实际实现:
慢慢看,慢慢理解。
更多实例
复杂控制结构
让我们来看看更现实的控制流操控的例子。
|
|
fun
是一个使用Cont
和callCC
建立起来的控制结构,接受不同的整数值n
,而落到不同范围,从而做出不同的事情。剖析一下:
(`runCont` id)
意味着把最终的计算结果Continuation
通过id
给拿出来。- 我们将
callCC
的结果绑定到str
中:- 如果
n
小于10,直接退出,只显示n
。 - 如果不是,继续进行。构造一个
list
,叫ns
,里面转载n `div` 2
的数字字符。 - 内部
callCC
,结果绑定到n'
:- 如果
length ns < 3
,带着length ns
从内部do-block
退出。 - 如果
n `div` 2
小于5个字符,带着n
从内部do-block
退出。 - 如果
n `div` 2
小于7个字符,带着一个String
直接退出到外部的callCC
。 - 否则,最终带着
n `div` 2
的sum
退出。
- 如果
- 最终带着字符串返回。
- 如果
- 最终带着
str
返回。
异常
Continuation
的一种用途是对异常进行建模。要做到这一点,我们要维护两个Continuation
,一个带我们到handler
处理函数以处理异常,一个在无异常的情况下带我们到处理后的代码。
这里有一个简单的函数,模拟除零异常:
从代码中可以看出,我们嵌套使用了两个callCC
,一个是将在没出问题时使用的Continuation
,一个是我们希望抛出异常时将使用到的Continuation
。如果分母不是0
,则ok
的Continuation
将直接返回到顶层,否则,err
将带着"Denominator 0"
交给handler
处理。
下面介绍更通用的异常处理方法。第一个参数传递计算方法(准确地说,这个计算会得到一个throw
函数,并在定义中决定是否使用)。另一个参数为错误处理程序。
|
|
将try
用到action
中:
在这个例子中,throw
意味着从封闭的callCC
中逃脱(escape
)了,这个throw
原本在tryCont
的内部callCC
中。
参考
柯里化的前生今世(八):尾调用与CPS
https://en.wikipedia.org/wiki/Continuation-passing_style#CPS_in_Haskell
https://en.m.wikibooks.org/wiki/Haskell/Continuation_passing_style#callCC