【问题标题】:Under what circumstances are monadic computations tail-recursive?在什么情况下一元计算是尾递归的?
【发布时间】:2012-11-14 12:44:44
【问题描述】:

在 Haskell Wiki 的 Recursion in a monad 中有一个示例,声称是 tail-recursive

f 0 acc = return (reverse acc)
f n acc = do
    v  <- getLine
    f (n-1) (v : acc)

虽然命令式表示法让我们相信它是尾递归的,但它一点也不明显(至少对我而言)。如果我们去糖 do 我们得到

f 0 acc = return (reverse acc)
f n acc = getLine >>= \v -> f (n-1) (v : acc)

重写第二行导致

f n acc = (>>=) getLine (\v -> f (n-1) (v : acc))

所以我们看到f 出现在&gt;&gt;= 的第二个参数中,而不是在尾递归位置。我们需要检查IO&gt;&gt;= 以获得答案。 显然将递归调用作为do 块中的最后一行并不是尾递归函数的充分条件。


假设 monad 是尾递归的当且仅当此 monad 中的每个递归函数都定义为

f = do
    ...
    f ...

或等效

f ...  =  (...) >>= \x -> f ...

是尾递归的。我的问题是:

  1. 哪些单子是尾递归的?
  2. 是否有一些通用规则可以用来立即区分尾递归单子?

更新:让我举一个具体的反例:根据上述定义,[] monad 不是尾递归的。如果是的话,那么

f 0 acc = acc
f n acc = do
    r <- acc
    f (n - 1) (map (r +) acc)

必须是尾递归的。但是,对第二行进行脱糖会导致

f n acc = acc >>= \r -> f (n - 1) (map (r +) acc)
        = (flip concatMap) acc (\r -> f (n - 1) (map (r +) acc))

显然,这不是尾递归,恕我直言,无法做到。原因是递归调用不是计算的结束。多次执行,将结果合并为最终结果。

【问题讨论】:

  • 请注意:尾递归的。尾递归只是意味着函数没有使用最后一次函数调用的返回值。在您的情况下,未使用最终 f 调用的值。如果您宁愿务实地考虑它,函数是尾递归的,如果在执行最后一次调用后,您可以处理与函数关联的所有上下文。此外,据我所知,任何单子都没有任何固有的尾递归或非尾递归。
  • @scvalex 虽然直觉上这是有道理的,但我想正式证明它是合理的。根据Tail recursion 中所述的标准,您能否证明f 是尾递归的?
  • @hammar 该定义并不关心您是否可以定义不是该形式的递归函数。它只关心任意函数的形式是否为尾递归。
  • 不能直接内联&gt;&gt;=的定义,看看结果是不是尾递归的?
  • @PetrPudlák 查看该页面上的定义,我不得不说f 不是尾递归的。另一方面,我不同意这个定义,因为它似乎排除了调用任何其他函数作为扩展函数的第一步。根据该定义,f = f $ 1 不是尾递归的。

标签: haskell monads tail-recursion


【解决方案1】:

引用自身的一元计算永远不会是尾递归的。然而,在 Haskell 中你有懒惰和 corecursion,这才是最重要的。让我们使用这个简单的例子:

forever :: (Monad m) => m a -> m b
forever c' = let c = c' >> c in c

当且仅当(&gt;&gt;) 在其第二个参数中是非严格的,这样的计算才会在恒定空间中运行。这真的很像列表和repeat

repeat :: a -> [a]
repeat x = let xs = x : xs in xs

由于 (:) 构造函数在其第二个参数中是非严格的,因此它可以工作并且可以遍历列表,因为您有一个有限的弱头范式 (WHNF)。只要消费者(例如列表折叠)只要求 WHNF,它就可以工作并在恒定空间中运行。

forever 的消费者是解释单子计算的任何东西。如果 monad 是 [],那么 (&gt;&gt;) 在它的第二个参数中是非严格的,当它的第一个参数是空列表时。所以forever [] 会导致[],而forever [1] 会发散。对于IO monad,解释器本身就是运行时系统,您可以认为(&gt;&gt;) 在其第二个参数中始终是非严格的。

【讨论】:

    【解决方案2】:

    真正重要的是恒定的堆栈空间。由于懒惰,您的第一个示例是tail recursive modulo cons

    (getLine &gt;&gt;=) 将被执行并消失,让我们再次调用f。重要的是,这发生在一定数量的步骤中 - 没有 thunk 积聚。

    你的第二个例子,

    f 0 acc = acc
    f n acc = concat [ f (n - 1) $ map (r +) acc | r <- acc]
    

    在其 thunk 构建中将仅是线性的(在 n 中),因为从左侧访问结果列表(再次由于惰性,因为 concat 是非严格的)。如果它在头部被消耗,它可以在 O(1) 空间中运行(不计算线性空间 thunk,f(0), f(1), ..., f(n-1) 在左边缘)。

    会更糟

    f n acc = concat [ f (n-1) $ map (r +) $ f (n-1) acc | r <- acc]
    

    do-notation,

    f n acc = do
      r <- acc
      f (n-1) $ map (r+) $ f (n-1) acc
    

    因为信息依赖会产生额外的强迫。同样,如果给定 monad 的绑定是严格操作。

    【讨论】:

    • 是的,但是在一般情况下,我们如何判断它什么时候蒸发,什么时候不蒸发呢?这就是问题的全部意义所在。
    • 我想我们必须内联绑定定义并分析结果。我认为懒惰在这里更重要,就像在 Haskell 中一样。
    • Will Ness - 如果仅针对(某些)Prolog 实现“尾递归取模 cons”优化,而不是通常为函数式语言或 GHC 实现,那么讨论是否使用 Haskell 并没有太多的教学价值函数采用“尾递归模 cons”形式。这混淆了尾递归/尾调用优化的讨论,而不是阐明它。
    • @stephentetley 不必在 Haskell 中专门实现;懒惰免费给我们。
    • @stephentetley 也是,我不是在谈论优化。无论是否采用优化,该代码仍然是 TRMC。并且在惰性评估下,只要有问题的“缺点”(即递归调用之前的常量空间计算)不会过早地强制递归调用,就不需要优化。
    猜你喜欢
    • 1970-01-01
    • 2011-04-10
    • 1970-01-01
    • 2010-11-17
    • 1970-01-01
    • 2016-04-13
    • 2013-09-14
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多