没有TCO时,什么时候需要担心堆叠失败?


14

每次都针对以JVM为目标的新编程语言进行讨论,不可避免地会有人说:

“ JVM不支持尾调用优化,因此我预计会有很多爆炸式堆栈”

该主题有成千上万的变体。

现在,我知道某些语言(例如Clojure)具有可以使用的特殊递归结构。

我不明白的是:缺少尾部呼叫优化有多严重?我什么时候应该担心呢?

我感到困惑的主要根源可能是因为Java是有史以来最成功的语言之一,而且相当多的JVM语言似乎运行得很好。这怎么可能,如果缺乏TCO的是真的任何关注?


4
如果您的递归深度足够深,可以在不使用TCO的情况下将堆栈炸毁,那么即使使用TCO,您也会遇到问题
棘手怪胎

18
@ratchet_freak废话。Scheme甚至没有循环,但是由于规范要求TCO支持,因此在大量数据上进行递归迭代并不比命令式循环昂贵(具有Scheme构造返回值的好处)。
itsbruce

6
@ratchetfreak TCO是一种使递归函数以某种方式(即,以尾递归方式)编写的机制,即使它们愿意,也完全无法破坏堆栈。您的陈述仅适用于不以尾递归方式编写的递归,在这种情况下,您是正确的,并且TCO不会为您提供帮助。
Evicatos

2
最后我看了一下,80x86也不进行(本地)尾调用优化。但这并没有阻止语言开发人员移植使用它的语言。编译器确定何时可以使用跳转和jsr,每个人都很高兴。您可以在JVM上执行相同的操作。
kdgregory

3
@kdgregory:但是x86有GOTO,JVM没有。而且x86不用作互操作平台。JVM没有,GOTO并且选择Java平台的主要原因之一是互操作性。如果要在JVM上实现TCO,则必须对堆栈做一些事情。自己进行管理(即根本不使用JVM调用堆栈),使用蹦床,将异常用作GOTO,诸如此类。在所有这些情况下,您都将与JVM调用堆栈不兼容。与Java堆栈兼容,拥有TCO和高性能是不可能的。您必须牺牲这三个之一。
约尔格W¯¯米塔格

Answers:


16

考虑到这一点,比方说我们摆脱了Java中的所有循环(编译器编写程序开始罢工或其他事情)。现在我们想写阶乘,所以我们可以纠正这样的事情

int factorial(int i){ return factorial(i, 1);}
int factorial(int i, int accum){
  if(i == 0) return accum;
  return factorial(i-1, accum * i);
}

现在我们感觉很聪明,即使没有循环,我们也设法编写了阶乘!但是,当我们进行测试时,我们注意到在任何合理大小的数字下,由于没有TCO,因此会出现堆栈溢出错误。

在实际的Java中,这不是问题。如果我们有尾部递归算法,则可以将其转换为循环,就可以了。但是,没有循环的语言呢?然后,您就被水管了。这就是为什么clojure有这个recur形式,如果没有它,它甚至不会变完整(无法进行无限循环)。

针对JVM的功能语言类包括Frege,Kawa(Scheme),Clojure总是试图解决缺少尾部调用的问题,因为在这些语言中,TC是循环的惯用方式!如果转换为Scheme,上面的阶乘将是一个很好的阶乘。如果循环5000次使您的程序崩溃,那将是非常不便的。这可以通过以下方式解决recur特殊形式解决此问题,这些注释会提示优化自我呼叫,蹦床等。但是它们都迫使性能下降或对程序员造成不必要的工作。

现在Java也不能免费使用,因为TCO还有更多功能,而不仅仅是递归,那么相互递归函数又如何呢?它们不能直接转换为循环,但是JVM仍未对其进行优化。这使得尝试使用Java进行相互递归来编写算法非常不愉快,因为如果您想要出色的性能/范围,则必须做一些魔术才能使其适应循环。

因此,总而言之,这在许多情况下都不是什么大问题。大多数尾调用要么只进行一个堆栈帧深的处理,例如

return foo(bar, baz); // foo is just a simple method

还是递归。但是,对于不适合这种类型的TC,每种JVM语言都会感到痛苦。

但是,我们尚无TCO的理由很充分。JVM为我们提供了堆栈跟踪。使用TCO,我们可以系统地消除我们知道已“消失”的堆栈帧,但是JVM可能实际上希望稍后将它们用作堆栈跟踪!假设我们实现了这样的FSM,其中每个状态都尾调用下一个。我们会删除以前状态的所有记录,因此回溯将向我们显示什么状态,但不会告诉我们如何到达那里。

此外,更为紧迫的是,很多字节码验证都是基于堆栈的,从而消除了让我们验证字节码的前景不好的事情。在此与Java具有循环的事实之间,TCO看起来比JVM工程师值得解决的麻烦多。


2
最大的问题是字节码验证程序,它完全基于堆栈检查。这是JVM规范中的主要错误。25年前,当设计JVM时,人们已经说过,首先让JVM字节码语言安全是总比不使该语言不安全,然后事后依靠字节码验证更好。但是,Matthias Felleisen(Scheme社区的主要人物之一)写了一篇论文,演示了如何在保留字节码验证程序的同时将尾调用添加到JVM。
约尔格W¯¯米塔格

2
有趣的是,IBM的J9 JVM 确实可以执行TCO。
约尔格W¯¯米塔格

1
@jozefg有趣的是,没有人关心循环的stacktrace条目,因此,至少对于尾部递归函数,stacktrace参数不成立。
Ingo

2
@MasonWheeler正是我的意思:stacktrace不会告诉您它发生在哪个迭代中。您只能通过检查循环变量等间接地看到它。那么,为什么要尾部递归函数的多个hundert堆栈跟踪条目?只有最后一个很有趣!而且,就像循环一样,您可以通过检查局部变量,参数值等来确定它是哪个递归。–
Ingo

3
@Ingo:如果一个函数仅对自身进行递归,则堆栈跟踪可能不会显示太多。但是,如果一组函数是相互递归的,则堆栈跟踪有时可能会显示很多。
2015年

7

由于尾部递归,尾部调用优化非常重要。但是,有一个争论为什么JVM不优化尾部调用实际上是件好事:由于TCO重用了堆栈的一部分,因此来自异常的堆栈跟踪将是不完整的,因此使调试变得更加困难。

有一些方法可以解决JVM的限制:

  1. 编译器可以将简单的尾递归优化为循环。
  2. 如果程序采用连续传递样式,则使用“踩踏”是微不足道的。在此,函数不返回最终结果,而是在外部执行的继续。此技术允许编译器编写器为任意复杂的控制流建模。

这可能需要一个更大的示例。考虑带有闭包的语言(例如JavaScript或类似语言)。我们可以将阶乘写为

def fac(n, acc = 1) = if (n <= 1) acc else n * fac(n-1, acc*n)

print fac(x)

现在我们可以让它返回一个回调:

def fac(n, acc = 1) =
  if (n <= 1) acc
  else        (() => fac(n-1, acc*n))  // this isn't full CPS, but you get the idea…

var continuation = (() => fac(x))
while (continuation instanceof function) {
  continuation = continuation()
}
var result = continuation
print result

现在,它可以在恒定的堆栈空间中工作,这很愚蠢,因为无论如何它都是尾递归的。但是,此技术能够将所有尾部调用展平为恒定的堆栈空间。如果程序在CPS中,则这意味着调用堆栈总体上是恒定的(在CPS中,每个调用都是尾调用)。

这种技术的主要缺点是,它更难调试,更难实现且性能更低–请参阅我正在使用的所有闭包和间接寻址。

出于这些原因,让VM实现尾部调用op是非常可取的-像Java这样有充分理由不支持尾部调用的语言将不必使用它。


1
“是的,但是当循环中的堆栈跟踪也不完整时,它不会记录循环执行的频率。” -A,即使JVM支持适当的尾部调用,在调试过程中,仍然可以选择退出。然后,对于生产而言,启用TCO以确保代码运行100,000或100,000,000尾调用。
Ingo

1
@Ingo No.(1)当循环没有实现为递归时,没有理由让它们显示在堆栈上(尾调用≠跳转≠调用)。(2)TCO比尾递归优化更通用。我的答案以递归为例。(3)如果您以依赖 TCO 的风格进行编程,则不能选择关闭此优化–完全TCO或完全堆栈跟踪是语言功能,或者不是。例如,Eg Scheme可以通过更高级的异常系统来平衡TCO的缺点。
阿蒙(Amon)2013年

1
(1)完全同意。但是,出于同样的原因,当然没有理由让成千上万的return foo(....);方法foo(2)中的所有指向的堆栈跟踪条目完全一致。尽管如此,我们仍然接受来自循环,赋值(!),语句序列的不完整跟踪。例如,如果您在变量中找到了意外的值,那么您肯定想知道它是如何到达那里的。但是在这种情况下,您不会抱怨缺少踪迹。因为它以某种方式刻在我们的大脑中,所以a)它仅在通话中发生b)它在所有通话中发生。两者都没有道理,恕我直言。
Ingo

(3)不同意。我看不出没有理由为什么要调试一个大小为N的问题的代码,因为其中的N很小,足以摆脱普通的堆栈。然后,打开开关并打开TCO-有效地降低了对探针尺寸的限制。
Ingo

@Ingo“不同意。我看不出没有理由为什么不可能用大小为N的问题来调试我的代码,因为其中的N足够小,可以摆脱普通的堆栈。” 如果TCO / TCE用于CPS转换,则将其关闭将使堆栈溢出并使程序崩溃,因此将无法进行调试。由于这个问题偶然发生,Google拒绝在V8 JS中实施TCO 。他们想要一些特殊的语法,以便程序员可以声明他确实想要TCO和丢失堆栈跟踪。有人知道TCO是否也搞错了吗?
谢尔比摩尔三世

6

程序中很大一部分调用是尾调用。每个子例程都有一个最后调用,因此每个子例程至少有一个尾调用。尾部调用具有GOTO子例程调用的性能特征,但具有安全性。

进行正确的尾部调用使您能够编写其他方式无法编写的程序。以状态机为例。通过将每个状态作为子例程并将每个状态转换作为子例程调用,可以非常直接地实现状态机。在这种情况下,您通过一个接一个接一个的接一个接一个的调用来从一个状态过渡到另一个状态,实际上您再也不会返回!如果没有正确的尾叫,您将立即丢掉筹码。

如果没有PTC,则必须使用“ GOTO蹦床”或“异常”作为控制流或类似的东西。这很丑陋,而不是状态机的直接1:1表示。

(请注意,我如何巧妙地避免使用无聊的“循环”示例。这是一个示例,即使在使用循环的语言中,PTC也很有。)

我在这里故意使用了“正确的尾部呼叫”一词,而不是TCO。TCO是编译器优化。PTC是一种语言功能,要求每个编译器都执行TCO。


The vast majority of calls in a program are tail calls. 如果被调用的“绝大多数”方法执行的调用不止一个,则不是这样。 Every subroutine has a last call, so every subroutine has at least one tail call. 这很容易证明是假的:return a + b。(除非你在基本的算术运算被定义为函数调用,当然有些疯狂的语言是。)
梅森惠勒

1
“相加两个数字就是相加两个数字。” 除了不是的语言。Lisp / Scheme中的+操作怎么样,其中单个算术运算符可以接受任意数量的参数?(+1 2 3)实现此功能的唯一明智的方法是将其作为函数。
Evicatos

1
@梅森·惠勒:抽象反转是什么意思?
Giorgio

1
@MasonWheeler毫无疑问,这是我所见过的有关技术主题的最手工的Wikipedia条目。我看到了一些可疑的条目,但这只是...哇。
Evicatos

1
@MasonWheeler:您是否在谈论On Lisp的第22和23页上的列表长度函数?尾注版本大约是复杂版本的1.2倍,远不及3倍。我也不清楚您对抽象反转的含义。
迈克尔·肖

4

“ JVM不支持尾调用优化,因此我预计会有很多爆炸式堆栈”

谁这样说的要么(1)不了解尾部调用优化,要么(2)不了解JVM,或者(3)两者均如此。

我将从Wikipedia的尾声定义开始(如果您不喜欢Wikipedia,这是替代方法):

在计算机科学中,尾部调用是子例程调用,该子例程调用发生在另一个过程中,作为其最终动作。它可能产生一个返回值,然后由调用过程立即返回

在下面的代码中,对的调用bar()是的尾部调用foo()

private void foo() {
    // do something
    bar()
}

当语言实现看到尾部调用,不使用常规方法调用(创建堆栈框架),而是创建分支时,便会发生尾部调用优化。这是一种优化,因为堆栈帧需要内存,并且它需要CPU周期才能将信息(例如返回地址)推送到该帧上,并且假定调用/返回对比无条件跳转需要更多的CPU周期。

TCO通常用于递归,但这并不是唯一的用途。它也不适用于所有递归。例如,用于计算阶乘的简单递归代码无法进行尾调用优化,因为函数中最后发生的事情是乘法运算。

public static int fact(int n) {
    if (n <= 1) return 1;
    else return n * fact(n - 1);
}

为了实现尾部调用优化,您需要做两件事:

  • 除了subtroutine调用之外,还支持分支的平台。
  • 可以确定是否可以进行尾部调用优化的静态分析器。

而已。正如我在其他地方提到的那样,JVM(像其他任何图灵完整的体系结构一样)都有一个转到。它碰巧有一个无条件的goto,但是可以使用条件分支轻松实现该功能。

静态分析很棘手。在一个功能内,这没问题。例如,这是一个尾递归Scala函数,用于将a中的值求和List

def sum(acc:Int, list:List[Int]) : Int = {
  if (list.isEmpty) acc
  else sum(acc + list.head, list.tail)
}

该函数转换为以下字节码:

public int sum(int, scala.collection.immutable.List);
  Code:
   0:   aload_2
   1:   invokevirtual   #63; //Method scala/collection/immutable/List.isEmpty:()Z
   4:   ifeq    9
   7:   iload_1
   8:   ireturn
   9:   iload_1
   10:  aload_2
   11:  invokevirtual   #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
   14:  invokestatic    #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
   17:  iadd
   18:  aload_2
   19:  invokevirtual   #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
   22:  checkcast   #59; //class scala/collection/immutable/List
   25:  astore_2
   26:  istore_1
   27:  goto    0

请注意goto 0最后。相比之下,一个等效的Java函数(必须使用Iterator来模仿将Scala列表分为头尾的行为)变成以下字节码。注意,最后两个操作现在是invoke,然后显式返回该递归调用产生的值。

public static int sum(int, java.util.Iterator);
  Code:
   0:   aload_1
   1:   invokeinterface #64,  1; //InterfaceMethod java/util/Iterator.hasNext:()Z
   6:   ifne    11
   9:   iload_0
   10:  ireturn
   11:  iload_0
   12:  aload_1
   13:  invokeinterface #70,  1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
   18:  checkcast   #25; //class java/lang/Integer
   21:  invokevirtual   #74; //Method java/lang/Integer.intValue:()I
   24:  iadd
   25:  aload_1
   26:  invokestatic    #43; //Method sum:(ILjava/util/Iterator;)I
   29:  ireturn

单一功能的尾调用优化的很简单:编译器可以看到,有一个使用调用的结果没有代码,因此它可以代替调用goto

如果您有多种方法,生活就会变得棘手。与诸如80x86之类的通用处理器的JVM分支指令不同,JVM的分支指令仅限于单个方法。如果拥有私有方法,它仍然相对简单:编译器可以自由地内联这些方法,因此可以优化尾部调用(如果您想知道这可能如何工作,请考虑使用一种switch用于控制行为的通用方法)。您甚至可以将此技术扩展到同一类中的多个公共方法:编译器内联方法主体,提供公共桥方法,并且内部调用变成跳转。

但是,当您考虑不同类中的公共方法时,此模型会崩溃,尤其是考虑到接口和类加载器时。源代码级编译器根本没有足够的知识来实现​​尾调用优化。但是, “裸机”实现不同,* JVM(确实以Hotspot编译器的形式(至少是前Sun编译器的形式)具有执行此操作的信息。我不知道它是否实际执行尾部呼叫优化,并且怀疑不是,但是可以

这将我带到您的问题的第二部分,我将其重新表述为“我们应该照顾吗?”

显然,如果您的语言使用递归作为唯一的迭代原语,则您会在意。但是,需要此功能的语言可以实现它。唯一的问题是用于该语言的编译器是否可以生成可以被任意Java类调用的类。

在那种情况之外,我将通过说无关紧要的方式来邀请不赞成投票的人。我所见过的大多数递归代码(并且我已经处理过许多图形项目)都不是可优化的尾部调用。像简单的阶乘一样,它使用递归来构建状态,并且尾部操作是组合。

对于可以优化尾部调用的代码,通常可以直接将其转换为可迭代的形式。例如,sum()我之前显示的功能可以概括为foldLeft()。如果您查看源代码,将会看到它实际上是作为迭代操作实现的。JörgW Mittag举了一个通过函数调用实现状态机的例子。有许多高效(且可维护)的状态机实现,它们不依赖于将函数调用转换为跳转。

我将完成一些完全不同的事情。如果您从SICP的脚注中搜索Google ,您可能会在这里结束。我个人发现,这比将我的编译器替换JSR为有趣得多JUMP


如果存在尾部呼叫操作码,为什么尾部呼叫优化除了在每个呼叫站点观察是否需要进行呼叫的方法之后是否需要执行任何代码之外,还需要什么呢?在某些情况下,return foo(123);通过内联执行foo比通过生成代码来操纵堆栈并执行跳转更好的语句可能是更好的选择,但是我不明白为什么尾调用与普通调用不同那方面。
supercat 2015年

@supercat-我不确定您的问题是什么。这篇文章的第一点是,编译器无法知道所有潜在被调用者的堆栈框架是什么样子(请记住,该堆栈框架不仅包含函数参数,还包含其局部变量)。我想您可以添加一个运行时检查兼容框架的操作码,但这使我进入了文章的第二部分:真正的价值是什么?
kdgregory 2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.