"A JVM não suporta otimização de chamada de cauda, portanto, prevejo muitas pilhas explosivas"
Qualquer pessoa que diga isso (1) não entende a otimização de chamada de cauda, ou (2) não entende a JVM, ou (3) ambos.
Vou começar com a definição de chamadas finais da Wikipedia (se você não gosta da Wikipedia, aqui está uma alternativa ):
Na ciência da computação, uma chamada de cauda é uma chamada de sub-rotina que acontece dentro de outro procedimento como sua ação final; pode produzir um valor de retorno que é retornado imediatamente pelo procedimento de chamada
No código abaixo, a chamada para bar()
é a chamada final de foo()
:
private void foo() {
// do something
bar()
}
A otimização da chamada final ocorre quando a implementação do idioma, vendo uma chamada final, não usa a invocação normal do método (que cria um quadro de pilha), mas cria uma ramificação. Isso é uma otimização porque um quadro de pilha requer memória e requer ciclos de CPU para enviar informações (como o endereço de retorno) para o quadro e porque se supõe que o par de chamada / retorno requer mais ciclos de CPU do que um salto incondicional.
O TCO é frequentemente aplicado à recursão, mas esse não é seu único uso. Nem é aplicável a todas as recursões. O código recursivo simples para calcular um fatorial, por exemplo, não pode ser otimizado para chamada de cauda, porque a última coisa que acontece na função é uma operação de multiplicação.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Para implementar a otimização da chamada de cauda, você precisa de duas coisas:
- Uma plataforma que suporta ramificação, além de chamadas de subtrotina.
- Um analisador estático que pode determinar se a otimização da chamada de cauda é possível.
É isso aí. Como já observei em outro lugar, a JVM (como qualquer outra arquitetura completa de Turing) tem um salto. Por acaso há um goto incondicional , mas a funcionalidade pode ser facilmente implementada usando uma ramificação condicional.
A parte da análise estática é o que é complicado. Dentro de uma única função, não há problema. Por exemplo, aqui está uma função Scala recursiva de cauda para somar os valores em um List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Essa função se transforma no seguinte código de código:
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
Observe o goto 0
no final. Por comparação, uma função Java equivalente (que deve usar um Iterator
para imitar o comportamento de quebrar uma lista do Scala em um cabeçalho e final) se transforma no seguinte bytecode. Note-se que as duas últimas operações são agora uma invocação , seguido por um retorno explícito do valor produzido por essa invocação 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
Otimização de chamada de cauda de uma única função é trivial: o compilador pode ver que não há nenhum código que utiliza o resultado da chamada, para que ele possa substituir a invocação com um goto
.
Onde a vida fica complicada é se você tiver vários métodos. As instruções de ramificação da JVM, ao contrário das de um processador de uso geral, como o 80x86, são limitadas a um único método. Ainda é relativamente simples se você tiver métodos particulares: o compilador é livre para incorporar esses métodos conforme apropriado, para otimizar as chamadas finais (se você está se perguntando como isso pode funcionar, considere um método comum que use a switch
para controlar o comportamento). Você pode até estender essa técnica a vários métodos públicos da mesma classe: o compilador alinha os corpos do método, fornece métodos de ponte pública e as chamadas internas se transformam em saltos.
Mas, esse modelo é quebrado quando você considera métodos públicos em diferentes classes, principalmente à luz de interfaces e carregadores de classes. O compilador no nível da fonte simplesmente não possui conhecimento suficiente para implementar otimizações de chamada de cauda. No entanto, diferentemente das implementações "bare-metal", a * JVM (possui as informações para fazer isso, na forma do compilador Hotspot (pelo menos, o ex-compilador Sun). Não sei se ele realmente executa otimizações de chamada de cauda, e suspeite que não, mas poderia .
O que me leva à segunda parte da sua pergunta, que vou reformular como "devemos nos importar?"
Claramente, se o seu idioma usa a recursão como único primitivo para a iteração, você se importa. Porém, linguagens que precisam desse recurso podem implementá-lo; o único problema é se um compilador para essa linguagem pode produzir uma classe que pode chamar e ser chamada por uma classe Java arbitrária.
Fora desse caso, vou convidar votos negativos dizendo que é irrelevante. A maior parte do código recursivo que eu vi (e trabalhei com muitos projetos de gráficos) não é otimizável por chamada de cauda . Como o fatorial simples, ele usa recursão para construir o estado, e a operação da cauda é uma combinação.
Para um código otimizável por chamada de cauda, geralmente é simples traduzir esse código em um formato iterável. Por exemplo, essa sum()
função que mostrei anteriormente pode ser generalizada como foldLeft()
. Se você olhar a fonte , verá que ela é realmente implementada como uma operação iterativa. Jörg W Mittag teve um exemplo de uma máquina de estado implementada por meio de chamadas de função; existem muitas implementações de máquinas de estado eficientes (e de manutenção) que não dependem de chamadas de função sendo convertidas em saltos.
Vou terminar com algo completamente diferente. Se você pesquisar no Google a partir de notas de rodapé no SICP, poderá acabar aqui . Eu pessoalmente acho que um lugar muito mais interessante do que ter meu compilador substituir JSR
por JUMP
.