原则上,尾调用总是可以重用调用函数的栈帧。但是,一些运行时环境(例如 Java VM)缺乏使堆栈帧重用于尾调用高效的原语。因此,生产质量的 Scala 实现只需要重用直接尾递归函数的堆栈帧,该函数的最后一个操作是调用自身。其他尾调用也可能会被优化,但不应依赖于跨实现。
谁能解释一下这段中间的两句话是什么意思?
尾递归是尾调用的一种特殊情况。直接尾递归是尾递归的一种特殊情况。只有直接尾递归才能保证被优化。其他的也可能被优化,但这基本上只是一个编译器优化。作为一种语言特性,Scala 只保证直接消除尾递归。
那么,有什么区别呢?
好吧,尾调用只是子例程中的最后一个调用:
def a = {
b
c
}
在这种情况下,调用c
是尾调用,调用b
不是。
尾递归是当尾调用调用之前已经调用过的子例程时:
def a = {
b
}
def b = {
a
}
这是尾递归:a
调用b
(尾调用),而后者又a
再次调用。(与下面描述的直接尾递归相反,这有时称为间接尾递归。)
但是,这两个示例都不会被 Scala 优化。或者,更准确地说:Scala 实现可以优化它们,但不是必须这样做。这与例如 Scheme 形成对比,其中语言规范保证所有上述情况都将占用O(1)
堆栈空间。
Scala 语言规范仅保证直接尾递归得到优化,即当子例程直接调用自身而中间没有其他调用时:
def a = {
b
a
}
在这种情况下,调用a
是尾调用(因为它是子程序中的最后一次调用),它是尾递归(因为它再次调用自己),最重要的是它是直接尾递归,因为a
直接调用自己而不经过先打另一个电话。
请注意,有许多微妙的事情可能导致方法不是直接尾递归的。例如,如果a
被重载,那么递归实际上可能会经历不同的重载,因此不再是直接的。
在实践中,这意味着两件事:
- 您不能对尾递归方法执行提取方法重构,至少不包括尾调用,因为这会将直接尾递归方法(将得到优化)转变为间接尾递归方法(不会得到优化)。
- 您只能使用直接尾递归。可以使用间接尾递归非常优雅地表达的尾递归下降解析器或状态机已经过时了。
主要原因是当你的底层执行引擎缺乏强大的控制流操作特性,例如GOTO
,延续,一流的可变堆栈或适当的尾调用,那么你需要在它上面实现你自己的堆栈,使用蹦床,进行全局 CPS 变换或类似的讨厌的东西,以提供通用的正确尾调用。所有这些都会对性能或与同一平台上的其他代码的互操作性产生严重影响。
或者,正如 Clojure 的创建者 Rich Hickey 在面临同样问题时所说:“性能、Java 互操作、尾调用。选择两个。” Clojure 和 Scala 都选择在尾调用上妥协,只提供尾递归而不是完整的尾调用。
长话短说:是的,您发布的具体示例将被优化,因为它是直接尾递归。您可以通过@tailrec
在方法上添加注释来测试它。注释不会改变方法是否被优化,但是它保证如果方法不能被优化,你会得到一个编译错误。
由于上述细微之处,通常最好@tailrec
在需要优化的方法上添加注释,这既是为了获得编译错误,也是为了提示其他开发人员维护您的代码。