„JVM nie obsługuje optymalizacji wywołania ogona, więc przewiduję wiele eksplodujących stosów”
Każdy, kto powie to: (1) nie rozumie optymalizacji wywołania ogona, lub (2) nie rozumie JVM, lub (3) jedno i drugie.
Zacznę od definicji wezwań ogonowych z Wikipedii (jeśli nie lubisz Wikipedii, oto alternatywa ):
W informatyce wywołanie ogona jest wywołaniem podprogramu, które odbywa się w ramach innej procedury jako jej ostateczne działanie; może wygenerować wartość zwracaną, która jest następnie natychmiast zwracana przez procedurę wywołującą
W poniższym kodzie wezwanie do bar()
jest wywołaniem końcowym foo()
:
private void foo() {
// do something
bar()
}
Optymalizacja wywołania ogona ma miejsce, gdy implementacja języka, widząc wywołanie ogona, nie używa normalnego wywołania metody (która tworzy ramkę stosu), ale zamiast tego tworzy gałąź. Jest to optymalizacja, ponieważ ramka stosu wymaga pamięci i wymaga cykli procesora w celu wypchnięcia informacji (takich jak adres zwrotny) do ramki oraz ponieważ zakłada się, że para wywołania / powrotu wymaga więcej cykli procesora niż bezwarunkowy skok.
TCO jest często stosowane do rekurencji, ale to nie jest jedyne jej zastosowanie. Nie dotyczy to również wszystkich rekurencji. Prostego kodu rekurencyjnego do obliczania silni nie można na przykład zoptymalizować wywołania ogonowego, ponieważ ostatnią rzeczą, która dzieje się w funkcji, jest operacja mnożenia.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Aby wdrożyć optymalizację wezwania ogona, potrzebujesz dwóch rzeczy:
- Platforma obsługująca rozgałęzianie oprócz połączeń podprogramowych.
- Analizator statyczny, który może ustalić, czy możliwa jest optymalizacja wezwania ogona.
Otóż to. Jak zauważyłem gdzie indziej, JVM (jak każda inna architektura Turinga) ma goto. Zdarza się, że ma bezwarunkowe goto , ale funkcjonalność można łatwo zaimplementować za pomocą gałęzi warunkowej.
Fragment analizy statycznej jest trudny. W ramach jednej funkcji nie ma problemu. Na przykład, tutaj jest funkcja Scala rekurencyjna, która sumuje wartości w List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Ta funkcja zmienia się w następujący kod bajtowy:
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
Uwaga goto 0
na końcu. Dla porównania równoważna funkcja Java (która musi Iterator
naśladować zachowanie polegające na rozbiciu listy Scala na głowę i ogon) zamienia się w następujący kod bajtowy. Zauważ, że dwie ostatnie operacje są teraz invoke , po czym następuje wyraźny zwrot wartości wytworzonej przez to rekurencyjne wywołanie.
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
Optymalizacja pojedynczego wywołania ogona jest banalna: kompilator widzi, że nie ma kodu, który wykorzystuje wynik wywołania, więc może zastąpić wywołanie przez goto
.
Życie staje się trudne, jeśli masz wiele metod. Instrukcje rozgałęziające JVM, w przeciwieństwie do instrukcji procesora ogólnego przeznaczenia, takiego jak 80x86, są ograniczone do jednej metody. Nadal jest to stosunkowo proste, jeśli masz metody prywatne: kompilator może dowolnie wstawiać te metody, więc może zoptymalizować wywołania ogona (jeśli zastanawiasz się, jak to może działać, rozważ wspólną metodę, która używa switch
zachowania kontrolującego). Możesz nawet rozszerzyć tę technikę na wiele metod publicznych w tej samej klasie: kompilator wstawia ciała metod, udostępnia publiczne metody pomostowe, a wywołania wewnętrzne zamieniają się w skoki.
Ale model ten psuje się, gdy weźmie się pod uwagę metody publiczne w różnych klasach, szczególnie w świetle interfejsów i programów ładujących klasy. Kompilator na poziomie źródła po prostu nie ma wystarczającej wiedzy, aby wdrożyć optymalizacje wywołania ogona. Jednak w odróżnieniu od implementacji typu „bare-metal” * JVM (posiada odpowiednie informacje, aby to zrobić, w formie kompilatora Hotspot (przynajmniej robi to kompilator ex-Sun). Nie wiem, czy faktycznie wykonuje optymalizacje wywołania ogona i nie podejrzewam, ale może .
Co prowadzi mnie do drugiej części twojego pytania, które sformułuję jako „czy powinno nas to obchodzić?”
Oczywiście, jeśli twój język używa rekurencji jako jedynej prymitywnej iteracji, zależy ci. Ale języki, które potrzebują tej funkcji, mogą ją zaimplementować; jedynym problemem jest to, czy kompilator dla wspomnianego języka może wytworzyć klasę, która może wywoływać i być wywoływana przez dowolną klasę Java.
Poza tym przypadkiem zapraszam głosujących, mówiąc, że to nie ma znaczenia. Większość kodu rekurencyjnego, który widziałem (i pracowałem z wieloma projektami graficznymi), nie można zoptymalizować . Podobnie jak prosta silnia, wykorzystuje rekurencję do budowania stanu, a operacja tail jest kombinacją.
W przypadku kodu, który można zoptymalizować za pomocą wywołania ogona, często łatwo jest przetłumaczyć ten kod na postać iterowalną. Na przykład sum()
funkcję, którą pokazałem wcześniej, można uogólnić jako foldLeft()
. Jeśli spojrzysz na źródło , zobaczysz, że jest ono faktycznie zaimplementowane jako operacja iteracyjna. Jörg W Mittag miał przykład maszyny stanów zaimplementowanej za pomocą wywołań funkcji; istnieje wiele wydajnych (i możliwych do utrzymania) implementacji maszyn stanów, które nie polegają na tłumaczeniu wywołań funkcji na skoki.
Skończę z czymś zupełnie innym. Jeśli przejdziesz do Google od przypisów w SICP, możesz tu skończyć . Ja osobiście uważam, że znacznie ciekawsze miejsce niż o mój kompilator zastąpić JSR
przez JUMP
.