跳转到内容

Haskell/继续传递风格

来自 Wikibooks,开放世界中的开放书籍

继续传递风格(简称 CPS)是一种编程风格,其中函数不返回值;相反,它们将控制权传递给一个延续,它指定接下来会发生什么。在本章中,我们将讨论它如何在 Haskell 中发挥作用,特别是如何用单子表达 CPS。

什么是延续?

[编辑 | 编辑源代码]

为了消除困惑,我们将再次回顾本书中早期的示例,当我们介绍 ($) 运算符时

> map ($ 2) [(2*), (4*), (8*)]
[4,8,16]

上面的表达式没有什么不寻常之处,除了写成这样而不是 map (*2) [2, 4, 8] 有点奇怪。($) 部分使代码看起来反向,就好像我们正在将值应用于函数,而不是相反。现在,重点来了:这种看起来无害的颠倒其实是继续传递风格的本质!

从 CPS 的角度来看,($ 2) 是一个挂起的计算:一个具有通用类型 (a -> r) -> r 的函数,它接收另一个函数作为参数,并产生最终结果。a -> r 参数是延续;它指定了计算将如何结束。在本例中,列表中的函数通过 map 作为延续提供,产生三个不同的结果。请注意,挂起的计算在很大程度上可以与普通值互换:flip ($) [1] 将任何值转换为挂起的计算,并将 id 作为其延续传递会返回原始值。

它们有什么用?

[编辑 | 编辑源代码]

延续不仅仅是用来给 Haskell 新手留下深刻印象的把戏。它们使我们能够显式地操作,并戏剧性地改变程序的控制流程。例如,可以使用延续从过程提前返回。异常和失败也可以使用延续来处理——传入一个成功延续,另一个失败延续,并调用适当的延续。其他可能性包括“挂起”计算并稍后再返回,以及实现简单的并发形式(值得注意的是,一个 Haskell 实现 Hugs 使用延续来实现协作并发)。

在 Haskell 中,延续可以用类似的方式,在单子中实现有趣的控制流。请注意,通常有针对这些用例的替代技术,尤其是在与惰性结合使用时。在某些情况下,CPS 可以通过消除某些构造-模式匹配序列(即函数返回一个复杂结构,调用者将在某个时候对其进行解构)来提高性能,尽管一个足够智能的编译器应该能够执行消除 [2]

传递延续

[编辑 | 编辑源代码]

利用延续的一个基本方法是修改我们的函数,使它们返回挂起的计算而不是普通值。我们将通过两个简单的示例来说明如何做到这一点。

毕达哥拉斯

[编辑 | 编辑源代码]

示例:一个简单的模块,没有延续

-- We assume some primitives add and square for the example:

add :: Int -> Int -> Int
add x y = x + y

square :: Int -> Int
square x = x * x

pythagoras :: Int -> Int -> Int
pythagoras x y = add (square x) (square y)

修改为返回挂起的计算,pythagoras 看起来像这样

示例:一个简单的模块,使用延续

-- We assume CPS versions of the add and square primitives,
-- (note: the actual definitions of add_cps and square_cps are not
-- in CPS form, they just have the correct type)

add_cps :: Int -> Int -> ((Int -> r) -> r)
add_cps x y = \k -> k (add x y)

square_cps :: Int -> ((Int -> r) -> r)
square_cps x = \k -> k (square x)

pythagoras_cps :: Int -> Int -> ((Int -> r) -> r)
pythagoras_cps x y = \k ->
 square_cps x $ \x_squared ->
 square_cps y $ \y_squared ->
 add_cps x_squared y_squared $ k

pythagoras_cps 示例是如何工作的

  1. 对 x 平方并将结果抛入 (\x_squared -> ...) 延续
  2. 对 y 平方并将结果抛入 (\y_squared -> ...) 延续
  3. 将 x_squared 和 y_squared 相加并将结果抛入顶级/程序延续 k

我们可以通过将 print 作为程序延续在 GHCi 中尝试它

*Main> pythagoras_cps 3 4 print
25

如果我们查看 pythagoras_cps 的类型,没有可选的括号围绕 (Int -> r) -> r,并将它与 pythagoras 的原始类型进行比较,我们会注意到延续实际上是作为额外参数添加的,从而证明了“继续传递风格”的名称。

示例: 一个简单的更高阶函数,没有延续

thrice :: (a -> a) -> a -> a
thrice f x = f (f (f x))
*Main> thrice tail "foobar"
"bar"

一个更高阶函数,比如 thrice,在转换为 CPS 后,也会接收以 CPS 形式存在的函数作为参数。因此,f :: a -> a 会变成 f_cps :: a -> ((a -> r) -> r),最终类型会是 thrice_cps :: (a -> ((a -> r) -> r)) -> a -> ((a -> r) -> r)。定义的其余部分自然而然地遵循类型 - 我们将 f 替换为 CPS 版本,并将当前延续传递给它。

示例: 一个简单的更高阶函数,使用延续

thrice_cps :: (a -> ((a -> r) -> r)) -> a -> ((a -> r) -> r)
thrice_cps f_cps x = \k ->
 f_cps x $ \fx ->
 f_cps fx $ \ffx ->
 f_cps ffx $ k


Cont 函子

[编辑 | 编辑源代码]

有了延续传递函数,下一步就是提供一种整洁的方式来组合它们,最好是不用我们上面看到的长长的嵌套 lambda 链。一个好的开始是使用一个组合子将 CPS 函数应用于一个挂起的计算。它可能会有以下类型

chainCPS :: ((a -> r) -> r) -> (a -> ((b -> r) -> r)) -> ((b -> r) -> r)

(你可能想在继续阅读之前尝试实现它。提示:从声明结果是一个接受 b -> r 延续的函数开始;然后,让类型来指导你。)

以下是实现代码

chainCPS s f = \k -> s $ \x -> f x $ k

我们为原始的挂起计算 s 提供了一个延续,这个延续会创建一个新的挂起计算(由 f 生成)并将最终延续 k 传递给它。不出所料,它与前面示例中嵌套 lambda 模式非常相似。

chainCPS 的类型看起来是不是很熟悉?如果我们用 (Monad m) => m a 替换 (a -> r) -> r,用 (Monad m) => m b 替换 (b -> r) -> r,就会得到 (>>=) 的签名。此外,我们老朋友 flip ($) 在某种程度上扮演着 return 的角色,因为它能以一种平凡的方式将一个值变成一个挂起的计算。瞧,我们得到了一个函子!现在我们唯一需要的是 [3]一个 Cont r a 类型来包装挂起的计算,以及常用的包装和解包函数。

cont :: ((a -> r) -> r) -> Cont r a
runCont :: Cont r a -> (a -> r) -> r

Cont 的函子实例直接源于我们的描述,唯一的区别是包装和解包的代码

instance Monad (Cont r) where
    return x = cont ($ x)
    s >>= f  = cont $ \c -> runCont s $ \x -> runCont (f x) c

最终结果是,函子实例使延续传递(以及 lambda 链)隐式化。函子绑定将 CPS 函数应用于一个挂起的计算,而 runCont 用于提供最终的延续。举个简单的例子,勾股定理示例变成了

示例: 使用 Cont 函子的 pythagoras 示例

-- Using the Cont monad from the transformers package.
import Control.Monad.Trans.Cont

add_cont :: Int -> Int -> Cont r Int
add_cont x y = return (add x y)

square_cont :: Int -> Cont r Int
square_cont x = return (square x)

pythagoras_cont :: Int -> Int -> Cont r Int
pythagoras_cont x y = do
    x_squared <- square_cont x
    y_squared <- square_cont y
    add_cont x_squared y_squared

虽然看到一个函子自然而然地出现总是令人愉悦,但可能还是会有点失望。CPS 的承诺之一是通过延续精确地控制流程操作。然而,在将函数转换为 CPS 后,我们立即将延续隐藏在函子后面。为了纠正这一点,我们将介绍 callCC,一个让我们能够明确控制延续的函数——但只在我们想要的地方。

callCC 是一个非常特殊的函数;最好用例子来介绍。让我们从一个简单的例子开始

示例: 使用 callCCsquare

-- Without callCC
square :: Int -> Cont r Int
square n = return (n ^ 2)

-- With callCC
squareCCC :: Int -> Cont r Int
squareCCC n = callCC $ \k -> k (n ^ 2)

传递给 callCC 的参数是一个函数,它的结果是一个挂起的计算(通用类型 Cont r a),我们将它称为“callCC 计算”。原则上callCC 计算就是整个 callCC 表达式求值的结果。需要注意的是,也是使 callCC 如此特殊的原因是由于 k,它是参数的另一个参数。它是一个函数,充当一个弹出按钮:在任何地方调用它都会导致传递给它的值变成一个挂起的计算,然后这个计算会被插入到调用 callCC 的位置的控制流程中。这是无条件发生的;特别是,callCC 计算中调用 k 之后的所有内容都会被立即丢弃。从另一个角度来看,k 会捕获调用 callCC 之后的所有剩余计算;调用它会将一个值抛到那个特定点(“callCC” 代表“调用当前延续”)的延续中。虽然在这个简单的例子中,效果仅仅是像一个普通的 return 一样,但 callCC 开启了许多可能性,我们现在将要探索。

决定何时使用 k

[编辑 | 编辑源代码]

callCC 使我们能够对抛到延续中的内容以及何时抛出这些内容具有额外的控制权。以下示例开始展示如何使用这种额外的控制权。

示例: 我们第一个真正的 callCC 函数

foo :: Int -> Cont r String
foo x = callCC $ \k -> do
    let y = x ^ 2 + 3
    when (y > 20) $ k "over twenty"
    return (show $ y - 4)

foo 是一个稍微病态的函数,它计算输入的平方并加 3;如果这个计算的结果大于 20,那么我们会立即从 callCC 计算(以及在这种情况下,从整个函数)中返回,并将字符串 "over twenty" 抛到传递给 foo 的延续中。如果不是这样,那么我们就会从之前的计算结果中减去 4,将它 show 出来,并把它抛到延续中。值得注意的是,这里的 k 的使用方式就像一个命令式语言中的 'return'语句一样,会立即退出函数。然而,由于这是 Haskell,所以 k 只是一个普通的头等函数,因此你可以将它传递给其他函数,比如 when,将其存储在一个 Reader 中,等等。

当然,你可以在 do 块中嵌入对 callCC 的调用

示例: 涉及 do 块的更复杂的 callCC 示例

bar :: Char -> String -> Cont r Int
bar c s = do
    msg <- callCC $ \k -> do
        let s0 = c : s
        when (s0 == "hello") $ k "They say hello."
        let s1 = show s0
        return ("They appear to be saying " ++ s1)
    return (length msg)

当你用一个值调用 k 时,整个 callCC 调用会接收这个值。实际上,这使得 k 非常像其他语言中的 'goto' 语句:当我们在示例中调用 k 时,它会将执行弹出到第一次调用 callCC 的地方,也就是 msg <- callCC $ ... 行。不会再执行 callCC 的参数(内部 do 块)。因此,以下示例包含了一行无用的代码

示例: 弹出函数,引入一行无用的代码

quux :: Cont r Int
quux = callCC $ \k -> do
    let n = 5
    k n
    return 25

quux 会返回 5,而不是 25,因为我们在到达 return 25 行之前就弹出了 quux

我们在这里有意打破了一个趋势:通常情况下,当我们介绍一个函数时,我们会直接给出它的类型,但这次我们没有这样做。原因很简单:类型很复杂,它并不能立即让我们了解函数的功能或工作原理。然而,在最初介绍 callCC 之后,我们现在可以更好地理解它。深吸一口气...

callCC :: ((a -> Cont r b) -> Cont r a) -> Cont r a

我们可以根据我们已经了解的 callCC 的知识来理解它。整体结果类型和参数的结果类型必须相同(即 Cont r a),因为在没有调用 k 的情况下,对应的结果值是相同的。那么,k 的类型呢?如上所述,k 的参数会被变成一个挂起的计算,并在调用 callCC 的位置被插入;因此,如果后者类型为 Cont r a,那么 k 的参数类型必须为 a。至于 k 的结果类型,有趣的是,只要它被包装在相同的 Cont r 函子中,它实际上并不重要;换句话说,b 代表一个任意类型。这是因为,由 a 参数生成的挂起计算会接收调用 callCC 之后的所有延续,因此 k 的结果接收的延续无关紧要。

注意

k 的结果类型的任意性解释了为什么以下无用代码示例的变体会导致类型错误

quux :: Cont r Int
quux = callCC $ \k -> do
   let n = 5
   when True $ k n
   k 25

k 的结果类型可以是任何形式为 Cont r b 的类型;但是,when 将其限制为 Cont r (),因此最后的 k 25quux 的结果类型不匹配。解决方案非常简单:将最后的 k 替换为一个普通的 return


为了结束这一节,以下是 callCC 的实现代码。你能在其中识别出 k 吗?

callCC f = cont $ \h -> runCont (f (\a -> cont $ \_ -> h a)) h

尽管代码并不明显,但一个令人惊奇的事实是,ContcallCCreturn(>>=) 的实现可以从它们的类型签名中自动生成 - Lennart Augustsson 的 Djinn [1] 是一个可以为你完成此操作的程序。有关 Djinn 背后理论的背景信息,请参阅 Phil Gossett 的 Google 技术讲座:[2];以及 Dan Piponi 的文章:[3],其中使用了 Djinn 来推导出继续传递风格。

示例:复杂的控制结构

[编辑 | 编辑源代码]

现在我们将看看控制流操作的一些更现实的例子。第一个示例,如下所示,最初取自 所有关于单子教程 的“继续单子”部分,经许可使用。

示例:使用 Cont 实现复杂的控制结构

{- We use the continuation monad to perform "escapes" from code blocks.
This function implements a complicated control structure to process
numbers:

Input (n)     Output                    List Shown
=========     ======                    ==========
0-9           n                         none
10-199        number of digits in (n/2) digits of (n/2)
200-19999     n                         digits of (n/2)
20000-1999999 (n/2) backwards           none
>= 2000000    sum of digits of (n/2)    digits of (n/2)
-} 
fun :: Int -> String
fun n = (`runCont` id) $ do
    str <- callCC $ \exit1 -> do                            -- define "exit1"
        when (n < 10) (exit1 (show n))
        let ns = map digitToInt (show (n `div` 2))
        n' <- callCC $ \exit2 -> do                         -- define "exit2"
            when ((length ns) < 3) (exit2 (length ns))
            when ((length ns) < 5) (exit2 n)
            when ((length ns) < 7) $ do
                let ns' = map intToDigit (reverse ns)
                exit1 (dropWhile (=='0') ns')               --escape 2 levels
            return $ sum ns
        return $ "(ns = " ++ (show ns) ++ ") " ++ (show n')
    return $ "Answer: " ++ str

fun 是一个接受整数 n 的函数。该实现使用 ContcallCC 来设置一个使用 ContcallCC 的控制结构,根据 n 所处的范围执行不同的操作,如顶部的注释所示。让我们来分析一下它。

  1. 首先,顶部的 (`runCont` id) 只是意味着我们使用 id 的最终继续执行后面的 Cont 块(或者换句话说,我们从挂起的计算中提取值而不做任何修改)。这是必需的,因为 fun 的结果类型没有提到 Cont
  2. 我们将 str 绑定到以下 callCC do-block 的结果。
    1. 如果 n 小于 10,我们立即退出,只显示 n
    2. 否则,我们继续执行。我们构建一个列表 ns,其中包含 n `div` 2 的数字。
    3. n'(一个 Int)绑定到以下内部 callCC do-block 的结果。
      1. 如果 length ns < 3,即如果 n `div` 2 的数字少于 3 位,我们从这个内部 do-block 中弹出,结果为数字位数。
      2. 如果 n `div` 2 的数字少于 5 位,我们从内部 do-block 中弹出,返回原始的 n
      3. 如果 n `div` 2 的数字少于 7 位,我们从内部外部的 do-block 中弹出,结果为 n `div` 2 的数字的反向顺序(一个 String)。
      4. 否则,我们结束内部 do-block,返回 n `div` 2 的数字之和。
    4. 我们结束这个 do-block,返回字符串 "(ns = X) Y",其中 X 是 nsn `div` 2 的数字),Y 是内部 do-block 的结果 n'
  3. 最后,我们从整个函数中返回,结果是字符串 "Answer: Z",其中 Z 是我们从 callCC do-block 中得到的字符串。

示例:异常

[编辑 | 编辑源代码]

继续的一种用法是模拟异常。为此,我们保存两个继续:一个是如果发生异常则将我们带到处理程序的继续,另一个是如果成功则将我们带到处理程序后代码的继续。以下是一个简单的函数,它接受两个数字并对其进行整数除法,当分母为零时失败。

示例:抛出异常的 div

divExcpt :: Int -> Int -> (String -> Cont r Int) -> Cont r Int
divExcpt x y handler = callCC $ \ok -> do
    err <- callCC $ \notOk -> do
        when (y == 0) $ notOk "Denominator 0"
        ok $ x `div` y
    handler err

{- For example,
runCont (divExcpt 10 2 error) id --> 5
runCont (divExcpt 10 0 error) id --> *** Exception: Denominator 0
-}

它是如何工作的?我们使用两次嵌套的 callCC 调用。第一个标记一个继续,它将在没有问题时使用。第二个标记一个继续,它将在我们希望抛出异常时使用。如果分母不是 0,x `div` y 将被抛入 ok 继续,因此执行将直接弹出到 divExcpt 的顶层。然而,如果我们传递了一个为零的分母,我们将在 notOk 继续中抛出一个错误消息,这将使我们弹出到内部 do-block,并且该字符串将被分配给 err 并传递给 handler

可以使用以下函数看到更通用的异常处理方法。将计算作为第一个参数传递(更准确地说,是一个接受一个抛出异常函数并导致计算的函数)并将一个错误处理程序作为第二个参数传递。本例利用了通用的 MonadCont[4],它默认情况下涵盖了 Cont 和相应的 ContT 变换器,以及任何其他实例化它的继续单子。

示例:使用继续实现通用的 try

import Control.Monad.Cont

tryCont :: MonadCont m => ((err -> m a) -> m a) -> (err -> m a) -> m a
tryCont c h = callCC $ \ok -> do
    err <- callCC $ \notOk -> do
        x <- c notOk
        ok x
    h err

以下是我们的 try 在实际中的应用。

示例:使用 try

data SqrtException = LessThanZero deriving (Show, Eq)

sqrtIO :: (SqrtException -> ContT r IO ()) -> ContT r IO ()
sqrtIO throw = do 
    ln <- lift (putStr "Enter a number to sqrt: " >> readLn)
    when (ln < 0) (throw LessThanZero)
    lift $ print (sqrt ln)

main = runContT (tryCont sqrtIO (lift . print)) return

在本例中,抛出错误意味着从封闭的 callCC 中退出。sqrtIO 中的 throw 跳出了 tryCont 的内部 callCC

示例:协程

[编辑 | 编辑源代码]

在本节中,我们创建了一个 CoroutineT 单子,它提供了一个带有 fork 的单子,该单子将新挂起的协程入队,以及一个带有 yield 的单子,该单子会挂起当前线程。

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-- We use GeneralizedNewtypeDeriving to avoid boilerplate. As of GHC 7.8, it is safe.

import Control.Applicative
import Control.Monad.Cont
import Control.Monad.State

-- The CoroutineT monad is just ContT stacked with a StateT containing the suspended coroutines.
newtype CoroutineT r m a = CoroutineT {runCoroutineT' :: ContT r (StateT [CoroutineT r m ()] m) a}
    deriving (Functor,Applicative,Monad,MonadCont,MonadIO)

-- Used to manipulate the coroutine queue.
getCCs :: Monad m => CoroutineT r m [CoroutineT r m ()]
getCCs = CoroutineT $ lift get

putCCs :: Monad m => [CoroutineT r m ()] -> CoroutineT r m ()
putCCs = CoroutineT . lift . put

-- Pop and push coroutines to the queue.
dequeue :: Monad m => CoroutineT r m ()
dequeue = do
    current_ccs <- getCCs
    case current_ccs of
        [] -> return ()
        (p:ps) -> do
            putCCs ps
            p

queue :: Monad m => CoroutineT r m () -> CoroutineT r m ()
queue p = do
    ccs <- getCCs
    putCCs (ccs++[p])

-- The interface.
yield :: Monad m => CoroutineT r m ()
yield = callCC $ \k -> do
    queue (k ())
    dequeue

fork :: Monad m => CoroutineT r m () -> CoroutineT r m ()
fork p = callCC $ \k -> do
    queue (k ())
    p
    dequeue

-- Exhaust passes control to suspended coroutines repeatedly until there isn't any left.
exhaust :: Monad m => CoroutineT r m ()
exhaust = do
    exhausted <- null <$> getCCs
    if not exhausted
        then yield >> exhaust
        else return ()

-- Runs the coroutines in the base monad.
runCoroutineT :: Monad m => CoroutineT r m r -> m r
runCoroutineT = flip evalStateT [] . flip runContT return . runCoroutineT' . (<* exhaust)

一些示例用法

printOne n = do
    liftIO (print n)
    yield

example = runCoroutineT $ do
    fork $ replicateM_ 3 (printOne 3)
    fork $ replicateM_ 4 (printOne 4)
    replicateM_ 2 (printOne 2)

输出

3
4
3
2
4
3
2
4
4

示例:实现模式匹配

[编辑 | 编辑源代码]

CPS 函数的一个有趣的用法是实现我们自己的模式匹配。我们将通过一些示例来说明如何做到这一点。

示例:内置模式匹配

check :: Bool -> String
check b = case b of
    True  -> "It's True"
    False -> "It's False"

现在我们已经学习了 CPS,我们可以像这样重构代码。

示例:CPS 中的模式匹配

type BoolCPS r = r -> r -> r

true :: BoolCPS r
true x _ = x

false :: BoolCPS r
false _ x = x

check :: BoolCPS String -> String
check b = b "It's True" "It's False"
*Main> check true
"It's True"
*Main> check false
"It's False"

这里发生的情况是,我们使用函数来表示 TrueFalse,而不是简单值,这些函数将选择传递给它们的第一个或第二个参数。由于 truefalse 的行为不同,我们可以实现与模式匹配相同的效果。此外,TrueFalsetruefalse 可以通过 \b -> b True False\b -> if b then true else false 来相互转换。

我们应该看看这与更复杂的示例中的 CPS 有什么关系。

示例:更复杂的模式匹配及其 CPS 等价关系

data Foobar = Zero | One Int | Two Int Int

type FoobarCPS r = r -> (Int -> r) -> (Int -> Int -> r) -> r

zero :: FoobarCPS r
zero x _ _ = x

one :: Int -> FoobarCPS r
one x _ f _ = f x

two :: Int -> Int -> FoobarCPS r
two x y _ _ f = f x y

fun :: Foobar -> Int
fun x = case x of
    Zero -> 0
    One a -> a + 1
    Two a b -> a + b + 2

funCPS :: FoobarCPS Int -> Int
funCPS x = x 0 (+1) (\a b -> a + b + 2)
*Main> fun Zero
0
*Main> fun $ One 3
4
*Main> fun $ Two 3 4
9
*Main> funCPS zero
0
*Main> funCPS $ one 3
4
*Main> funCPS $ two 3 4
9

与前面的示例类似,我们用函数表示值。这些函数值会选择它们传递的相应(即匹配)继续,并将存储在函数值中的值传递给后者。有趣的是,这个过程不涉及任何比较。如我们所知,模式匹配可以用于不是 Eq 实例的类型:函数值“知道”它们自己的模式是什么,并将自动选择正确的继续。如果这是从外部完成的,比如通过 pattern_match :: [(pattern, result)] -> value -> result 函数,它就必须检查和比较模式和值,以查看它们是否匹配 - 因此需要 Eq 实例。

说明

  1. 也就是说,\x -> ($ x),完全写出来是 \x -> \k -> k x
  2. attoparsec 是 CPS 性能驱动用法的示例。
  3. 除了验证单子定律是否成立之外,这留给读者作为练习。
  4. mtl 包中找到,模块 Control.Monad.Cont 中。
华夏公益教科书