Type récursif
En programmation informatique et thĂ©orie des types, un type rĂ©cursif est un type de donnĂ©es dont la dĂ©finition fait appel au type luiâmĂȘme, de façon rĂ©cursive. Cela permet entre autres des structures de donnĂ©es qui contiennent des sousâstructures du mĂȘme type. Cette notion s'applique naturellement dans l'Ă©tude des listes et des arbres.
Exemples
Types algébriques
Les types algébriques sont de loin la forme la plus courante de types récursifs. Un exemple classique est le type liste. En syntaxe Haskell (avec une variable de type a
qui rend cette définition polymorphe) :
data List a = Nil | Cons a (List a)
Autrement dit, une liste dâĂ©lĂ©ments de type a
est soit une liste vide, soit une cellule contenant une donnée de type a
(la « tĂȘte » de liste) et une autre liste (la « queue » de liste).
Autre exemple, le type des entiers naturels (voir lâarithmĂ©tique de PĂ©ano) peut ĂȘtre dĂ©fini par :
data Nat = Zero | Succ Nat
câestâĂ âdire quâun entier naturel est soit zĂ©ro, soit le successeur dâun entier naturel.
Types récursifs de fonctions
On peut avoir besoin dâune fonction qui se prend elleâmĂȘme en argument. Par exemple, si lâon souhaite Ă©crire le combinateur de point fixe Y, qui permet de dĂ©finir des fonctions rĂ©cursives[1] :
-- ne compile pasâŻ!
fix :: ((a -> b) -> a -> b) -> a -> b
fix f = let g x a = f (x x) a in g g
alors, bien que fix
nâait pas luiâmĂȘme un type rĂ©cursif, sa dĂ©finition en fait intervenir un. En effet, la variable x
est appliquĂ©e Ă elleâmĂȘme donc, si lâon note son type, alors x
est aussi une fonction prenant un argument de type . On obtient alors un type rĂ©cursif dĂ©fini par lâĂ©quation pour un certain type [note 1].
De façon symĂ©trique, il est parfois utile dâĂ©crire une fonction qui se renvoie elleâmĂȘme, ou une autre fonction du mĂȘme type (par exemple pour coder un automate ou un gestionnaire dâĂ©vĂ©nements)[C 1] - [C 2]. Un exemple simplifiĂ© en langage C :
/* syntaxe C pour lâĂ©quation de types «âŻfunction_t = (int â function_t)âŻÂ» ;
* ne compile pasâŻ! */
typedef function_t (*function_t) (int) ;
function_t f (int n)
{
printf("%i", n) ;
return f ;
}
int main (void)
{
f(1)(2)(3) ; /* devrait Ă©crire «âŻ123âŻÂ» */
return 0 ;
}
Si lâon note le type de la fonction f
, alors f
est une fonction qui prend un entier (type ) et se renvoie elleâmĂȘme (type ) ; son type est donc aussi , dâoĂč lâĂ©quation .
En fait, comme expliquĂ© plus loin, de tels types rĂ©cursifs ne sont pas permis tels quels par les langages C ni Haskell. Ces exemples fonctionnent en langage OCaml avec lâoption de compilation -rectypes
[3] :
(* fix : (('a -> 'b) -> 'a -> 'b) -> 'a -> 'b ;
* requiert lâoption de compilation «âŻ-rectypesâŻÂ» *)
let fix f = let g x a = f (x x) a in g g
(* f :âŻ(int -> 'a) as 'a ;
* requiert lâoption de compilation «âŻ-rectypesâŻÂ» *)
let rec f n =
Format.printf "%i" n ;
f
let _ =
f 1 2 3 (* Ă©crit «âŻ123âŻÂ» *)
Théorie
En thĂ©orie des types, un type rĂ©cursif a la forme . Intuitivement, cette notation dĂ©signe le type reprĂ©sentĂ© par lâexpression , dans lequel la variable de type peut apparaĂźtre et dĂ©signe le type luiâmĂȘme[note 2].
Par exemple, si , alors est le type dâune fonction qui prend un entier et renvoie une fonction de mĂȘme type quâelleâmĂȘme. On peut dĂ©rouler cette expression de type, en remplaçant dans la variable par le type (ce quâon note ), ce qui donne . On peut itĂ©rer cette opĂ©ration autant de fois que souhaitĂ© :
Lâexemple ciâdessus des entiers naturels sâĂ©crit , oĂč les deux bras du type somme reprĂ©sentent les constructeurs Zero
et Succ
, avec Zero
ne prenant pas d'argument (représenté par le type unité) et Succ
prenant un élément du type Nat
luiâmĂȘme (reprĂ©sentĂ© par la variable de type ).
Lâexemple des listes dâĂ©lĂ©ments de type , quant Ă lui, sâĂ©crit . Le type unitĂ© reprĂ©sente le constructeur Nil
sans argument, le type produit représente le constructeur Cons
ayant pour arguments une valeur de type et une autre valeur de type . En déroulant :
- , soit
- , soit
- , soit
On obtient successivement le type des listes de longueur zéro (), un (), deux (), trois (), et ainsi de suite.
LâidĂ©e est que le type rĂ©cursif vĂ©rifie lâĂ©quation informelle suivante :
Dans les exemples prĂ©cĂ©dents, elle sâĂ©crit respectivement :
Si lâon sâautorisait les expressions de type infinies, on pourrait dĂ©rouler Ă lâinfini et Ă©crire, respectivement :
- (une fonction curryfiĂ©e acceptant autant dâarguments de type que souhaitĂ©)
- (câest en effet une union disjointe de singletons que lâon peut nommer , , , etc.)
- (lâĂ©toile de Kleene)
Ces expressions infinies sont cependant mal dĂ©finies, car elles ne reflĂštent quâun seul type solution alors que lâĂ©quation de types peut en admettre plusieurs. On rĂ©sout lâambigĂŒitĂ© en choisissant le plus petit ou le plus grand point fixe du dĂ©roulement, comme expliquĂ© dans la section «âŻDonnĂ©es & coâdonnĂ©esâŻÂ».
Pour donner un sens Ă cette Ă©galitĂ© de types, on distingue deux formes de rĂ©cursivitĂ© qui diffĂšrent dans la maniĂšre dâintroduire et dâĂ©liminer les types rĂ©cursifs : lâĂ©quirĂ©cursivitĂ© et lâisorĂ©cursivitĂ©.
Types équirécursifs
Avec des types Ă©quirĂ©cursifs, un type et son dĂ©roulement sont Ă©gaux â c'est-Ă -dire que ces deux expressions de type dĂ©notent le mĂȘme type. En fait, la plupart des thĂ©ories de types Ă©quirĂ©cursifs vont plus loin et Ă©noncent que deux expressions avec le mĂȘme «âŻdĂ©roulement infiniâŻÂ» sont Ă©quivalentes. Cela complique le systĂšme de types, bien plus quâavec des types isorĂ©cursifs. Les problĂšmes algorithmiques tels que la vĂ©rification des types ou l'infĂ©rence de types sont aussi plus difficiles. Comme la simple comparaison syntaxique nâest pas suffisante pour les types Ă©quirĂ©cursifs, il faut les convertir en une forme canonique, ce qui peut se faire en [4].
Les types équirécursifs capturent le caractÚre auto-référent (ou mutuellement référent) des définitions de types dans les langages procéduraux et orientés objet. Ils surviennent aussi dans la sémantique des objets et classes en théorie des types.
Types isorécursifs
Avec des types isorécursifs, un type et son déroulement sont des types distincts et disjoints, néanmoins reliés par un isomorphisme qui se matérialise par des constructions de termes spécifiques. Plus précisément, on dispose de fonctions inverses et .
Les types isorĂ©cursifs sont trĂšs prĂ©sents dans les langages fonctionnels sous la forme de types algĂ©briques. De fait, les langages Haskell et OCaml (sauf avec lâoption de compilation -rectypes
)[3] interdisent la rĂ©cursion dans les alias de type â câestâĂ âdire lâĂ©quirĂ©cursivitĂ©. Ainsi, les dĂ©finitions suivantes sont illĂ©gales en Haskell :
type Bad = (Int, Bad)
type Evil = Bool -> Evil
Cette limitation existe car, Ă lâinstar de typedef
en langage C et contrairement aux types algĂ©briques, les alias de types sont simplement remplacĂ©s par leur dĂ©finition lors de la compilation. Les types algĂ©briques permettent de contourner cette limitation en enveloppant lâexpression rĂ©cursive dans un constructeur :
data Good = Pair Int Good
data Fine = Fun (Bool->Fine)
Une façon de le comprendre est de remarquer que ce niveau dâindirection supplĂ©mentaire explicite les opĂ©rations dâenroulement et de dĂ©roulement du type : la construction dâune valeur (lâapplication dâun constructeur) enroule son type, tandis que sa destruction (par filtrage par motif) le dĂ©roule. Dans le dernier exemple :
roll :: (Bool->Fine) -> Fine
roll x = Fun x
unroll :: Fine -> (Bool->Fine)
unroll (Fun x) = x
Ceci permet dâĂ©crire en Haskell le combinateur de point fixe vu plus haut[1] :
data FunctionTakingItself b = R (FunctionTakingItself b -> b)
fix :: ((a -> b) -> a -> b) -> a -> b
fix f = let g y@(R x) a = f (x y) a in g (R g)
De mĂȘme, on peut Ă©crire en langage C lâexemple de fonction qui se renvoie elleâmĂȘme[C 1] - [C 2] :
/* définition récursive du type encapsulée dans une structure */
struct function_t {
struct function_t (*fun) (int) ;
};
typedef struct function_t function_t ;
/* définition mutuellement récursive de la fonction f et de son encapsulation g */
static function_t g ;
function_t f (int n)
{
printf("%i", n) ;
return g ;
}
static function_t g = { .fun = f } ;
int main (void)
{
g.fun(1).fun(2).fun(3) ; /* Ă©crit «âŻ123âŻÂ» */
return 0 ;
}
DonnĂ©es & coâdonnĂ©es
AlgĂ©briquement, on peut dĂ©finir le type comme un point fixe de la fonction (qui Ă un type associe un type). La question est de choisir ce point fixe. On peut prendre soit le plus petit point fixe, soit le plus grand. On parle parfois de donnĂ©es dans le premier cas et de coâdonnĂ©es dans le second cas. Cette distinction est particuliĂšrement importante en programmation totale (en), oĂč lâon veut garantir la terminaison de tous les calculs[2]. En effet, dans le cas des types algĂ©briques :
- les données sont bien fondées et présentent donc un principe de récurrence, qui permet de les détruire (définir des fonctions sur ces données) récursivement de façon terminante ;
- les coâdonnĂ©es prĂ©sentent un principe de co-rĂ©currence, qui permet de les construire (dĂ©finir de telles valeurs) rĂ©cursivement de façon productive.
Ces deux notions sont duales.
Dans un langage total, ce sont les deux seules formes autorisées de récursivité. Ces deux restrictions visent à garantir un bon comportement des programmes.
- Que les données soient nécessairement finies assure la terminaison des fonctions qui les traitent, grùce au principe de récurrence.
- Lâintroduction des coâdonnĂ©es potentiellement infinies permet de retrouver une forme de nonâterminaison nĂ©cessaire pour rĂ©aliser des programmes Ă boucle dâĂŻnteraction, tels quâun systĂšme dâexploitation, qui rĂ©agissent Ă un flux dâĂ©vĂ©nements extĂ©rieurs. Ici, le bon comportement est garanti par la productivitĂ© des dĂ©finitions coârĂ©cursives : chaque Ă©tape de calcul produit une portion (un coâconstructeur) de coâdonnĂ©e, qui est donc exploitable par la suite.
Afin de ne pas risquer quâune fonction dĂ©finie par rĂ©currence sur une donnĂ©e soit appelĂ©e sur une coâdonnĂ©e infinie, on considĂ©rera distincts les types de donnĂ©es et les types de coâdonnĂ©es, avec deux motsâclĂ©s diffĂ©rents pour les dĂ©finir.
Dans lâexemple prĂ©cĂ©dent des listes, la fonction de types Ă©tait .
- Son plus petit point fixe est , câestâĂ âdire le type des listes finies.
- Son plus grand point fixe est , câestâĂ âdire lâensemble des listes potentiellement infinies.
Si lâon nâavait pas le constructeur Nil
(permettant de fonder une liste), alors la fonction serait et alors :
- le plus petit point fixe est le type vide , ne contenant aucune valeur (sans
Nil
, il est impossible de construire une liste finie) ; - le plus grand point fixe est , le type des listes infinies.
De mĂȘme, dans lâexemple des entiers naturels, on considĂšre :
- le plus petit point fixe est , le type des entiers naturels ;
- le plus grand point fixe est .
Et une remarque analogue sâapplique pour le constructeur Zero
.
Les langages Turingâcomplets usuels ignorent cette nuance et permettent une rĂ©cursivitĂ© non contrĂŽlĂ©e. Il nây a donc quâun seul motâclĂ© pour dĂ©finir un type rĂ©cursif, qui est implicitement le plus grand point fixe. Ainsi en Haskell :
data List a = Nil | Cons a (List a)
on peut utiliser ces listes comme des données, avec une fonction définie par récurrence (cette définition est bien fondée : chaque appel récursif se fait sur des données structurellement plus petites) :
length :: List a -> Int
length Nil = 0
length (Cons _ tail) = 1 + length tail
ou comme des coâdonnĂ©es, avec une dĂ©finition par coârĂ©currence (cette dĂ©finition est productive : chaque itĂ©ration produit un constructeur) :
infinite_list :: List Int
infinite_list = Cons 1 infinite_list -- la liste [ 1, 1, 1, ⊠]
naturals :: List Int
naturals = naturals_from 0 where -- la liste [ 0, 1, 2, ⊠]
naturals_from n = Cons n (naturals_from (n+1))
LâĂ©valuation paresseuse permet dâĂ©crire cette dĂ©finition sans entrer dans une boucle infinie dâĂ©valuation. On voit ainsi que la paresse est essentielle pour construire des coâdonnĂ©es dans un langage total.
Mais on peut Ă©galement Ă©crire des dĂ©finitions rĂ©cursives qui ne sont ni rĂ©currentes ni coârĂ©currentes, quâelles soient pertinentes ou pas. Ainsi lâexemple prĂ©cĂ©dent peut se rĂ©Ă©crire de façon parfaitement valide suivant un idiome courant en Haskell (cette dĂ©finition nâest pas coârĂ©currente au sens le plus courant, Ă cause de lâappel Ă map
) :
naturals :: List Int
naturals = Cons 0 (map succ naturals) -- la liste [ 0, 1, 2, ⊠]
Mais rien ne nous empĂȘche dâĂ©crire des programmes boguĂ©s :
stupid list = stupid (Cons 1 list)
idiot = length naturals
Notes
- La rĂ©cursivitĂ© de types contravariante, dont est un exemple, donne accĂšs Ă la TuringâcomplĂ©tude et Ă la nonâterminaison. En effet, comme on vient de le voir, elle permet de typer le combinateur de point fixe Y grĂące auquel on peut Ă©crire toutes les fonctions rĂ©cursives. On peut aussi Ă©crire plus directement le terme nonâterminant (câestâĂ âdire )[2].
- Comme dans la notation du lambda-calcul, la variable est liĂ©e dans lâexpression ; la lettre nâest pas significative, elle fait partie de la syntaxe.
Références
- (en) « Type checking and recursive types (writing the Y combinator in Haskell/OCaml) », sur programmers.stackexchange.com, (consulté le )
- (en) David A. Turner, « Total Functional Programming », Journal of Universal Computer Science, vol. 10, no 7,â , p. 751â768 (DOI 10.3217/jucs-010-07-0751, lire en ligne [PDF])
- (en) « The OCaml system, §âŻ6.4. Type expressions : Aliased and recursive types », sur caml.inria.fr (consultĂ© le ) : « Recursive types for which there exists a recursive path that does not contain an object or polymorphic variant type constructor are rejected, except when the
-rectypes
mode is selected. » - (en) Nadji Gauthier et François Pottier, « Numbering Matters: First-Order Canonical Forms for Second-Order Recursive Types », Proceedings of the ACM SIGPLAN International Conference on Functional Programming (ICFP), vol. 39, no 9,â , p. 150-161 (DOI 10.1145/1016850.1016872, lire en ligne [PDF])
- (en) « comp.lang.c FAQ list · Question 1.22 : How can I declare a function that can return a pointer to a function of the same type? (âŠ) », sur c-faq.com (consultĂ© le )
- (en) Herb Sutter, « Guru of the Week #57: Recursive declarations : Can you write a function that returns a pointer to itself? If so, why would you want to? », sur gotw.ca, (consulté le )
(en) Cet article contient des extraits de la Free On-line Dictionary of Computing qui autorise l'utilisation de son contenu sous licence GFDL.