«JVM не поддерживает оптимизацию хвостового вызова, поэтому я предсказываю множество взрывающихся стеков»
Любой, кто говорит это, либо (1) не понимает оптимизацию хвостового вызова, либо (2) не понимает JVM, либо (3) оба.
Я начну с определения хвостовых вызовов из Википедии (если вам не нравится Википедия, вот альтернатива ):
В информатике хвостовой вызов - это вызов подпрограммы, который происходит внутри другой процедуры как ее конечное действие; он может произвести возвращаемое значение, которое затем немедленно возвращается вызывающей процедурой
В приведенном ниже коде вызов bar()
является хвостовым вызовом foo()
:
private void foo() {
// do something
bar()
}
Оптимизация хвостового вызова происходит, когда языковая реализация, видя хвостовой вызов, не использует нормальный вызов метода (который создает фрейм стека), а вместо этого создает ветвь. Это оптимизация, потому что для стекового фрейма требуется память, и для этого требуется, чтобы циклы ЦП выдвигали информацию (например, адрес возврата) в фрейм, а также потому, что для пары вызов / возврат предполагается больше циклов ЦП, чем для безусловного перехода.
TCO часто применяется к рекурсии, но это не единственное ее использование. Это не относится ко всем рекурсиям. Например, простой рекурсивный код для вычисления факториала не может быть оптимизирован хвостовым вызовом, потому что последнее, что происходит в функции - это операция умножения.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Для реализации оптимизации хвостового вызова вам понадобятся две вещи:
- Платформа, которая поддерживает ветвление в дополнение к вызовам подпрограмм.
- Статический анализатор, который может определить, возможна ли оптимизация хвостового вызова.
Вот и все. Как я уже отмечал в другом месте, у JVM (как и у любой другой полной по Тьюрингу архитектуры) есть начало. Это имеет безусловный переход , но функциональность может быть легко реализована с использованием условного перехода.
Часть статического анализа - вот что сложно. В пределах одной функции это не проблема. Например, вот хвостовая рекурсивная функция Scala для суммирования значений в List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Эта функция превращается в следующий байт-код:
public int sum(int, scala.collection.immutable.List);
Code:
0: aload_2
1: invokevirtual #63; //Method scala/collection/immutable/List.isEmpty:()Z
4: ifeq 9
7: iload_1
8: ireturn
9: iload_1
10: aload_2
11: invokevirtual #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
14: invokestatic #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
17: iadd
18: aload_2
19: invokevirtual #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
22: checkcast #59; //class scala/collection/immutable/List
25: astore_2
26: istore_1
27: goto 0
Обратите внимание goto 0
на в конце. Для сравнения, эквивалентная Java-функция (которая должна использовать функцию Iterator
имитации поведения разбиения списка Scala на голову и хвост) превращается в следующий байт-код. Обратите внимание, что последние две операции теперь являются вызовом , за которым следует явный возврат значения, созданного этим рекурсивным вызовом.
public static int sum(int, java.util.Iterator);
Code:
0: aload_1
1: invokeinterface #64, 1; //InterfaceMethod java/util/Iterator.hasNext:()Z
6: ifne 11
9: iload_0
10: ireturn
11: iload_0
12: aload_1
13: invokeinterface #70, 1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
18: checkcast #25; //class java/lang/Integer
21: invokevirtual #74; //Method java/lang/Integer.intValue:()I
24: iadd
25: aload_1
26: invokestatic #43; //Method sum:(ILjava/util/Iterator;)I
29: ireturn
Хвост оптимизации вызова одной функции тривиальна: компилятор может видеть , что не существует кода , который использует результат вызова, так что он может заменить Invoke с goto
.
Жизнь становится сложнее, если у вас есть несколько методов. Инструкции по ветвлению JVM, в отличие от процессора общего назначения, такого как 80x86, ограничены одним методом. Это все еще относительно просто, если у вас есть закрытые методы: компилятор может встраивать эти методы соответствующим образом, поэтому он может оптимизировать хвостовые вызовы (если вам интересно, как это может работать, рассмотрите общий метод, который использует a switch
для управления поведением). Вы даже можете распространить эту технику на несколько открытых методов в одном классе: компилятор вставляет тела методов, предоставляет методы открытого моста, а внутренние вызовы превращаются в переходы.
Но эта модель ломается, когда вы рассматриваете публичные методы в разных классах, особенно в свете интерфейсов и загрузчиков классов. Компилятору исходного уровня просто не хватает знаний для реализации оптимизации хвостовых вызовов. Однако, в отличие от "голых" реализаций, * JVM (имеет информацию для этого в виде компилятора Hotspot (по крайней мере, это делает компилятор ex-Sun). Я не знаю, выполняет ли он на самом деле Оптимизация хвостового вызова, и подозреваю, что нет, но это возможно .
Что подводит меня ко второй части вашего вопроса, которую я перефразирую как «нам все равно?»
Понятно, что если ваш язык использует рекурсию в качестве единственного примитива для итерации, вам все равно. Но языки, которым нужна эта функция, могут ее реализовать; единственная проблема заключается в том, может ли компилятор для указанного языка создать класс, который может вызываться и вызываться произвольным классом Java.
Вне этого случая я собираюсь пригласить отрицательные голоса, говоря, что это не имеет значения. Большая часть рекурсивного кода, который я видел (и я работал со многими графическими проектами) , не оптимизирована с помощью хвостового вызова . Как и простой факториал, он использует рекурсию для построения состояния, а хвостовая операция является комбинацией.
Для кода, оптимизируемого с помощью хвостового вызова, часто просто перевести этот код в итеративную форму. Например, ту sum()
функцию, которую я показал ранее, можно обобщить как foldLeft()
. Если вы посмотрите на источник , вы увидите, что он фактически реализован как итерационная операция. У Jörg W Mittag был пример конечного автомата, реализованного посредством вызовов функций; Есть много эффективных (и поддерживаемых) реализаций конечного автомата, которые не полагаются на вызовы функций, переводимые в переходы.
Я закончу с чем-то совершенно другим. Если вы выберете Google из сносок в SICP, вы можете оказаться здесь . Я лично считаю, что это гораздо более интересное место, чем замена моего компилятора JSR
на JUMP
.