"La JVM non supporta l'ottimizzazione delle chiamate in coda, quindi prevedo un sacco di pile esplosive"
Chiunque dica questo (1) non capisce l'ottimizzazione della coda, o (2) non capisce la JVM, o (3) entrambi.
Inizierò con la definizione delle chiamate di coda da Wikipedia (se non ti piace Wikipedia, ecco un'alternativa ):
Nell'informatica, una chiamata di coda è una chiamata di subroutine che avviene all'interno di un'altra procedura come azione finale; può produrre un valore di ritorno che viene quindi immediatamente restituito dalla procedura di chiamata
Nel codice seguente, la chiamata a bar()
è la coda di foo()
:
private void foo() {
// do something
bar()
}
L'ottimizzazione delle chiamate di coda si verifica quando l'implementazione del linguaggio, vedendo una chiamata di coda, non utilizza il normale richiamo del metodo (che crea un frame di stack), ma crea invece un ramo. Questa è un'ottimizzazione perché un frame stack richiede memoria e richiede cicli CPU per inviare informazioni (come l'indirizzo di ritorno) sul frame e poiché si presume che la coppia call / return richieda più cicli CPU rispetto a un salto incondizionato.
Il TCO viene spesso applicato alla ricorsione, ma non è l'unico utilizzo. Né è applicabile a tutte le ricorsioni. Il semplice codice ricorsivo per calcolare un fattoriale, ad esempio, non può essere ottimizzato per la coda, poiché l'ultima cosa che accade nella funzione è un'operazione di moltiplicazione.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Per implementare l'ottimizzazione delle chiamate di coda, hai bisogno di due cose:
- Una piattaforma che supporta la diramazione oltre alle chiamate di subtroutine.
- Un analizzatore statico in grado di determinare se è possibile l'ottimizzazione delle chiamate in coda.
Questo è tutto. Come ho notato altrove, la JVM (come qualsiasi altra architettura completa di Turing) ha un goto. Capita di avere un goto incondizionato , ma la funzionalità potrebbe essere facilmente implementata usando un ramo condizionale.
Il pezzo di analisi statica è ciò che è difficile. All'interno di una singola funzione, non è un problema. Ad esempio, ecco una funzione Scala ricorsiva della coda per sommare i valori in a List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Questa funzione si trasforma nel seguente bytecode:
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
Nota goto 0
alla fine. In confronto, una funzione Java equivalente (che deve usare un Iterator
per imitare il comportamento di spezzare un elenco Scala in testa e coda) si trasforma nel seguente bytecode. Si noti che le ultime due operazioni sono ora invocate , seguite da un ritorno esplicito del valore prodotto da quella chiamata ricorsiva.
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
L'ottimizzazione delle chiamate di coda di una singola funzione è banale: il compilatore può vedere che non esiste un codice che utilizza il risultato della chiamata, quindi può sostituire l' invocazione con a goto
.
Dove la vita diventa complicata è se hai più metodi. Le istruzioni di diramazione della JVM, a differenza di quelle di un processore generico come 80x86, sono limitate a un singolo metodo. È ancora relativamente semplice se hai metodi privati: il compilatore è libero di incorporare quei metodi nel modo appropriato, quindi puoi ottimizzare le chiamate di coda (se ti stai chiedendo come potrebbe funzionare, considera un metodo comune che usa un switch
per controllare il comportamento). Puoi anche estendere questa tecnica a più metodi pubblici nella stessa classe: il compilatore allinea i corpi del metodo, fornisce metodi di bridge pubblici e le chiamate interne si trasformano in salti.
Tuttavia, questo modello si interrompe quando si considerano i metodi pubblici in diverse classi, in particolare alla luce delle interfacce e dei programmi di caricamento classi. Il compilatore a livello di sorgente semplicemente non ha abbastanza conoscenze per implementare le ottimizzazioni di coda. Tuttavia, a differenza delle implementazioni "bare-metal", il * JVM (ha le informazioni per farlo, nella forma del compilatore Hotspot (almeno, il compilatore ex-Sun lo fa). Non so se effettivamente esegua ottimizzazioni di coda, e sospetto di no, ma potrebbe .
Il che mi porta alla seconda parte della tua domanda, che riformulerò come "dovremmo preoccuparci?"
Chiaramente, se la tua lingua usa la ricorsione come unica primitiva per l'iterazione, te ne importa. Ma le lingue che necessitano di questa funzionalità possono implementarla; l'unico problema è se un compilatore per detto linguaggio può produrre una classe che può chiamare ed essere chiamata da una classe Java arbitraria.
Al di fuori di quel caso, inviterò i voti negativi dicendo che è irrilevante. La maggior parte del codice ricorsivo che ho visto (e ho lavorato con molti progetti grafici) non è ottimizzabile in coda . Come il semplice fattoriale, utilizza la ricorsione per costruire lo stato e l'operazione di coda è una combinazione.
Per un codice ottimizzabile per le chiamate in coda, è spesso semplice tradurre quel codice in una forma iterabile. Ad esempio, quella sum()
funzione che ho mostrato in precedenza può essere generalizzata come foldLeft()
. Se guardi la fonte , vedrai che è effettivamente implementata come un'operazione iterativa. Jörg W Mittag aveva un esempio di macchina a stati implementata tramite chiamate di funzione; ci sono molte implementazioni efficienti (e gestibili) della macchina a stati che non si basano sulle chiamate di funzione tradotte in salti.
Finirò con qualcosa di completamente diverso. Se fai Google dalle note a piè di pagina nel SICP, potresti finire qui . Personalmente trovo che un posto molto più interessante che avere il mio compilatore sostituire JSR
da JUMP
.