Почему компилятор Scala не применяет оптимизацию хвостового вызова, если только метод не является окончательным?

Почему компилятор Scala не применяет оптимизацию хвостового вызова, если только метод не является окончательным?

Например, это:

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

приводит к

error: не удалось оптимизировать метод @tailrec annotated: он не является ни закрытым, ни окончательным, поэтому его можно переопределить

Что именно пойдет не так, если компилятор применил TCO в таком случае?

Ответ 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 не работает, если метод не является окончательным (или закрытым).

ОБНОВЛЕНИЕ: Я рекомендую также прочитать другие два ответа (Джон и Рекс).

Ответ 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}, так как расширение не будет применяться.

Так как этот вид рекурсии правдоподобен и будет уничтожен, если бы хвост вызывал не конечные методы, то компилятор вместо этого вставлял реальный вызов.

Ответ 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(), чтобы он мог расслабиться.

Ответ 4

Что именно может пойти не так, если компилятор применил TCO в таком случае?

Ничего не получится. Любой язык с правильным устранением вызова хвоста сделает это (SML, OCaml, F #, Haskell и т.д.). Единственной причиной Scala не является то, что JVM не поддерживает хвостовую рекурсию и Scala обычный взлом замены саморекурсивных вызовов в хвостовом положении с помощью goto в этом случае не работает. Scala в CLR может сделать это, как это делает F #.