为什么Scala编译器只有在方法被标记为final时才应用尾调用优化?

46

为什么Scala编译器只有在方法被声明为final时才会应用尾递归优化?

例如,下面的代码:

class C {
    @tailrec def fact(n: Int, result: Int): Int =
        if(n == 0)
            result
        else
            fact(n - 1, n * result)
}

结果是

错误:无法优化@tailrec注释的方法:它既不是私有的也不是最终的,因此可以被覆盖

如果编译器在这种情况下应用TCO,会发生什么问题?


这个问题让人困惑的是 TCO,它可以安全地与该方法一起使用,而更严格的 tailrec 不能使用,因为该方法可能不是自递归的。 - Tim
5个回答

56

考虑以下与REPL的交互。首先我们定义了一个具有阶乘方法的类:

scala> class C {
         def fact(n: Int, result: Int): Int =
           if(n == 0) result
           else fact(n - 1, n * result)
       }
defined class C

scala> (new C).fact(5, 1)
res11: Int = 120

现在让我们在一个子类中重写它,以使超类的答案加倍:

scala> class C2 extends C {
         override def fact(n: Int, result: Int): Int = 2 * super.fact(n, result)
       }
defined class C2

scala> (new C).fact(5, 1)
res12: Int = 120

scala> (new C2).fact(5, 1)

你对这个最后的调用期望什么结果?你可能期望得到240。但不是这样的:

scala> (new C2).fact(5, 1)
res13: Int = 7680

这是因为当超类的方法进行递归调用时,递归调用会经过子类。

如果重写的工作方式是 240 是正确答案,那么在超类中执行尾递归优化是安全的。但 Scala (或 Java) 并不是这样工作的。

除非一个方法被标记为 final,否则当它进行递归调用时,它可能不会调用自身

这就是为什么 @tailrec 只有在一个方法被标记为 final(或 private)时才有效。

更新:我建议也阅读其他两个答案(John 和 Rex 的答案)。


可能值得明确指出的是,“可能不会调用自身”是一个特定于Scala的问题,与一般的尾递归消除无关。在SML、OCaml、F#等语言中,所有这些尾递归都将被消除。 - J D
你如何编写“fact”父子级,使得子级的意图是父级“fact”的2倍? - Kevin Meredith
@KevinMeredith:我认为这会成为一个很好的独立问题。 - Seth Tisue

23

递归调用可能是到一个子类而不是到一个超类;final关键字可以防止这种情况。但为什么你想要这种行为呢?斐波那契数列并没有提供任何线索。但下面的内容会:

class Pretty {
  def recursivePrinter(a: Any): String = { a match {
    case xs: List[_] => xs.map(recursivePrinter).mkString("L[",",","]")
    case xs: Array[_] => xs.map(recursivePrinter).mkString("A[",",","]")
    case _ => a.toString
  }}
}
class Prettier extends Pretty {
  override def recursivePrinter(a: Any): String = { a match {
    case s: Set[_] => s.map(recursivePrinter).mkString("{",",","}")
    case _ => super.recursivePrinter(a)
  }}
}

scala> (new Prettier).recursivePrinter(Set(Set(0,1),1))
res8: String = {{0,1},1}
如果Pretty调用是尾递归的话,我们会打印出{Set(0, 1),1},因为扩展将不适用。
由于这种类型的递归可能很有用,并且如果允许在非最终方法上进行尾调用,则会被破坏,因此编译器会插入真正的调用。

3
好的,当我读完你的内容后,我的印象是“Scala无法优化程序,导致程序输出与预期不符。”因此,我认为我最好提供另一个例子来更清楚地解释为什么7680是“正确”的答案。 - Rex Kerr
这种递归可能非常有用。它是函数式技术(如续延传递样式)的支柱。 - J D
@JonHarrop - 的确。依赖于此的 CPS 和其他函数式技术可能是有用的。 - Rex Kerr
顺便提一下,在金融领域中有一个使用它的例子http://zbray.com/2011/11/02/solving-the-0-1-knapsack-problem-using-continuation-passing-style-with-memoization-in-f/。 - J D

7
foo::fact(n, res)表示你的例程,让baz::fact(n, res)表示其他人覆盖了你的例程。编译器告诉你这个语义允许baz::fact()成为一个包装器,如果它愿意,可能会调用foo::fact()。在这种情况下,规则是当foo::fact()递归时,必须激活baz::fact()而不是foo::fact(),并且虽然foo::fact()是尾递归的,但baz::fact()可能不是。此时,foo::fact()必须返回到baz::fact(),以便它可以解开自己,而不是在尾递归调用上循环。

谢谢,John。我的回答侧重于结果是什么,但你指出尾调用属性也会丢失,这一点也是正确的。 - Seth Tisue
可能值得说明的是,它们都是尾递归的,问题在于Scala只能优化其中一个(由于JVM缺乏尾调用消除)。 - J D
@JonHarrop,尾调用消除是由编译器而非“处理器”(JVM)完成的。JVM本质上是一个微处理器,以软件形式实现,具有适用于Java的机器语言。JVM具有分支指令(“goto”)和子程序调用指令(“jsr”),而决定发出哪个指令是编译器的工作。如果Java编译器没有进行尾调用优化,则这是编译器的问题。(注意:在21世纪第二个十年,一个不能进行尾调用优化的生产级编译器必须被视为无望的破损品。) - John R. Strohm
@JohnR.Strohm:“JVM有一个分支指令(“goto”)和一个子例程调用指令(“jsr”),而编译器的工作是决定发出哪个指令。”这只在函数尾部调用自身的特殊情况下才成立。一般来说,尾调用可以到达任何地方。真正的微处理器有跳转指令,可以将程序计数器带到任何地方。JVM的goto指令仅限于当前函数体中的目标。因此,JVM无法本地通用地表达尾调用。Scala编译器在VM中存在的这种缺陷下是无助和瘫痪的。 - J D
1
@JonHarrop:我理解你的观点,你是正确的。我在考虑尾递归优化,这是一般尾调用优化的特殊情况。 - John R. Strohm
@JohnR.Strohm:值得一提的是,Sun 公司曾经研究过在 Hotspot 中实现尾调用消除的可能性,但最终未能实现。他们的生产重点已转向动态语言(例如 InvokeDynamic),而非函数式语言。详情请见:https://blogs.oracle.com/jrose/entry/tail_calls_in_the_vm - J D

1

如果编译器在这种情况下应用TCO,会发生什么问题?

不会出现任何问题。任何支持 proper tail call elimination 的语言都可以做到这一点(例如 SML、OCaml、F#、Haskell 等)。Scala 之所以不这样做,是因为 JVM 不支持尾递归,并且 Scala 常规的将尾部位置的自递归调用替换为 goto 的方法在这种情况下无法奏效。在 CLR 上运行的 Scala 可以像 F# 一样实现这一点。


嘿,伙计,你能告诉我为什么在这种情况下F#会崩溃吗?http://ideone.com/VgJnR6 - 在ideone上的mono失败并出现sigsegv错误,在win8中尝试使用tryfsharp会导致IE崩溃。 - OlegYch
@OlegYch Mono和TryFSharp没有进行正确的尾递归消除。当启用TCO时,您的程序在.NET上可以正常工作。 - J D
谢谢Jon,感谢您的反馈! - OlegYch
@OlegYch 我建议你再用Mono重新尝试一下你的样例,因为现在它对尾调用的支持更好了。 - knocte
@knocte 如果Mono能够正确处理尾调用优化,我会感到非常惊讶,但我会去看一下。 - J D
jaykrell在过去的几个月/年一直在这方面工作...请务必测试最新的预览版。 - knocte

0

对于这个问题,流行且被接受的答案实际上是误导性的,因为问题本身很令人困惑。OP没有区分tailrecTCO,而答案也没有解决这个问题。

关键点在于tailrec的要求比TCO的要求更严格

tailrec注释要求尾调用必须是同一个函数,而TCO可以用于尾调用到任何函数

编译器可以在fact上使用TCO,因为尾部位置有一个调用。具体来说,它可以通过适当地调整堆栈,将对fact调用转换为对fact跳转。这个版本的fact与调用它的函数不同并不重要。

所以被接受的答案正确地解释了为什么非 final 函数不能是 tailrec,因为你无法保证尾调用是到同一个函数而不是到函数的重载版本。但它错误地暗示了在这个方法上使用 TCO 是不安全的,事实上这将是完全安全和良好的优化。

[请注意,正如 Jon Harrop 所解释的那样,你不能在 JVM 上实现 TCO,但这是编译器的限制,而不是语言的限制,并且与 tailrec 无关]


作为参考,以下是您如何避免问题而不使方法final

class C {
  def fact(n: Int): Int = {
    @tailrec
    def loop(n: Int, result: Int): Int =
      if (n == 0) {
        result
      } else {
        loop(n - 1, n * result)
      }

    loop(n, 1)
  }
}

这个方法可行是因为loop是具体函数而不是方法,所以它不能被覆盖。这个版本还有一个优点是删除了fact中多余的result参数。

这是我在所有递归算法中使用的模式。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接