En informatique , un appel terminal est un appel de sous-programme effectué en dernière étape d'une procédure. Si la cible de l'appel terminal est le même sous-programme, on dit que ce dernier est récursif terminal ; il s'agit d'un cas particulier de récursivité directe . La récursivité terminale est particulièrement utile et souvent facile à optimiser dans les implémentations.
Les appels terminaux peuvent être implémentés sans ajouter de nouveau cadre de pile à la pile d'appels . La majeure partie du cadre de la procédure courante n'est plus nécessaire et peut être remplacée par celui de l'appel terminal, modifié si besoin (similaire à la superposition pour les processus, mais pour les appels de fonction). Le programme peut alors exécuter la sous-routine appelée. Produire un tel code au lieu d'une séquence d'appels standard est appelé élimination ou optimisation des appels terminaux . L'élimination des appels terminaux permet d'implémenter les appels de procédure en position terminale aussi efficacement que les instructions GOTO , permettant ainsi une programmation structurée efficace . Selon Guy L. Steele , « en général, les appels de procédure peuvent être utilement considérés comme des instructions GOTO qui passent également des paramètres, et peuvent être codés uniformément comme des instructions JUMP [en code machine] ».
Tous les langages de programmation n'imposent pas l'élimination des appels terminaux. Cependant, dans les langages de programmation fonctionnelle , cette élimination est souvent garantie par la norme du langage , permettant ainsi à la récursivité terminale d'utiliser une quantité de mémoire similaire à celle d'une boucle équivalente . Le cas particulier des appels récursifs terminaux, lorsqu'une fonction s'appelle elle-même, peut se prêter davantage à l'élimination d'appels que les appels terminaux en général. Lorsque la sémantique du langage ne prend pas explicitement en charge les appels terminaux en général, un compilateur peut souvent optimiser les appels frères , c'est-à-dire les appels terminaux à des fonctions qui acceptent et renvoient des types identiques à ceux de l'appelant.
adresse de retour , afin de pouvoir y retourner avec le résultat une fois l'appel terminé. Généralement, cette information est enregistrée sur la pile d'appels , une liste d'adresses de retour dans l'ordre où les adresses d'appel ont été atteintes. De plus, les compilateurs allouent de la mémoire pour les variables locales de la fonction appelée et empilent le contenu des registres (le cas échéant et/ou pertinents). Cela se fait généralement en allouant un cadre de pile incluant les registres sauvegardés, l'espace alloué aux variables locales non-registraires, l'adresse de retour et les paramètres d'appel (sauf s'ils sont passés dans des registres). Pour les appels terminaux, il n'est pas nécessaire de mémoriser l'appelant ni de conserver le contenu des registres ; l'élimination des appels terminaux évite l'allocation de nouveaux cadres de pile et n'apporte que les modifications minimales nécessaires au cadre de pile existant avant de le transmettre, la fonction appelée retournant directement à l' appelant initial . Cependant, cela entraîne la perte complète du cadre de pile de l'appelant, ce qui est parfois considéré comme un obstacle au débogage. L'appel terminal n'a pas besoin d'apparaître lexicalement après toutes les autres instructions du code source ; il importe seulement que la fonction appelante retourne immédiatement après l'appel terminal, en retournant le résultat de l'appel terminal le cas échéant, puisque la fonction appelante est ignorée lors de l'optimisation.Pour les appels de fonctions non récursifs, cette optimisation ne permet généralement qu'un gain minime de temps et d'espace, car le nombre de fonctions appelables est limité. En revanche, pour les fonctions récursives ou mutuellement récursives, où la récursion s'effectue par appels terminaux, l'espace mémoire gagné sur la pile et le nombre de retours économisés peuvent devenir considérables. En effet, une fonction peut s'appeler elle-même, directement ou indirectement, créant ainsi un nouveau cadre de pile à chaque appel. L'élimination des appels terminaux réduit souvent l'espace mémoire asymptotique requis de la pile de manière linéaire ( O (n)) à une manière constante (O(1)). C'est pourquoi elle est exigée par les définitions standard de certains langages de programmation, comme Scheme , et des langages de la famille ML , entre autres. La définition du langage Scheme formalise précisément la notion intuitive de position terminale, en spécifiant les formes syntaxiques autorisant des résultats dans un contexte terminal. Les implémentations permettant un nombre illimité d'appels terminaux actifs simultanément, grâce à l'élimination des appels terminaux, sont qualifiées de « récursives terminales à proprement parler ».
Outre l'efficacité en termes d'espace et d'exécution, l'élimination des appels terminaux est importante dans l' idiome de programmation fonctionnelle connu sous le nom de style de passage de continuation (CPS), qui autrement manquerait rapidement d'espace de pile.
Forme syntaxique
Un appel terminal peut être situé juste avant la fin syntaxique d'une fonction.
;; factorielle : nombre -> nombre ;; pour calculer le produit de tous les entiers positifs inférieurs ou égaux à n. ( définir ( factorielle n ) ( si ( = n 0 ) 1 ( * n ( factorielle ( - n 1 )))))
Ce code n'est pas écrit de manière récursive terminale, car la fonction de multiplication (« * ») se trouve en position terminale. On peut le comparer à :
;; factorielle : nombre -> nombre ;; pour calculer le produit de tous les entiers positifs inférieurs ou égaux à n. ( définir ( factorielle n ) ( fait-iter 1 n )) ( définir ( fait-iter produit n ) ( si ( = n 0 ) produit ( fait-iter ( * produit n ) ( - n 1 ))))
Ce programme suppose une évaluation dans l'ordre applicatif . La procédure interne fact-iters'appelle elle-même en dernier dans le flux de contrôle. Cela permet à un interpréteur ou à un compilateur de réorganiser l'exécution qui ressemblerait normalement à ceci :
appel factorielle (4) appel fact-iter (1 4) appel fact-iter (4 3) appel fact-iter (12 2) appel fact-iter (24 1) retour 24 retour 24 retour 24 retour 24 retour 24dans la variante la plus efficace , tant en termes d'espace que de temps :
appel factorielle (4) appel fact-iter (1 4) remplacer les arguments par (4 3) remplacer les arguments par (12 2) remplacer les arguments par (24 1) retour 24 retour 24Cette réorganisation permet de gagner de l'espace car aucun état, hormis l'adresse de la fonction appelante, n'a besoin d'être sauvegardé, ni sur la pile ni sur le tas. De plus, le cadre de pile d'appels fact-iterest réutilisé pour le stockage des résultats intermédiaires. Ainsi, le programmeur n'a plus à craindre de manquer d'espace sur la pile ou le tas lors de récursions extrêmement profondes. Dans les implémentations classiques, la variante récursive terminale est nettement plus rapide que l'autre, mais seulement d'un facteur constant.
Certains programmeurs travaillant avec des langages fonctionnels réécrivent le code récursif pour le rendre récursif terminal et ainsi tirer parti de cette fonctionnalité. Cela nécessite souvent l'ajout d'un argument « accumulateur » ( productdans l'exemple ci-dessus) à la fonction.
récursion terminale modulo cons
La récursion terminale modulo cons est une généralisation de l'optimisation par récursion terminale introduite par David H.D. Warren dans le contexte de la compilation de Prolog , considéré comme un langage à initialisation explicite . Elle a été décrite (sans être nommée) par Daniel P. Friedman et LISP . Comme son nom l'indique, elle s'applique lorsque la seule opération restante après un appel récursif consiste à ajouter une valeur connue au début de la liste renvoyée (ou, plus généralement, à effectuer un nombre constant d'opérations simples de construction de données). Cet appel serait donc un appel terminal, hormis l' opération cons (« modulo ») . Or, ajouter une valeur au début d'une liste à la sortie d'un appel récursif revient à ajouter cette valeur à la fin de la liste en cours d'agrandissement à l'entrée de l'appel récursif, construisant ainsi la liste comme un effet de bord , à la manière d'un paramètre d'accumulateur implicite. Le fragment Prolog suivant illustre ce concept :
Exemple de code
nœud de liste et l'initialisation de son firstchamp, puis en un appel terminal avec le pointeur vers le champ du nœud restcomme argument, lequel sera rempli récursivement. Le même résultat est obtenu lorsque la récursion est protégée par un constructeur de données évalué paresseusement, ce qui est automatique dans les langages de programmation paresseux comme Haskell.Exemple CLe fragment suivant définit une fonction récursive en C qui duplique une liste chaînée (avec du code Scheme et Prolog équivalent en commentaires, à titre de comparaison) :
|