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

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

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

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

приводит к

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

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


person Seth Tisue    schedule 24.01.2011    source источник
comment
Этот вопрос сбивает с толку TCO, который можно безопасно использовать с этим методом, и более строгий tailrec, который нельзя использовать, потому что метод не может быть саморекурсивным.   -  person Tim    schedule 27.02.2019


Ответы (5)


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

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

person Seth Tisue    schedule 24.01.2011
comment
Возможно, стоит пояснить, что, возможно, не вызывает себя, это специфическая для Scala проблема, которая не имеет ничего общего с устранением хвостовых вызовов в целом. Все эти хвостовые вызовы будут исключены в SML, OCaml, F # и т. Д. - person J D; 31.01.2012
comment
Как вы могли бы написать fact родителя и ребенка так, чтобы дочерний элемент имел 2 × родительских fact? - person Kevin Meredith; 16.04.2014
comment
@KevinMeredith: Думаю, это будет отличный отдельный вопрос. - person Seth Tisue; 16.04.2014

Рекурсивные вызовы могут быть к подклассу, а не к суперклассу; 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}, поскольку расширение не применялось бы.

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

person Rex Kerr    schedule 24.01.2011
comment
Что ж, когда я прочитал ваше, у меня осталось впечатление, что Scala не может оптимизировать, поэтому он может давать странные, сумасшедшие результаты вместо того, что вы хотите. Поэтому я подумал, что мне лучше привести еще один пример, который немного прояснит, почему 7680 - правильный ответ. - person Rex Kerr; 24.01.2011
comment
такой вид рекурсии весьма полезен. Это основа функциональных техник, таких как стиль передачи продолжения. - person J D; 31.01.2012
comment
@JonHarrop - Верно. CPS и другие функциональные методы, основанные на этом, весьма полезны. - person Rex Kerr; 31.01.2012
comment
FWIW, вот пример его использования в финансовом секторе zbray.com/2011/11/02/ - person J D; 31.01.2012

Пусть 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(), чтобы он мог раскрутиться.

person John R. Strohm    schedule 24.01.2011
comment
спасибо, Джон. мой ответ сосредоточен на том, каков будет результат, но вы правы, отметив, что свойство «хвостовой вызов» тоже потеряно. - person Seth Tisue; 24.01.2011
comment
Вероятно, стоит пояснить, что они оба хвостовые рекурсивные, и проблема в том, что Scala может выполнять только хвостовой вызов, оптимизируя один из них (из-за отсутствия исключения хвостовых вызовов в JVM). - person J D; 31.01.2012
comment
@JonHarrop, устранение хвостового вызова выполняется компилятором, а не процессором (JVM). JVM - это, по сути, микропроцессор, реализованный программно, с машинным языком, удобным для Java. JVM имеет инструкцию ветвления (goto) и инструкцию вызова подпрограммы (jsr), и задача КОМПИЛЯТОРА состоит в том, чтобы решить, какую инструкцию передать. Если компилятор Java не выполняет оптимизацию хвостового вызова, это ошибка компилятора. (Примечание: во втором десятилетии 21-го века компилятор производственного уровня, который не может выполнять оптимизацию хвостовых вызовов, должен рассматриваться как безнадежно сломанный.) - person John R. Strohm; 22.10.2014
comment
@ JohnR.Strohm: JVM имеет инструкцию ветвления (goto) и инструкцию вызова подпрограммы (jsr), и это задача КОМПИЛЕРАТОРА решать, какую инструкцию выдать. Это верно только в частном случае, когда хвост функции вызывает сам себя. В общем, хвостовые крики могут идти куда угодно. Настоящие микропроцессоры имеют инструкции перехода, которые могут взять программный счетчик куда угодно. Инструкция JVM goto ограничена целями в текущем теле функции. Таким образом, JVM не может изначально выражать хвостовые вызовы. Компилятор Scala беспомощен и искалечен этим недостатком виртуальной машины. - person J D; 23.10.2014
comment
@JonHarrop: Я понимаю вашу точку зрения, и вы правы. Я думал об оптимизации хвостовой рекурсии, которая является частным случаем общей оптимизации хвостового вызова. - person John R. Strohm; 23.10.2014
comment
@ JohnR.Strohm: FWIW, Sun исследовала возможность включения исключения хвостовых вызовов в Hotspot, но этого не было сделано, и их производство было сосредоточено на динамических языках (например, InvokeDynamic) с тех пор, а не на функциональных языках: blogs.oracle.com/jrose/entry/tail_calls_in_the_vm - person J D; 23.10.2014

Что именно пошло бы не так, если бы компилятор применил совокупную стоимость владения в таком случае?

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

person J D    schedule 12.08.2013
comment
Привет, приятель, ты можешь сказать мне, почему в этом случае взрывается f # ideone.com/VgJnR6 - моно на идеоне не работает с sigsegv и tryfsharp в win8 вылетает из IE - person OlegYch; 13.08.2013
comment
@OlegYch Mono и TryFSharp не выполняют надлежащего устранения хвостовых вызовов. Ваша программа отлично работает в .NET, когда включена TCO. - person J D; 13.08.2013
comment
Спасибо, Джон, ценим ваш отзыв! - person OlegYch; 13.08.2013
comment
@OlegYch Я бы порекомендовал еще раз попробовать ваш образец с Mono, потому что в настоящее время он имеет гораздо лучшую поддержку хвостовых вызовов - person knocte; 13.12.2018
comment
@knocte Я был бы удивлен, если бы Mono правильно справился с оптимизацией хвостовых вызовов, но я посмотрю. - person J D; 13.12.2018
comment
Джейкрелл работал над этим последние месяцы / годы ... обязательно протестируйте последний превью - person knocte; 13.12.2018

Популярный и общепринятый ответ на этот вопрос на самом деле вводит в заблуждение, потому что сам вопрос сбивает с толку. OP не делает различий между 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.

Это шаблон, который я использую для всех рекурсивных алгоритмов.

person Tim    schedule 27.02.2019