"La JVM no admite la optimización de llamadas de cola, por lo que predigo muchas pilas explosivas"
Cualquiera que diga esto, ya sea (1) no entiende la optimización de la cola, o (2) no entiende la JVM, o (3) ambos.
Comenzaré con la definición de llamadas de cola de Wikipedia (si no te gusta Wikipedia, aquí hay una alternativa ):
En informática, una llamada de cola es una llamada de subrutina que ocurre dentro de otro procedimiento como su acción final; puede producir un valor de retorno que luego es devuelto inmediatamente por el procedimiento de llamada
En el siguiente código, la llamada a bar()
es la llamada de cola de foo()
:
private void foo() {
// do something
bar()
}
La optimización de la llamada de cola ocurre cuando la implementación del lenguaje, al ver una llamada de cola, no utiliza la invocación de método normal (que crea un marco de pila), sino que crea una rama. Esta es una optimización porque un marco de pila requiere memoria, y requiere ciclos de CPU para insertar información (como la dirección de retorno) en el marco, y porque se supone que el par de llamada / retorno requiere más ciclos de CPU que un salto incondicional.
El TCO a menudo se aplica a la recursividad, pero ese no es su único uso. Tampoco es aplicable a todas las recursiones. El código recursivo simple para calcular un factorial, por ejemplo, no puede ser optimizado, porque lo último que sucede en la función es una operación de multiplicación.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Para implementar la optimización de llamadas de cola, necesita dos cosas:
- Una plataforma que admite la ramificación además de las llamadas de subtrutina.
- Un analizador estático que puede determinar si es posible la optimización de llamadas de cola.
Eso es. Como he señalado en otra parte, la JVM (como cualquier otra arquitectura completa de Turing) tiene un goto. Resulta que tiene un goto incondicional , pero la funcionalidad podría implementarse fácilmente utilizando una rama condicional.
El análisis estático es lo que es complicado. Dentro de una sola función, no hay problema. Por ejemplo, aquí hay una función Scala recursiva de cola para sumar los valores en a List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Esta función se convierte en el siguiente código de bytes:
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
Tenga goto 0
en cuenta el al final. En comparación, una función Java equivalente (que debe usar un Iterator
para imitar el comportamiento de romper una lista Scala en cabeza y cola) se convierte en el siguiente código de bytes. Tenga en cuenta que las dos últimas operaciones son ahora una invocación , seguida de un retorno explícito del valor producido por esa invocación recursiva.
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
La optimización de la llamada de cola de una sola función es trivial: el compilador puede ver que no hay código que use el resultado de la llamada, por lo que puede reemplazar la invocación con a goto
.
Donde la vida se vuelve difícil es si tienes múltiples métodos. Las instrucciones de ramificación de la JVM, a diferencia de las de un procesador de uso general como el 80x86, se limitan a un solo método. Todavía es relativamente sencillo si tiene métodos privados: el compilador es libre de incorporar esos métodos según corresponda, por lo que puede optimizar las llamadas de cola (si se pregunta cómo podría funcionar esto, considere un método común que use un switch
para controlar el comportamiento). Incluso puede extender esta técnica a varios métodos públicos en la misma clase: el compilador integra los cuerpos de los métodos, proporciona métodos de puentes públicos y las llamadas internas se convierten en saltos.
Pero, este modelo se rompe cuando considera los métodos públicos en diferentes clases, particularmente a la luz de las interfaces y cargadores de clases. El compilador de nivel de origen simplemente no tiene suficiente conocimiento para implementar optimizaciones de cola. Sin embargo, a diferencia de las implementaciones "bare-metal", el * JVM (tiene la información para hacer esto, en la forma del compilador Hotspot (al menos, el compilador ex-Sun sí lo tiene). No sé si realmente funciona optimizaciones de llamada de cola, y sospecho que no, pero podría .
Lo que me lleva a la segunda parte de su pregunta, que reformularé como "¿debería importarnos?"
Claramente, si su lenguaje usa la recursividad como su única primitiva para la iteración, le importa. Pero los idiomas que necesitan esta característica pueden implementarla; El único problema es si un compilador para dicho lenguaje puede producir una clase que pueda llamar y ser llamada por una clase arbitraria de Java.
Fuera de ese caso, voy a invitar votos negativos diciendo que es irrelevante. La mayor parte del código recursivo que he visto (y he trabajado con muchos proyectos gráficos) no es optimizable . Al igual que el factorial simple, utiliza la recursividad para construir el estado, y la operación de cola es una combinación.
Para el código que es optimizable en cola, a menudo es sencillo traducir ese código en una forma iterable. Por ejemplo, esa sum()
función que mostré anteriormente se puede generalizar como foldLeft()
. Si observa la fuente , verá que en realidad se implementa como una operación iterativa. Jörg W Mittag tenía un ejemplo de una máquina de estado implementada mediante llamadas de función; Hay muchas implementaciones de máquinas de estado eficientes (y mantenibles) que no dependen de llamadas de funciones que se traducen en saltos.
Terminaré con algo completamente diferente. Si busca en Google las notas al pie en el SICP, puede terminar aquí . Personalmente considero que un lugar mucho más interesante que tener mi compilador sustituir JSR
por JUMP
.