Clojure n'effectue pas lui-même l'optimisation des appels de queue: lorsque vous avez une fonction récursive de queue et que vous souhaitez la faire optimiser, vous devez utiliser le formulaire spécial recur
. De même, si vous avez deux fonctions mutuellement récursives, vous ne pouvez les optimiser qu'en utilisant trampoline
.
Le compilateur Scala est capable d'exécuter le TCO pour une fonction récursive, mais pas pour deux fonctions mutuellement récursives.
Chaque fois que j'ai lu ces limitations, elles ont toujours été attribuées à des limitations intrinsèques au modèle JVM. Je ne connais pratiquement rien des compilateurs, mais cela me laisse un peu perplexe. Permettez-moi de prendre l'exemple de Programming Scala
. Ici la fonction
def approximate(guess: Double): Double =
if (isGoodEnough(guess)) guess
else approximate(improve(guess))
est traduit en
0: aload_0
1: astore_3
2: aload_0
3: dload_1
4: invokevirtual #24; //Method isGoodEnough:(D)Z
7: ifeq
10: dload_1
11: dreturn
12: aload_0
13: dload_1
14: invokevirtual #27; //Method improve:(D)D
17: dstore_1
18: goto 2
Donc, au niveau du bytecode, on a juste besoin de goto
. Dans ce cas, en fait, le travail acharné est effectué par le compilateur.
Quelle facilité de la machine virtuelle sous-jacente permettrait au compilateur de gérer plus facilement le TCO?
En passant, je ne m'attendrais pas à ce que les machines réelles soient beaucoup plus intelligentes que la JVM. Pourtant, de nombreux langages qui se compilent en code natif, comme Haskell, ne semblent pas avoir de problèmes avec l'optimisation des appels de queue (enfin, Haskell peut avoir parfois en raison de la paresse, mais c'est un autre problème).
Maintenant, je ne sais pas grand-chose sur Clojure et peu sur Scala, mais je vais essayer.
Tout d'abord, nous devons faire la différence entre tail-CALLs et tail-RECURSION. La récursivité de la queue est en effet assez facile à transformer en boucle. Avec les appels de queue, c'est beaucoup plus difficile, voire impossible, dans le cas général. Vous devez savoir ce qui est appelé, mais avec le polymorphisme et/ou les fonctions de première classe, vous le savez rarement, donc le compilateur ne peut pas savoir comment remplacer l'appel. Ce n'est qu'au moment de l'exécution que vous connaissez le code cible et pouvez y sauter sans allouer une autre trame de pile. Par exemple, le fragment suivant a un appel de queue et n'a pas besoin d'espace de pile lorsqu'il est correctement optimisé (y compris TCO), mais il ne peut pas être éliminé lors de la compilation pour la JVM:
function forward(obj: Callable<int, int>, arg: int) =
let arg1 <- arg + 1 in obj.call(arg1)
Bien que ce soit juste un peu inefficace ici, il existe des styles de programmation entiers (tels que le style de passage continu ou CPS) qui ont des tonnes d'appels de queue et reviennent rarement. Faire cela sans TCO complet signifie que vous ne pouvez exécuter que de minuscules morceaux de code avant de manquer d'espace sur la pile.
Quelle facilité de la machine virtuelle sous-jacente permettrait au compilateur de gérer plus facilement le TCO?
Une instruction d'appel de queue, comme dans la VM Lua 5.1. Votre exemple ne devient pas beaucoup plus simple. Le mien devient quelque chose comme ça:
Push arg
Push 1
add
load obj
tailcall Callable.call
// implicit return; stack frame was recycled
En tant que sidenote, je ne m'attendrais pas à ce que les machines réelles soient beaucoup plus intelligentes que la JVM.
Vous avez raison, ils ne le sont pas. En fait, ils sont moins intelligents et ne connaissent donc pas (beaucoup) des choses comme les cadres de pile. C'est précisément pourquoi on peut tirer des astuces comme réutiliser l'espace de pile et sauter au code sans pousser une adresse de retour.
Clojure pourrait effectuer une optimisation automatique de la récursivité de queue en boucles: il est certainement possible de le faire sur la JVM comme Scala prouve.
C'était en fait une décision de conception de ne pas le faire - vous devez utiliser explicitement le formulaire spécial recur
si vous voulez cette fonctionnalité. Voir le fil de discussion Re: Pourquoi pas d'optimisation des appels de queue sur le groupe Google Clojure.
Sur la JVM actuelle, la seule chose qui est impossible à faire est l'optimisation des appels de queue entre différentes fonctions (récurrence mutuelle). Ce n'est pas particulièrement complexe à implémenter (d'autres langages comme Scheme ont cette fonctionnalité depuis le début) mais cela nécessiterait des changements dans la spécification JVM. Par exemple, vous devez modifier les règles de conservation de la pile complète d'appels de fonctions.
Une itération future de la machine virtuelle Java est susceptible d'obtenir cette capacité, bien que probablement en option afin de maintenir un comportement de compatibilité descendante pour l'ancien code. Dites, Aperçu des fonctionnalités sur Geeknizer répertorie cela pour Java 9:
Ajout d'appels de queue et de continuations ...
Bien entendu, les futures feuilles de route sont toujours sujettes à changement.
En fin de compte, ce n'est pas si grave de toute façon. En plus de 2 ans de codage Clojure, je n'ai jamais rencontré une situation où le manque de TCO était un problème. Les principales raisons en sont:
recur
ou une boucle. Le cas de récurrence de la queue mutuelle est assez rare dans le code normalEn tant que sidenote, je ne m'attendrais pas à ce que les machines réelles soient beaucoup plus intelligentes que la JVM.
Il ne s'agit pas d'être plus intelligent, mais d'être différent. Jusqu'à récemment, la JVM était conçue et optimisée exclusivement pour un seul langage (Java, évidemment), qui a une mémoire très stricte et des modèles d'appel.
Non seulement il n'y avait ni goto
ni pointeurs, il n'y avait même aucun moyen d'appeler une fonction "nue" (qui n'était pas une méthode définie dans une classe).
Conceptuellement, lors du ciblage de la JVM, un rédacteur de compilateur doit se demander "comment puis-je exprimer ce concept en termes Java termes?". Et évidemment, il n'y a aucun moyen d'exprimer le TCO en Java.
Notez que ceux-ci ne sont pas considérés comme des échecs de JVM, car ils ne sont pas nécessaires pour Java. Dès que Java avait besoin d'une fonctionnalité comme celle-ci, elle est ajoutée à JVM.
Ce n'est que récemment que les autorités Java Java ont commencé à prendre au sérieux JVM en tant que plate-forme pour les langages non Java, elle a donc déjà pris en charge des fonctionnalités qui n'ont pas Java = équivalent Le plus connu est le typage dynamique, qui est déjà en JVM mais pas en Java.
Donc, au niveau du bytecode, on a juste besoin de goto. Dans ce cas, en fait, le travail acharné est effectué par le compilateur.
Avez-vous remarqué que l'adresse de la méthode commence par 0? Que toutes les méthodes d'ensembles commencent par 0? JVM ne permet pas de sauter en dehors d'une méthode.
Je n'ai aucune idée de ce qui se passerait avec une branche avec décalage en dehors de la méthode a été chargée par Java - peut-être qu'elle serait interceptée par le vérificateur de bytecode, peut-être qu'elle générerait une exception, et peut-être sauterait en fait en dehors de la méthode.
Le problème, bien sûr, est que vous ne pouvez pas vraiment garantir où seront les autres méthodes de la même classe, encore moins les méthodes des autres classes. Je doute que JVM donne des garanties sur l'endroit où il chargera les méthodes, mais je serais heureux d'être corrigé.