Почему компилятор Scala не применяет оптимизацию хвостового вызова, если метод не является окончательным?
Почему компилятор Scala не применяет оптимизацию хвостового вызова, если метод не является окончательным?
Например, это:
class C {
@tailrec def fact(n: Int, result: Int): Int =
if(n == 0)
result
else
fact(n - 1, n * result)
}
результаты в
ошибка: не удалось оптимизировать аннотированный метод @tailrec: он не является ни частным, ни конечным, поэтому может быть переопределен
Что именно пойдет не так, если компилятор применяет TCO в таком случае, как этот?
4 ответа
Рассмотрим следующее взаимодействие с 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
предотвратит это. Но зачем тебе такое поведение? Серия Фибоначчи не дает никаких подсказок. Но это делает:
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}
вместо этого, поскольку расширение не будет применяться.
Так как этот вид рекурсии правдоподобно полезен и будет уничтожен, если будут разрешены хвостовые вызовы для не финальных методов, компилятор вставляет реальный вызов.
Позволять 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()
, так что он может раскрутиться сам.
Что именно пойдет не так, если компилятор применяет TCO в таком случае, как этот?
Ничто не пойдет не так. Любой язык с надлежащим удалением хвостовых вызовов будет делать это (SML, OCaml, F#, Haskell и т. Д.). Единственная причина, по которой Scala этого не делает, заключается в том, что JVM не поддерживает хвостовую рекурсию и обычный взлом Scala замены саморекурсивных вызовов в хвостовой позиции goto
не работает в этом случае. Scala в CLR может делать это так же, как F#.
Популярный и принятый ответ на этот вопрос на самом деле вводит в заблуждение, потому что сам вопрос сбивает с толку. ФП не делает различий между tailrec
а также TCO
и ответ не касается этого.
Ключевым моментом является то, что требования к tailrec
более строгие, чем требования к TCO
,
tailrec
аннотация требует, чтобы хвостовые вызовы выполнялись для одной и той же функции, тогда как TCO
может быть использован на хвостовых вызовах любой функции.
Компилятор может использовать TCO
на fact
потому что есть вызов в хвостовой позиции. В частности, это может повернуть вызов fact
в прыжок в fact
регулируя стек соответствующим образом. Неважно, что эта версия fact
это не то же самое, что функция, выполняющая вызов.
Таким образом, принятый ответ правильно объясняет, почему не финальная функция не может быть tailrec
потому что вы не можете гарантировать, что хвостовые вызовы относятся к одной и той же функции, а не к перегруженной версии функции. Но это неверно подразумевает, что это не безопасно использовать TCO
на этот метод, когда на самом деле это было бы совершенно безопасно и хорошая оптимизация.
[Обратите внимание, что, как объяснил Джон Харроп, вы не можете реализовать TCO
на JVM, но это ограничение компилятора, а не языка, и не связано с 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
это конкретная функция, а не метод и не может быть переопределена. Эта версия также имеет преимущество удаления ложных result
параметр для fact
,
Это шаблон, который я использую для всех рекурсивных алгоритмов.