一个正确实现的递归惰性迭代器函数永远不会堆栈溢出吗?

时间:2014-08-14 18:52:00

标签: c# .net yield-return tail-call-optimization

TL;博士;

在C#中,你是否保证一个懒惰的迭代器函数只调用它本身并且确实有一个有效的递归退出条件会导致堆栈溢出?


详细问题:

我知道,作为一项规则,您不能保证C#编译器(或JIT)生成的尾调用优化(TCO)指令,所以当您可能获取时TCO,没有任何保证。

鉴于对TCO的这种认识,我想知道懒惰迭代器是否因为它们作为协程的性质而起作用(使用yield return等) - 每个尾部调用一个甚至占用堆栈空间?由于它们的重新定义,我对协同程序的直觉是默认情况下每个尾部调用都被优化,因为跳出函数并从父框架跳到下一个函数而不是创建新框架的能力看起来很自然。

这是C#中的行为,还是C#迭代器函数'递归调用从当前创建一个新帧而不是弹出到父帧并使用新参数重新输入?


示例:

public static IEnumerable<IEnumerable<T>> GeneratePermutations<T>(this IEnumerable<T> choices, int numberToChoose)
{
    if (numberToChoose == 1)
    {
        foreach (var choice in choices)
            yield return new T[] { choice };
        yield break;
    }

    var subPermutations = choices.SelectMany(choice =>
        choices.Where(elem => !EqualityComparer<T>.Default.Equals(elem, choice))
            .GeneratePermutations(numberToChoose - 1)
            .Select(permutation => (new T[] { choice }).Concat(permutation)));
    foreach (var perm in subPermutations)
        yield return perm;
}

我的直觉基于上面的例子subPermutations只是一个堆积的计算,它似乎在调用迭代它,它可以知道它的堆积计算(它是函数sig的一部分,它是一个迭代器函数),因此立即跳出它的当前帧并将堆积的计算扩展到一个新的框架 - 没有额外的尝试递归调用之前那里的堆栈空间......

这种直觉可能完全没有根据......

1 个答案:

答案 0 :(得分:10)

所以,让我们打开一个示例方法,以便我们可以参考:

public static IEnumerable<int> Foo()
{
    yield return 1;
    foreach (var n in Foo())
        yield return n;
}

这是我们的递归迭代器块。我只想花一点时间来说出这种方法的一些属性,这些属性可能(或可能不)最终具有相关性。

  • 有一个递归调用,但递归调用是在yield之后。

  • 当我们到达我们的递归调用时,我们在该点之后做的唯一事情是产生它的所有结果。每个项目都没有投影,没有finally块,没有投标,等等。

那么,当某些代码进入并写下以后会发生什么?

foreach(var n in Foo())
    Console.WriteLine(n);

好吧,当我们达到此声明时,首先要评估Foo()到一个值。在这种情况下,这将创建表示序列生成器的状态机。我们实际上并没有执行方法体中的任何代码。

接下来,我们致电MoveNext。我们点击了第一个yield块,产生了一个值并打印出来。

之后,最外层级别再次调用MoveNext。在这里,我们的状态机的MoveNext方法到达了它自己的foreach块。它将像Main方法一样,将Foo()计算为值,从而创建第二个状态机。然后它会立即在该状态机上调用MoveNext。第二个状态机将到达它的第一个yield,它将产生第一个迭代器的值,这将返回到主方法,将打印它。

然后main方法再次调用MoveNext。第一个迭代器向第二个迭代器询问它的第二个项,第二个迭代器到达它的foreach方法,创建第三个迭代器,并从中获取一个值。价值一直在上升。

正如我们在这里看到的那样,每当我们作为另一个项目的顶级迭代器时,堆栈总是比以前更深一层。尽管我们正在使用状态机,并且创建迭代器并没有消耗大量的堆栈空间,但获取序列中的下一个项目将消耗越来越多的堆栈空间,直到我们用完。

运行代码时,我们可以看到完全按照此处所述的方式运行,堆栈将溢出。

那么,这怎么可能被优化呢?

嗯,我们希望在这里做的是让顶级迭代器意识到当它从现在开始到foreach那个&#34;时,我的其他项目序列与递归调用中的所有项目相同&#34;。这听起来很像典型的TCO情况。

所以在这一点上我们有两个问题需要解决。首先,如果我们认识到我们处于这种情况,我们是否真的可以避免创建额外的状态机,从而不断增加堆栈空间。它不是那么容易,可能不像传统的非迭代器块TCO那么容易。您需要将状态机的所有实例字段设置为 的实例字段,如果您已调用Foo,则将创建状态机。我现在只是挥手示意,这听起来似乎是可能,但并不是每个都超级好。

然后我们有另一个问题。我们如何才能认识到我们实际上处于TCO有效的位置?我们需要递归地调用自己,我们需要对该方法调用不做任何事情,除了迭代整个事物并完全按原样产生每个项目,我们不需要在try或{{1} } block(否则using块将丢失),并且在该迭代之后不能有任何方法。

现在,如果有一个finally运算符,那么这不会那么糟糕。您只需设置规则,如果迭代器块中的最后一个语句是yield foreach运算符,并在最后对该方法进行递归调用,则应用TCO。遗憾的是,在C#中(与其他一些.NET语言不同),我们没有yield foreach运算符。我们需要键入整个yield foreach运算符,同时除了完全按原样生成项目之外,不要做任何其他操作。这似乎......有点尴尬。

回顾一下:

  • 编译器是否可以对递归迭代器块使用Tail Call Optimization?
    • 最有可能。
  • 它是由编译器完成的吗?
    • 它不会出现。
  • 将此支持添加到编译器中是否特别可行?
    • 可能不是。