Les fonctions récursives terminales

Il y a quelques temps déjà, je discutais avec un collègue dans ce lieu de socialisation qu’est la machine à café. Nous ne parlions pas de notre travail, mais nous parlions tout de même d’informatique. Nous eûmes l’échange suivant :

  • Les langages fonctionnels, c’est nul ; les fonctions récursives ça peut casser la pile d’exécution.
  • Bah non, si tu utilises une fonction récursive terminale…
  • Une quoi ?

Les mots ne sont pas exacts, mais je crois être fidèle à nos intentions. Depuis j’ai eu d’autres occasions de vérifier que cette notion de programmation était souvent très mal connue. D’où mon idée d’écrire un article sur le sujet.

Qu’est-ce qu’une fonction récursive terminale ?

Une fonction récursive terminale est une fonction qui lorsqu’elle fait appel à elle-même, le fait en tout dernier. Concrètement il n’y a pas de calcul entre l’appel récursif et l’instruction return. Le terme anglais est « tail-end recursion ».

Il s’agit donc d’une manière d’écrire votre fonction récursive.

À quoi ça sert ?

Cela sert à produire du code efficace, clair, et qui ne risque pas de faire déborder la pile d’exécution.

D’un point de vue algorithmique, une fonction récursive terminale est équivalente à une simple boucle. Écrire ainsi vos fonctions récursives permettra au compilateur d’optimiser efficacement le code produit.

Dans un langage impératif, le programmeur a souvent l’idée d’utiliser une boucle, donc la question est moins importante. En revanche, dans un langage fonctionnel comme OCaml ou parallelC#, qui vous invite par sa syntaxe et son esthétisme à user de fonctions, il est primordial d’écrire des fonctions terminales.

Un exemple commenté : la fonction ‘factorielle’ en langage C

Pour illustrer mon propos, j’ai choisi de commenter plusieurs implémentations de la fonction « factorielle », écrites en langage C.

La plupart des programmeurs (dé)formés par des années de pratique du C, préfèrent utiliser une boucle for :

int factorial(int n) {
    int accu=1;
    for(; n; --n) accu *= n;
    return accu;
}

Ce code fonctionne parfaitement bien. Il a l’unique défaut d’être finalement assez éloigné de la définition mathématique de la factorielle, c’est-à-dire que la plupart des gens le jugeront peu naturel.

Considérons maintenant une implémentation plus naturelle, très proche de la définition apprise sur les bancs d’école. C’est aussi la façon dont la plupart des programmeurs débutants écrivent cette fonction :

int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

Cette implémentation récursive est assurément très mauvaise. Elle consomme beaucoup de mémoire en pile, donc c’est lent et ça peut aussi se terminer prématurément si on l’appelle avec un paramètre trop grand (stack overflow). La version récursive terminale pallie précisément ce problème :

int factorial (int n, int accu) {
    if (n == 0) return accu;
    else return factorial(n - 1, n * accu);
}

C’est une tail-end recursion car l’appel récursif est le dernier statement. Vous avez certainement remarqué que pour ce faire, j’ai dû introduire un paramètre supplémentaire ; ceci est discuté plus loin.

Comment ça marche ?

Comme vous le savez certainement, lorsque l’on effectue un appel de fonction, la fonction appelante stocke sur la pile d’exécution les paramètres de la fonction appelée et l’adresse de retour. La fonction appelée utilise ses paramètres pour effectuer ses calculs et, à la toute fin, saute à l’adresse précédemment sauvegardée. On revient alors dans le code de la fonction appelante qui dépile avant de continuer…

Sachant cela, il est facile de comprendre qu’un grand nombre d’appels de fonctions imbriqués fait grossir la pile, et que les fonctions récursives représentent un risque important de débordement de pile (stack overflow).

L’optimisation opérée par le compilateur consiste à produire un code qui, lors de l’appel récursif, au lieu d’empiler… et de dépiler plus tard, va remplacer les paramètres actuels et simplement boucler. Ceci est possible dans le cas particulier d’une fonction récursive terminale car il n’y a plus rien à faire dans la fonction appelante.

En fait cette optimisation n’est pas propre aux fonctions récursives. Toute fonction se terminant par un appel de fonction (appel récursif ou pas) est susceptible de profiter de la même optimisation. On parle de sibling call ou tail call.

Un dessin valant mieux que des mots, voici un exemple d’optimisation de tail call sur une machine windows x86 : la méthode foo se termine par un appel à la méthode bar, donc on libère la place réservée pour l’exécution de foo avant d’exécuter bar.

 As soon as foo          While foo        When foo is about to
   is called            is executing        tail call to bar

                      |              |                     
                      +--------------+                     
                      |              |                     
                      |              |                     
                      | foo's frame  |                     
                      |              |      |              |
                      |              |      +--------------+
|              |      |              |      |return address|
+--------------+      +--------------+      +--------------+
|return address|      |return address|      |              |
+--------------+      +--------------+      |              |
|              |      |              |      |              |
|  stack args  |      |  stack args  |      |  stack args  |
|    of foo    |      |    of foo    |      |    of bar    |
|              |      |              |      |              |
+--------------+      +--------------+      +--------------+
|              |      |              |      |              |
|              |      |              |      |              |
|              |      |              |      |              |
|   Frame of   |      |   Frame of   |      |   Frame of   |
| foo's caller |      | foo's caller |      | foo's caller |
|              |      |              |      |              |
|              |      |              |      |              |

Ce dessin suggère un algorithme assez grossier pour libérer la pile de foo. En fait le compilateur peut faire quelque chose de très subtile grâce à un calcul de dépendances des variables et expressions. On produit alors un code en tout point semblable à la meilleure des boucles (un décompilateur ne pourra pas faire la différence).

Pour ce faire, le compilateur doit effectuer des analyses compliquées. Ces analyses sont biens connues : factorisation, élimination du code mort, une allocation optimales des registres, l’analyse d’échappement, le calcul de causalité (élimination des synchronisations inutiles, invalidation des caches mémoires), et bien d’autres. Cependant elles font toujours l’objet d’une recherche intense. Le défi est d’être capable d’effectuer une optimisation de qualité dans un temps raisonnable (utilisable dans un JIT) et/ou en consommant très peu de mémoire (carte à puce, téléphone). Mais je me perds dans mes digressions, revenons à notre sujet principal.

A-t-on raison de parler d’optimisation ?

Certains auteurs contestent, à tort, le terme d’« optimisation ». L’argument avancé est qu’un programme qui se termine par une erreur stack overflow n’a visiblement pas le même comportement qu’un programme qui se termine en retournant un résultat (ou qui ne se termine pas).

En effet, pour être correcte une optimisation ne doit pas modifier la sémantique d’un programme (et ce n’est pas toujours simple, cf. la gestion de la sécurité, les interactions avec le ramasse miettes, la gestion des exceptions et des signaux etc.) Mais en algèbre de compilation on considère toujours que la machine cible possède une mémoire infinie. Sans cela, la plupart des optimisations ne pourraient pas être effectuées par le compilateur au prétexte que cela pourrait prévenir une erreur au runtime.

Voici un exemple pour les réfractaires :

a = 1 + 1 + 1 + 1; // ça fait 4

La plupart des compilateurs effectuent les calculs à la compilation quand c’est possible. Pourtant, ce faisant on change effectivement le comportement du programme à l’exécution car il est possible que l’évaluation de cette expression provoque un stack overflow dans certaines conditions.

Donc oui c’est une optimisation ; le terme consacré est Tail Call Optimization (TCO).

Que faire du paramètre supplémentaire ?

Comme je l’ai déjà fait remarquer dans l’exemple de la factorielle, pour écrire une fonction récursive sous forme terminale, il est souvent nécessaire de modifier sa signature en y ajoutant un paramètre.

Que faire de ce paramètre ?

  • On peut le considérer comme une astuce qu’il convient de cacher. Dans notre exemple, le paramètre accu joue le même rôle que la variable de boucle res.
  • On peut aussi le considérer comme un paramètre qui fait sens et qui améliore notre fonction. Ainsi dans notre exemple, le second paramètre accu sert à choisir la valeur du cas de base (factorielle(0)).

En général, on souhaite masquer ce paramètre en trop. On définit alors deux fonctions : la première que l’on cache et qui prend deux paramètres, la seconde qui appelle la première et expose la signature voulue. Pour faire cela, il existe diverses méthodes selon le langage (et les paradigmes) utilisé.

On peut définir la fonction récursive dans la fermeture (closure) de la fonction principale. C’est une technique couramment utilisée dans les langages fonctionnels.

(* Exemple en OCaml *)
let factorielle = function n -
    let rec __factorielle = function n - function accu -
        if n = 0
        then accu
        else __factorielle (n-1) (n*accu)
    in __factorielle n 1
;;

Plus classique, on peut restreindre la portée de la fonction récursive. Ainsi en C, on peut utiliser le mot static pour empêcher une fonction d’être exposée dans l’interface du module.

// Exemple en C
static int __factorielle (int n, int accu) {
    if (n == 0) return accu;
    else return __factorielle(n-1, n*accu);
}
int factorielle (int n) {
    return __factorielle(n, 1);
}

La plupart des langages objets offrent des modificateurs d’accès. En Java, on utilisera les modificateurs public et private :

/* Exemple en Java */
public Mathematiques {
    private static __factorielle(int n, int accu) {
        if (n == 0) return accu;
        else return __factorielle(n-1, n*accu);
    }
    public static factorielle (int n) {
        return __factorielle(n, 1);
    }
}

Certains langages, tels que Python ou C#, permettent de donner une valeur par défaut à un paramètre. Ceci est particulièrement utile dans notre cas.

# Exemple en Python
def factorielle(n, accu=1):
    if n == 0: return accu
    else: return factorielle(n-1, n*accu)

Évidemment il n’y a pas une seule bonne manière de faire. Vous devrez juger quelle est la technique la plus pertinente, la mieux adaptée à votre cas. C# est sans doute le langage qui offre le plus de possibilités.

Est-ce que toutes les fonctions récursives peuvent être écrites sous une forme terminale ?

Oui, toute. Les preuves : les théories de la calculabilité, la logique combinatoire, combinateur de point fixe.

Un second exemple commenté : les nombres de Fibonacci

Considérons ensemble une fonction un peu plus difficile à écrire : la fonction qui calcule les nombres de Fibonacci.

Pour écrire la fonction de Fibonacci sous forme récursive terminale, nous avons besoin d’utiliser non pas un, mais deux paramètres supplémentaires (accu0 et accu1). Ceci est à rapprocher du fait que l’implémentation itérative utilise deux variables de boucle (res0 et res1) et du fait que la définition récursive donne deux cas de base (fibonacci(0) et fibonacci(1)).

# Pour changer, voici du Ruby
# Nombre de Fibonacci, implémentation naïve
def fibonacci_naive(n)
    if n == 0
        0
    elsif n == 1
        1
    else
        fibonacci_naive(n-1) + fibonacci_naive(n-2)
    end
end

# Nombre de Fibonacci, implémentation avec une boucle
def fibonacci_boucle(n)
    res0, res1 = 1, 0 # Pour n'avoir qu'un seul test, on commence au rang -1
    (1..n).each do res0, res1 = res1, res0+res1 end
    res1
end

# Nombre de Fibonacci, implémentation récursive terminal
def fibonacci_recursive(n, res0=1, res1=0)
    if n == 0
        res1
    else
        fibonacci_recursive(n-1, res1, res0+res1)
    end
end

La fonction de fibonacci est particulière à bien des égards. L’implémentation naïve, outre le fait de ne pas être terminale, présente le défaut de nous faire calculer de nombreuses fois les mêmes valeurs (par exemple Fibonacci(4) = Fibonacci(3) + Fibonacci(2) et Fibonacci(3) = Fibonacci(2) + Fibonacci(1), donc nous calculons deux fois Fibonacci(2)…)

Ici, l’implémentation récursive terminale apporte beaucoup plus qu’une bonne gestion de la pile d’exécution. C’est souvent le cas. Ceci ne signifie pas qu’il faille systématiquement utiliser des fonctions récursives terminales (par exemple, je trouve plus lisible d’utiliser une syntaxe for each pour parcourir une collection). Mais je crois que l’étude des fonctions terminales est très formatrice, et qu’elle aide les développeurs à concevoir des algorithmes concis et optimaux.

Est-ce que ça marche pour tous les langages ?

Malheureusement non !

En fait, d’un point de vue technique, cela dépend non pas du langage mais du compilateur. Cependant, on peut remarquer que certains langages, tels que F# et OCaml, définissent ce comportement dans leur spécification.

Pour les langages fonctionnels, ne pas optimiser correctement les appels terminaux est généralement considéré comme un bogue. C’est pleinement justifié car ces langages promeuvent l’utilisation de fonctions récursives. En revanche, la chose est plus discutée pour les autres langages. D’ailleurs, nous allons voir que les plateformes Java et .NET ne sont pas très satisfaisantes sur ce point.

Faisons ensemble un petit tour d’horizon parmi les technologies les plus utilisées.

Langage C

La norme C ne dit rien. Mais tous les bons compilateurs (VS, gcc) proposent une option pour effectuer cette optimisation. Voici un extrait de l’aide de gcc, le fameux compilateur C :

-foptimize-sibling-calls
Optimize sibling and tail recursive calls. Enabled at levels -O2, -O3, -Os.

Notons au passage que le choix des options de compilation est vraiment très important. Beaucoup de programmeurs ont l’habitude de compiler le programme de release avec l’option « -O2 » et de déboguer avec les options « -O0 -g ». Ce faisant on change le comportement du programme et il est possible de provoquer une erreur stack overflow qui n’était pas l’objet des tests.

Plateforme Java

Java pour les nuls

Une des techniques essentielles de Java est l’utilisation d’un langage intermédiaire appelé bytecode Java. Un programmes Java est transformé (traduit, compilé) par le JDK en bytecode Java qui pourra être exécuté (interprété ou compilé à la volée et exécuté) par toutes les JVMs.

Un bytecode est un langage neutre, facile à parser et à projeter sur les architectures courantes. Le bytecode Java est un langage à pile exposant certaines abstractions caractéristique du paradigme objet tels que les appels de méthodes virtuelles et statiques.

L’optimisations des tail calls en Java

Le JDK peut optimiser les fonctions récursives terminales en les transformant en simple boucle. Mais il est beaucoup plus intéressant d’optimiser tous les appels de méthodes terminaux (tail calls) car c’est un cas plus général qui traite aussi la récursivité croisée et la programmation par continuation. Mais le JDK ne peut pas optimiser les tail calls en général car le bytecode Java n’offre pas les instructions nécessaires (on ne manipule pas directement la pile et on ne peut pas faire de jump n’importe où). Ceci ne peut donc être fait que par la JVM lors du chargement des classes.

Dites moi ce dont vous avez besoin et je vous expliquerai comment vous en passer

The Java Language Specification ne dit rien au sujet des tail recursive calls.

Chaque vendeur de Java VM fait ce qu’il veut. La JVM fournie par Sun, qui est de fait l’implémentation de référence, n’effectue pas cette transformation au grand damne des faiseurs de langages dynamiques fondés sur Java (script Groovy, JRuby, etc.)

public class Main {
    public static void main(String[] args) { main(args); }
}

Si vous exécutez le programme ci-dessus sur une JVM de Sun, il se terminera toujours rapidement et lamentablement par une exception Stack Overflow. Heureusement certaines implémentations de Java gère les appels de méthodes terminaux, c’est le cas notamment de la JVM d’IBM.

Sun ne communique pas beaucoup autour de ce sujet. On peut tout de même voir que ce problème est noté en very very very low priority (Votez pour ce bug !) Tant que que Sun/Oracle ne supportera pas officiellement les tail recursive calls, je crains qu’il ne nous faille renoncer à ce type de programmation en Java.

Voici quelques billets très intéressants sur le sujet : java closures controversy, non java languages on jvm et tail calls in the VM.

Plateforme .NET

Pour les mêmes raisons qu’en Java, l’optimisation des tail recursive calls peut être effectuée aussi bien par le compilateur que par la plateforme d’exécution (CLR). En revanche, l’optimisation des tail calls ne peut être effectuée que par le CLR, au moment du chargement des bibliothèques.

L’optimisation des tail calls étant requis pour traiter correctement la récursivité croisée et, plus généralement, les continuations, choses couramment utilisée dans les langages fonctionnels, la plateforme .NET qui se veut être une plateforme multi-langages et multi-paradigmes, doit optimiser correctement les tail calls.

Voici ce que dit la documentation de CIL (le langage intermédiaire commun utilisé par .NET) :

CIL ECMA 335 spec “use a tail. prefix to specify that the current stack frame should be released before transferring control. If the call would transfer control to a method of higher trust than the original method the stack frame will not be released.”

Malheureusement, le comportement de la plateforme .NET de Microsoft est loin d’être satisfaisant. Pour des raisons qui me sont obscures (ordre des paramètres sur la pile, compatibilité binaire avec du code natif, concurrence avec le GC), la plate-forme .NET ne sait pas, en général, optimiser correctement les tail calls.

Voici une liste des conditions requises pour que le JIT du CLR2 effectue une TCO :
http://blogs.msdn.com/davbr/pages/tail-call-jit-conditions.aspx. J’ai été étonné par certaines de ces limitations qui me semblent mal fondées. Microsoft s’est visiblement montré très défensif dans les deux premières versions du CLR.

Concrêtement les comportements du compilateur et de la VM dépendent de l’architecture de la machine. L’implémentation de la CLR pour x86, au lieu d’une savante manipulation, utilise une fonction magique et affreusement lente qui décale le sommet de pile (cf. le dessin au début de l’article). Par conséquent, la plupart des compilateurs .NET ne produisent jamais le préfixe tail. et les compilateurs de langages fonctionnels tels que F# ou parallelC# transforment les fonctions récursives terminales en boucles. Mais il s’agit plus d’un workaround qu’autre chose. En effet cela ne traite pas les cas de co-récursivités et plus généralement de la correcte optimisation d’un programme écrit par continuation.

L’implémentation pour x64 est meilleure. En générale, tous les arguments d’une fonction peuvent être stockés dans les registres processeurs car ils sont beaucoup plus nombreux sur cette architecture. Grâce à cela, les performances des tail calls sont acceptables. Par conséquent, sur x64, le CLR effectue des TCO même quand le prefix tail. n’a pas été spécifié dans le code CIL, si les conditions requises sont réunies…

Microsoft a bien conscience du problème. Un effort significatif a été apporté à l’optimisation des tail calls dans la CLR4 sur x86_64. Maintenant les optimisations faciles sont toujours effectives et, pour les rares cas restant, on utilise la fonction magique dont je parlais tantôt afin de toujours tenir compte du prefix .tail dans le code IL.

Oyez, programmeur. F# fonctionne correctement (enfin) sur CLR4/x86_64 !

Remarquez que l’optimisation correcte des tail calls a été l’occasion pour Microsoft d’améliorer son compilateur en supprimant certaines actions inutiles (copie de buffer de values, etc.) Je me répète sûrement : la récursivité aide les développeurs, mêmes les très bons, à concevoir des algorithmes concis et optimaux.

Python

Guido van Rossum, le créateur et leader du langage de programmation Python a publié un billet horrible où il tente d’expliquer pourquoi les TCO sont le mal et ne devraient surtout jamais être effectuées en Python.

Certains des arguments avancés sont tout simplement faux. Vous trouverez quelques arguments bien construits dans les commentaires de cet autre billet.

Python est un langage qui a été pensé pour être facile à utiliser, et non pas pour produire des programmes rapides. En cela je comprends parfaitement que les TCO ne soient pas une priorité, mais il n’y a aucune raison valable pour s’y opposer par principe.

Ruby

La spécification de Ruby ne dit rien au sujet des tail recursive calls.

Yukihiro Matsumoto (le créateur de Ruby) semble être favorable au TCO, mais, il n’a pas souhaité forcer toutes les implémentations de Ruby à le supporter. Voilà une position qui me semble sage ;-).

YARV, qui est l’interpréteur Ruby officielle, supporte le TCO depuis la version 1.9. Notons que c’est très récent et que l’option, considérée encore instable, est désactivée par défaut (il est nécessaire de commenter une ligne et de recompiler l’interpréteur).

Quant à JRuby et Ruby.NET, ils dépendent évidemment des capacités de la plateforme sous-jacentes..

Ceci signifie que vos programmes Ruby ne devraient pas se fonder sur le TCO, car dans ce cas ils ne seront pas portables sur toutes les Ruby VM.