continuation + tail递归技巧是否真的为堆空间交换堆栈空间?

时间:2016-02-01 12:36:21

标签: optimization f# functional-programming tail-recursion continuations

在函数式编程中存在这种CPS技巧,它采用非尾递归函数并在连续传递样式(CPS)中重写它,从而轻松地使其尾递归。很多问题实际上涵盖了这一点,比如

举一些例子

let rec count n = 
    if n = 0
      then 0
      else 1 + count (n - 1)

let rec countCPS n cont =
    if n = 0
      then cont 0
      else countCPS (n - 1) (fun ret -> cont (ret + 1))

count的第一个版本将在每次递归调用中累积堆栈帧,在我的计算机上产生大约n = 60000的堆栈溢出。

CPS技巧的想法是countCPS实现是尾递归的,所以计算

let f = countCPS 60000

实际上将被优化为循环运行并且没有问题。而不是堆栈帧,继续运行将在每个步骤中累积,但这是堆上的诚实对象,其中内存不会导致问题。 因此,据说CPS风格用于堆积空间的堆栈空间。但是,我甚至对此表示怀疑。

这就是为什么:通过实际运行延续countCPS 60000 (fun x -> x)评估计算会打击我的堆栈!每次通话

countCPS (n - 1) (fun ret -> cont (ret + 1))

从旧的生成一个新的延续闭包,并运行它涉及一个函数应用程序。所以在评估countCPS 60000 (fun x -> x)时,我们调用一个60000闭包的嵌套序列,即使它们的数据位于堆上,我们仍然有函数应用程序,所以再次存在堆栈帧。

让我们深入研究生成的代码,将其反汇编成C#

对于countCPS,我们得到

public static a countCPS<a>(int n, FSharpFunc<int, a> cont)
{
    while (n != 0)
    {
        int arg_1B_0 = n - 1;
        cont = new Program<a>.countCPS@10(cont);
        n = arg_1B_0;
    }
    return cont.Invoke(0);
}

我们走了,尾递归实际上已经被优化了。但是,闭包类看起来像

internal class countCPS@10<a> : FSharpFunc<int, a>
{
    public FSharpFunc<int, a> cont;

    internal countCPS@10(FSharpFunc<int, a> cont)
    {
        this.cont = cont;
    }

    public override a Invoke(int ret)
    {
        return this.cont.Invoke(ret + 1);
    }
}

所以运行最外面的闭包会导致它的子句被.Invoke关闭,然后它一次又一次地关闭它...... 我们真的有60000次嵌套函数调用。

所以我不知道延续技巧是如何实际做出广告的。

现在我们可以争辩说this.cont.Invoke再次是尾调用,因此它不需要堆栈帧。 .NET是否执行这种优化?那些更复杂的例子如

let rec fib_cps n k = match n with
  | 0 | 1 -> k 1
  | n -> fib_cps (n-1) (fun a -> fib_cps (n-2) (fun b -> k (a+b)))

至少我们必须争论为什么我们可以优化在延续中捕获的嵌套函数调用。

修改

    interface FSharpFunc<A, B>
    {
        B Invoke(A arg);
    }

    class Closure<A> : FSharpFunc<int, A>
    {
        public FSharpFunc<int, A> cont;

        public Closure(FSharpFunc<int, A> cont)
        {
            this.cont = cont;
        }

        public A Invoke(int arg)
        {
            return cont.Invoke(arg + 1);
        }
    }

    class Identity<A> : FSharpFunc<A, A>
    {
        public A Invoke(A arg)
        {
            return arg;
        }
    }
    static void Main(string[] args)
    {
        FSharpFunc<int, int> computation = new Identity<int>();

        for(int n = 10; n > 0; --n)
            computation = new Closure<int>(computation);

        Console.WriteLine(computation.Invoke(0));
    }

更准确地说,我们模拟了CPS样式函数在C#中构建的闭包。

显然,数据存在于堆上。但是,评估computation.Invoke(0)会导致嵌套Invoke级别的子闭包。只需在Identity.Invoke上设置一个断点,然后查看堆栈跟踪!那么,如果它实际上大量使用两者,那么构建计算如何交换堆栈空间?

2 个答案:

答案 0 :(得分:9)

这里有很多概念。

对于尾递归函数,编译器可以将其优化为循环,因此不需要任何堆栈或堆空间。您可以通过编写:

count函数重写为简单的尾递归函数
let rec count acc n = 
   if n = 0
      then acc
      else count (acc + 1) (n - 1)

这将被编译成一个带有while循环的方法,不会进行递归调用。

当函数不能被写为尾递归时,通常需要继续。然后,您需要在堆上的堆栈上保留一些状态 。忽略fib可以更有效地编写的事实,天真的递归实现将是:

let fib n = 
  if n <= 1 then 1
  else (fib (n-1)) + (fib (n-2))

这需要 stack 空间来记住第一次递归调用返回结果后需要发生的事情(然后我们需要调用另一个递归调用并添加结果)。使用continuation,您可以将其转换为堆分配函数:

let fib n cont = 
  if n <= 1 then cont 1
  else fib (n-1) (fun r1 -> 
         fib (n-2) (fun r2 -> cont (r1 + r2))

这为每个递归调用分配一个continuation(函数值),但它是尾递归的,所以它不会耗尽可用的堆栈空间。

答案 1 :(得分:0)

这个问题很棘手:

  1. 它被视为关于一般原则的问题;
  2. 但是关于它似乎无法运作的所有细节都不可避免地要与实施细节相关
  3. 编译尾部调用可以,以便在堆栈或堆上不分配新帧。目标代码可以使用相同的堆栈指针值就地创建被调用者的堆栈帧,并无条件地将控制转移到其目标代码例程。

    但我加粗了#34; can&#34;因为这是语言实现者可以使用的选项。并非所有语言实现都能在所有情况下优化所有尾调用。

    知道F#的人将不得不对你案件的细节发表评论,但我可以在你提交的标题中回答这个问题:

      

    continuation + tail recursion trick是否实际交换了堆空间的堆栈空间?

    答案是它完全取决于您的语言实现。特别是,尝试在更常规的VM(如Java VM)上提供尾调用优化的实现通常不会提供不完整的TCO,边缘情况不起作用。