"La JVM ne prend pas en charge l'optimisation des appels de queue, donc je prédis beaucoup de piles explosives"
Quiconque dit cela (1) ne comprend pas l'optimisation des appels de queue, ou (2) ne comprend pas la JVM, ou (3) les deux.
Je vais commencer par la définition des appels de queue de Wikipedia (si vous n'aimez pas Wikipedia, voici une alternative ):
En informatique, un appel de queue est un appel de sous-programme qui se produit à l'intérieur d'une autre procédure comme son action finale; il peut produire une valeur de retour qui est ensuite immédiatement renvoyée par la procédure d'appel
Dans le code ci-dessous, l'appel à bar()
est l'appel final de foo()
:
private void foo() {
// do something
bar()
}
L'optimisation des appels de queue se produit lorsque l'implémentation du langage, voyant un appel de queue, n'utilise pas l'invocation de méthode normale (qui crée un cadre de pile), mais crée plutôt une branche. Il s'agit d'une optimisation car une trame de pile nécessite de la mémoire, et elle nécessite des cycles CPU pour pousser des informations (telles que l'adresse de retour) sur la trame, et parce que la paire appel / retour est supposée nécessiter plus de cycles CPU qu'un saut inconditionnel.
Le TCO est souvent appliqué à la récursivité, mais ce n'est pas sa seule utilisation. Elle n'est pas non plus applicable à toutes les récursions. Le code récursif simple pour calculer une factorielle, par exemple, ne peut pas être optimisé pour les appels de queue, car la dernière chose qui se produit dans la fonction est une opération de multiplication.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Pour implémenter l'optimisation des appels de queue, vous avez besoin de deux choses:
- Une plate-forme qui prend en charge la branche en plus des appels de sous-routine.
- Un analyseur statique qui peut déterminer si l'optimisation des appels de queue est possible.
C'est ça. Comme je l'ai noté ailleurs, la JVM (comme toute autre architecture complète de Turing) a un goto. Il se trouve qu'il a un goto inconditionnel , mais la fonctionnalité pourrait facilement être implémentée à l'aide d'une branche conditionnelle.
L'élément d'analyse statique est ce qui est délicat. Dans une seule fonction, ce n'est pas un problème. Par exemple, voici une fonction Scala récursive de queue pour additionner les valeurs dans a List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Cette fonction se transforme en le bytecode suivant:
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
Notez le goto 0
à la fin. Par comparaison, une fonction Java équivalente (qui doit utiliser un Iterator
pour imiter le comportement de rupture d'une liste Scala en tête et en queue) se transforme en le bytecode suivant. Notez que les deux dernières opérations sont maintenant un appel , suivi d'un retour explicite de la valeur produite par cet appel récursif.
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' optimisation des appels queue d'une seule fonction est trivial: le compilateur peut voir qu'il n'y a pas de code qui utilise le résultat de l'appel, il peut donc remplacer le Invoke avec goto
.
Là où la vie devient difficile, c'est si vous avez plusieurs méthodes. Les instructions de branchement de la JVM, contrairement à celles d'un processeur à usage général tel que le 80x86, se limitent à une seule méthode. C'est encore relativement simple si vous avez des méthodes privées: le compilateur est libre de les intégrer comme il convient, donc peut optimiser les appels de queue (si vous vous demandez comment cela pourrait fonctionner, envisagez une méthode courante qui utilise un switch
pour contrôler le comportement). Vous pouvez même étendre cette technique à plusieurs méthodes publiques dans la même classe: le compilateur insère les corps de méthode, fournit des méthodes de pont public et les appels internes se transforment en sauts.
Mais, ce modèle tombe en panne lorsque vous considérez les méthodes publiques dans différentes classes, en particulier à la lumière des interfaces et des chargeurs de classe. Le compilateur de niveau source n'a tout simplement pas suffisamment de connaissances pour implémenter les optimisations d'appel de fin. Cependant, contrairement aux implémentations "bare-metal", la * JVM (a les informations pour le faire, sous la forme du compilateur Hotspot (du moins, l'ex-compilateur Sun en a). Je ne sais pas si elle fonctionne réellement optimisations de queue-appel, et ne soupçonnez pas, mais il pourrait .
Ce qui m'amène à la deuxième partie de votre question, que je reformulerai comme «devrions-nous nous en préoccuper?
De toute évidence, si votre langue utilise la récursivité comme unique primitive d'itération, vous vous en souciez. Mais, les langues qui ont besoin de cette fonctionnalité peuvent l'implémenter; le seul problème est de savoir si un compilateur pour ledit langage peut produire une classe qui peut appeler et être appelée par une classe Java arbitraire.
En dehors de ce cas, je vais inviter des votes négatifs en disant que cela n'a pas d'importance. La plupart du code récursif que j'ai vu (et j'ai travaillé avec beaucoup de projets de graphes) n'est pas optimisable en queue d'appel . Comme le factoriel simple, il utilise la récursivité pour construire l'état, et l'opération de queue est une combinaison.
Pour le code qui est optimisable par appel, il est souvent simple de traduire ce code sous une forme itérable. Par exemple, cette sum()
fonction que j'ai montrée précédemment peut être généralisée comme foldLeft()
. Si vous regardez la source , vous verrez qu'elle est en fait implémentée comme une opération itérative. Jörg W Mittag avait un exemple de machine d'état implémentée via des appels de fonction; il existe de nombreuses implémentations de machines à états efficaces (et maintenables) qui ne dépendent pas de la conversion d'appels de fonction en sauts.
Je terminerai avec quelque chose de complètement différent. Si vous recherchez votre chemin à partir des notes de bas de page dans le SICP, vous pourriez vous retrouver ici . Personnellement, je trouve que c'est un endroit beaucoup plus intéressant que de remplacer mon compilateur JSR
par JUMP
.