【问题标题】:Why won't the Scala compiler apply tail call optimization unless a method is final?为什么除非方法是最终的,否则 Scala 编译器不会应用尾调用优化?
【发布时间】:2011-06-14 16:54:28
【问题描述】:

为什么 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,因为方法可能不是自递归的,所以不能使用。跨度>

标签: scala tail-recursion tail-call-optimization


【解决方案1】:

考虑以下与 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 不起作用的原因,除非方法是最终的(或私有的)。

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

【讨论】:

  • 可能值得一提的是,“可能不会调用自身”是 Scala 特有的问题,通常与尾调用消除无关。所有这些尾调用都将在 SML、OCaml、F# 等中被消除。
  • 你怎么写fact父母和孩子,这样孩子的预期2×父母的fact
  • @KevinMeredith:我认为这是一个很好的单独问题。
【解决方案2】:

递归调用可能是对子类而不是对超类; 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},因为扩展不适用。

由于这种递归似乎很有用,并且如果允许对非最终方法的尾调用会被破坏,因此编译器会插入一个真正的调用。

【讨论】:

  • 嗯,当我读到你的文章时,给我留下的印象是,“Scala 未能优化,所以它会产生奇怪、疯狂的结果,而不是你想要的结果。”所以我想我最好再提供一个例子来说明为什么 7680 是“正确”的答案。
  • “这种递归似乎很有用”。它是延续传递风格等功能技术的支柱。
  • @JonHarrop - 确实。 CPS 和其他依赖于此的功能性技术似乎很有用。
  • FWIW,这是一个在金融领域使用的例子zbray.com/2011/11/02/…
【解决方案3】:

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(),而不是在尾递归调用上循环,所以它可以自行展开。

【讨论】:

  • 谢谢,约翰。我的回答侧重于结果是什么,但您也正确地指出尾部调用属性也丢失了。
  • 可能值得一提的是它们都是尾递归的,问题是 Scala 只能对其中一个进行尾调用优化(由于 JVM 中缺少尾调用消除)。
  • @JonHarrop,尾调用消除是由编译器完成的,而不是“处理器”(JVM)。 JVM 本质上是一个微处理器,用软件实现,具有便于 Java 的机器语言。 JVM 有一个分支指令(“goto”)和一个子程序调用指令(“jsr”),编译器的工作是决定发出哪条指令。如果 Java 编译器没有进行尾调用优化,那是编译器的错。 (注:21世纪的第二个十年,不能做尾调用优化的生产级编译器一定被认为是绝地求生。)
  • @JohnR.Strohm:“JVM 有一个分支指令(“goto”)和一个子程序调用指令(“jsr”),编译器的工作是决定发出哪条指令”。这仅在函数尾部调用自身的特殊情况下才是正确的。一般来说,尾声可以去任何地方。真正的微处理器具有跳转指令,可以将程序计数器带到任何地方。 JVM goto 指令仅限于当前函数体中的目标。因此,JVM 通常无法原生地表达尾调用。 Scala 编译器因 VM 中的这一缺陷而束手无策。
  • @JonHarrop:我明白你的意思,你是对的。我在考虑尾递归优化,这是一般尾调用优化的一个特例。
【解决方案4】:

如果编译器在这种情况下应用 TCO,究竟会出现什么问题?

什么都不会出错。任何具有适当尾调用消除的语言都可以做到这一点(SML、OCaml、F#、Haskell 等)。 Scala 不支持的唯一原因是 JVM 不支持尾递归,并且 Scala 通常用 goto 替换尾部位置的自递归调用的技巧在这种情况下不起作用。 CLR 上的 Scala 可以像 F# 一样做到这一点。

【讨论】:

  • 嘿,伙计,你能告诉我为什么 f# 在这种情况下会爆炸吗ideone.com/VgJnR6 - ideone 上的单声道会因 sigsegv 而失败,而 win8 中的 tryfsharp 会导致 IE 崩溃
  • @OlegYch Mono 和 TryFSharp 没有进行适当的尾调用消除。启用 TCO 后,您的程序在 .NET 上运行良好。
  • 感谢乔恩,感谢您的反馈!
  • @OlegYch 我建议您使用 Mono 重新尝试您的示例,因为现在它有更好的尾调用支持
  • @knocte 如果 Mono 正确处理尾调用优化,我会感到惊讶,但我会看看。
【解决方案5】:

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

关键是tailrec的要求比TCO的要求更严格

tailrec 注释要求尾调用对同一函数,而TCO 可用于尾调用任何函数

编译器可以在fact 上使用TCO,因为在尾部位置有一个调用。具体来说,它可以通过适当调整堆栈将 callfact 变成 跳转fact。这个版本的fact 和调用的函数不一样没关系。

因此,接受的答案正确地解释了为什么非最终函数不能是 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参数。

这是我用于所有递归算法的模式。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2013-05-08
    • 2012-01-04
    • 1970-01-01
    • 1970-01-01
    • 2020-02-08
    • 2011-12-13
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多