Chaque fois qu'il y a une discussion sur un nouveau langage de programmation ciblant la JVM, il y a inévitablement des gens qui disent des choses comme:
"La JVM ne prend pas en charge l'optimisation des appels de queue, donc je prédis beaucoup de piles explosives"
Il existe des milliers de variations sur ce thème.
Maintenant, je sais que certains langages, comme Clojure par exemple, ont une construction récurrente spéciale que vous pouvez utiliser.
Ce que je ne comprends pas, c'est: quelle est la gravité du manque d'optimisation des appels de queue? Quand devrais-je m'en inquiéter?
Ma principale source de confusion vient probablement du fait que Java est l'un des langages les plus réussis de tous les temps et que bon nombre des langages JVM semblent plutôt bien fonctionner. Comment est - ce possible si le manque de TCO est vraiment de toute préoccupation?
la source
GOTO
, pas la JVM. Et x86 n'est pas utilisé comme plateforme d'interopérabilité. La JVM n'a pasGOTO
et l'une des principales raisons de choisir la plate-forme Java est l'interopérabilité. Si vous souhaitez implémenter TCO sur la JVM, vous devez faire quelque chose pour la pile. Gérez-le vous-même (c'est-à-dire n'utilisez pas du tout la pile d'appels JVM), utilisez des trampolines, utilisez des exceptions commeGOTO
, quelque chose comme ça. Dans tous ces cas, vous devenez incompatible avec la pile d'appels JVM. Il est impossible d'être compatible avec la pile avec Java, d'avoir un TCO et de hautes performances. Vous devez sacrifier l'un de ces trois.Réponses:
Considérez ceci, disons que nous nous sommes débarrassés de toutes les boucles en Java (les rédacteurs du compilateur sont en grève ou quelque chose). Maintenant, nous voulons écrire factorielle, donc nous pourrions corriger quelque chose comme ça
Maintenant, nous nous sentons assez intelligents, nous avons réussi à écrire notre factorielle même sans boucles! Mais lorsque nous testons, nous remarquons qu'avec un nombre de taille raisonnable, nous obtenons des erreurs de stackoverflow car il n'y a pas de TCO.
En vrai Java, ce n'est pas un problème. Si jamais nous avons un algorithme récursif de queue, nous pouvons le transformer en boucle et être très bien. Mais qu'en est-il des langues sans boucles? Ensuite, vous êtes juste arrosé. C'est pourquoi clojure a cette
recur
forme, sans elle, elle n'est même pas complète (pas moyen de faire des boucles infinies).La classe des langages fonctionnels qui ciblent la JVM, Frege, Kawa (Scheme), Clojure essaient toujours de faire face au manque d'appels de queue, car dans ces langages, TC est la façon idiomatique de faire des boucles! S'il était traduit en schéma, ce factoriel ci-dessus serait un bon factoriel. Ce serait extrêmement gênant si une boucle 5000 fois faisait planter votre programme. Cela peut être contourné cependant, avec
recur
des formulaires spéciaux, des annotations faisant allusion à l'optimisation des appels personnels, au trampoline, etc. Mais ils forcent tous des résultats de performance ou un travail inutile sur le programmeur.Maintenant, Java n'est pas non plus gratuit, car il y a plus de TCO que de récursivité, qu'en est-il des fonctions mutuellement récursives? Ils ne peuvent pas être directement traduits en boucles, mais ne sont toujours pas optimisés par la JVM. Cela rend spectaculairement désagréable d'essayer d'écrire des algorithmes en utilisant la récursivité mutuelle en utilisant Java car si vous voulez des performances / plage décentes, vous devez faire de la magie noire pour qu'il s'intègre dans les boucles.
Donc, en résumé, ce n'est pas énorme pour de nombreux cas. La plupart des appels de queue ne se déroulent que sur un stackframe profond, avec des choses comme
ou sont récursives. Cependant, pour la classe de TC qui ne correspond pas à cela, chaque langage JVM ressent la douleur.
Cependant, il y a une bonne raison pour laquelle nous n'avons pas encore de TCO. La JVM nous donne des traces de pile. Avec le TCO, nous éliminons systématiquement les stackframes que nous savons "condamnés", mais la JVM pourrait en fait en avoir besoin plus tard pour une trace de stack! Imaginons que nous implémentions un FSM comme celui-ci, où chaque état appelle le suivant. Nous effacerions tous les enregistrements des états précédents afin qu'un retraçage nous montre quel état, mais rien sur la façon dont nous y sommes arrivés.
De plus, et de manière plus urgente, une grande partie de la vérification du bytecode est basée sur la pile, éliminant la chose qui nous permet de vérifier que le bytecode n'est pas une perspective agréable. Entre cela et le fait que Java possède des boucles, le TCO semble un peu plus difficile que cela ne vaut aux ingénieurs JVM.
la source
Les optimisations des appels de queue sont principalement importantes en raison de la récursivité de queue. Cependant, il existe un argument expliquant pourquoi il est bon que la JVM n'optimise pas les appels de queue: lorsque TCO réutilise une partie de la pile, une trace de pile à partir d'une exception sera incomplète, ce qui rend le débogage un peu plus difficile.
Il existe des moyens de contourner les limites de la JVM:
Cela peut nécessiter un exemple plus large. Considérez un langage avec des fermetures (par exemple JavaScript ou similaire). On peut écrire la factorielle comme
Maintenant, nous pouvons le faire renvoyer un rappel à la place:
Cela fonctionne maintenant dans un espace de pile constant, ce qui est un peu idiot car il est récursif de toute façon. Cependant, cette technique est capable d'aplatir tous les appels de queue dans un espace de pile constant. Et si le programme est en CPS, cela signifie que la pile d'appels est globalement constante (en CPS, chaque appel est un appel de queue).
Un inconvénient majeur de cette technique est qu'elle est beaucoup plus difficile à déboguer, un peu plus difficile à implémenter et moins performante - voir toutes les fermetures et indirection que j'utilise.
Pour ces raisons, il serait largement préférable que la machine virtuelle implémente un appel de queue - les langages comme Java qui ont de bonnes raisons de ne pas prendre en charge les appels de queue n'auraient pas à l'utiliser.
la source
return foo(....);
dans la méthodefoo
(2), bien sûr. Néanmoins, nous acceptons le suivi incomplet des boucles, des affectations (!), Des séquences d'instructions. Par exemple, si vous trouvez une valeur inattendue dans une variable, vous voulez sûrement savoir comment elle y est arrivée. Mais vous ne vous plaignez pas de manquer de traces dans ce cas. Parce qu'il est en quelque sorte gravé dans notre cerveau que a) cela se produit uniquement sur les appels b) cela se produit sur tous les appels. Les deux n'ont aucun sens, à mon humble avis.Une partie importante des appels dans un programme sont des appels de queue. Chaque sous-programme a un dernier appel, donc chaque sous-programme a au moins un appel de queue. Les appels de queue ont les caractéristiques de performance
GOTO
mais la sécurité d'un appel de sous-programme.Avoir des appels de queue appropriés vous permet d'écrire des programmes que vous ne pourriez pas écrire autrement. Prenons, par exemple, une machine d'état. Une machine à états peut être implémentée très directement en faisant de chaque état un sous-programme et de chaque transition d'état un appel de sous-programme. Dans ce cas, vous passez d'un état à l'autre, en faisant appel après appel après appel, et vous ne revenez jamais ! Sans appels de queue appropriés, vous exploseriez immédiatement la pile.
Sans PTC, vous devez utiliser des
GOTO
trampolines ou des exceptions comme flux de contrôle ou quelque chose comme ça. C'est beaucoup plus laid, et pas tellement une représentation 1: 1 directe de la machine d'état.(Notez comment j'ai habilement évité d'utiliser l'exemple ennuyeux de "boucle". C'est un exemple où les PTC sont utiles même dans un langage avec des boucles.)
J'ai délibérément utilisé le terme «appels de queue appropriés» ici au lieu de TCO. TCO est une optimisation du compilateur. PTC est une fonctionnalité de langage qui nécessite que chaque compilateur effectue le TCO.
la source
The vast majority of calls in a program are tail calls.
Pas si "la grande majorité" des méthodes appelées effectuent plus d'un appel.Every subroutine has a last call, so every subroutine has at least one tail call.
Ceci est trivialement démontrables faux:return a + b
. (Sauf si vous êtes dans un langage insensé où les opérations arithmétiques de base sont définies comme des appels de fonction, bien sûr.)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 ):
Dans le code ci-dessous, l'appel à
bar()
est l'appel final defoo()
: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.
Pour implémenter l'optimisation des appels de queue, vous avez besoin de deux choses:
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
:Cette fonction se transforme en le bytecode suivant:
Notez le
goto 0
à la fin. Par comparaison, une fonction Java équivalente (qui doit utiliser unIterator
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.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 commefoldLeft()
. 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
parJUMP
.la source
return foo(123);
puisse être mieux exécutée par in-liningfoo
que par génération de code pour manipuler la pile et effectuer un saut, mais je ne vois pas pourquoi l'appel de queue serait différent d'un appel ordinaire dans cet égard.