Loading AI tools
système formel de la logique mathématique De Wikipédia, l'encyclopédie libre
Le lambda-calcul (ou λ-calcul) est un système formel inventé par Alonzo Church dans les années 1930. Ce système fonde les concepts de fonction et d'application qui forment un cadre pour manipuler des expressions appelées λ-expressions. La lettre grecque λ est utilisée pour lier une variable. Par exemple, si M est une λ-expression, représentant la fonction qui associe à x l'expression M.
Le λ-calcul a été le premier formalisme pour définir et caractériser les fonctions récursives. Il revêt donc une grande importance dans la théorie de la calculabilité, au même titre que les machines de Turing[1] et le modèle de Herbrand-Gödel. Depuis, il a été appliqué comme langage de programmation théorique et comme métalangage pour la démonstration formelle assistée par ordinateur. Le lambda-calcul peut être typé ou non.
Le lambda-calcul est apparenté à la logique combinatoire de Haskell Curry et se généralise dans les calculs de substitutions explicites.
L'idée de base du lambda-calcul est que tout est fonction. Une fonction est exprimée par une λ-expression qui peut contenir des variables représentant des fonctions qui ne sont pas encore définies. Ces variables servent de place-holders pour ces fonctions. Il existe une opération fondamentale dans le lambda-calcul, appelée application. L'application consiste à appliquer une fonction à un argument. Si l'expression (qui décrit une fonction) est appliquée à l'expression (qui décrit également une fonction), cela se note .
On peut aussi fabriquer des fonctions en disant que si est une expression[2], on crée la fonction qui à fait correspondre l'expression . On écrit cette nouvelle fonction[3].
Le nom de la variable n'est pas plus important qu'il ne l'est dans une expression comme qui est équivalente à . Autrement dit si est l'expression dans laquelle toutes les occurrences de ont été renommées en , on considérera que les expressions et sont équivalentes.
En utilisant les outils dont on vient de se doter, on obtient, par applications et abstractions, des fonctions assez compliquées que l'on peut vouloir simplifier ou évaluer. Simplifier une application de la forme (λx.E) P revient à la transformer en une variante de l'expression E dans laquelle toute occurrence libre de x est remplacée par P. Cette forme de simplification s'appelle une contraction (ou une β-contraction si l'on veut rappeler que l'on applique la règle β du lambda-calcul).
Sur cette base, on peut construire quelques fonctions intéressantes, comme la fonction identité , qui est la fonction qui à fait correspondre , autrement dit la fonction . On peut aussi construire la fonction constante égale à , à savoir .
De là on peut construire la fonction qui fabrique les fonctions constantes, pourvu qu'on lui donne la constante comme paramètre, autrement dit la fonction , c'est-à-dire la fonction qui à fait correspondre la fonction constamment égale à .
On peut aussi par exemple construire une fonction qui permute l'utilisation des paramètres d'une autre fonction, plus précisément si est une expression, on voudrait que fonctionne comme . La fonction est la fonction . Si on applique la fonction à on obtient que l'on peut simplifier en .
Jusqu'à maintenant nous avons été assez informels[4]. L'idée du lambda-calcul consiste à fournir un langage précis pour décrire les fonctions et les simplifier.
Le lambda calcul définit des entités syntaxiques que l'on appelle des lambda-termes (ou parfois aussi des lambda expressions) et qui se rangent en trois catégories :
L’application peut être vue ainsi : si est une fonction et si est son argument, alors est le résultat de l'application à de la fonction .
L’abstraction peut être interprétée comme la formalisation de la fonction qui, à , associe , où contient en général des occurrences de .
Ainsi, la fonction[5] qui prend en paramètre le lambda-terme et lui ajoute 2 (c'est-à-dire en notation mathématique courante la fonction ) sera dénotée en lambda-calcul par l'expression . L'application de cette fonction au nombre 3 s'écrit (λ x.x+2)3 et s'« évalue » (ou se normalise) en l'expression 3+2.
Alonzo Church connaissait la relation entre son calcul et celui des Principia Mathematica de Russell et Whitehead. Or ceux-ci utilisent la notation pour noter l'abstraction, mais Church utilisa à la place la notation qui devint par la suite [6]. Peano a lui aussi défini l'abstraction dans son Formulaire de mathématique[7], il utilise notamment la notation, pour noter la fonction telle que [8].
Pour délimiter les applications, on utilise des parenthèses, mais par souci de clarté, on omet certaines parenthèses. Par exemple, on écrit x1 x2 ... xn pour ((x1 x2) ... xn).
Il y a en fait deux conventions :
Shönfinkel et Curry ont introduit la curryfication : c'est un procédé pour représenter une fonction à plusieurs arguments. Par exemple, la fonction qui au couple (x, y) associe u est considérée comme une fonction qui, à x, associe une fonction qui, à y, associe u. Elle est donc notée : λx.(λy.u). Cela s'écrit aussi λx.λy.u ou λxλy.u ou tout simplement λxy.u. Par exemple, la fonction qui, au couple (x, y) associe x+y sera notée λx.λy.x+y ou plus simplement λxy.x+y.
Dans les expressions mathématiques en général et dans le lambda calcul en particulier, il y a deux catégories de variables : les variables libres et les variables liées (ou muettes). En lambda-calcul, une variable est liée[9] par un λ. Une variable liée a une portée[10] et cette portée est locale. De plus, on peut renommer une variable liée sans changer la signification globale de l'expression entière où elle figure. Une variable qui n'est pas liée est dite libre.
Par exemple dans l'expression , la variable est libre, mais la variable est liée (par le ). Cette expression est « la même » que car était un nom local, tout comme l'est . Par contre ne correspond pas à la même expression car le est libre.
Tout comme l'intégrale lie la variable d'intégration, le lie la variable qui le suit.
Exemples:
On définit l'ensemble VL(t) des variables libres d'un terme t par récurrence :
Un terme qui ne contient aucune variable libre est dit clos (ou fermé). On dit aussi que ce lambda-terme est un combinateur (d'après le concept apparenté de logique combinatoire).
Un terme qui n'est pas clos est dit ouvert.
L'outil le plus important pour le lambda-calcul est la substitution qui permet de remplacer, dans un terme, une variable par un terme. Ce mécanisme est à la base de la réduction qui est le mécanisme fondamental de l'évaluation des expressions, donc du « calcul » des lambda-termes.
La substitution dans un lambda terme t d'une variable x par un terme u est notée t[x := u]. Il faut prendre quelques précautions pour définir correctement la substitution afin d'éviter le phénomène de capture de variable qui pourrait, si l'on n'y prend pas garde, rendre liée une variable qui était libre avant que la substitution n'ait lieu.
Par exemple, si u contient la variable libre y et si x apparaît dans t comme occurrence d'un sous terme de la forme λy.v, le phénomène de capture pourrait apparaître. L'opération t[x := u] s'appelle la substitution dans t de x par u et se définit par récurrence sur t :
Remarque : dans le dernier cas on fera attention à ce que y ne soit pas une variable libre de u. En effet, elle serait alors « capturée » par le lambda externe. Si c'est le cas, on renomme y et toutes ses occurrences dans v par une variable z qui n'apparaît ni dans t ni dans u.
L'α-conversion identifie λy.v et λz.v[y := z]. Deux lambda-termes qui ne diffèrent que par un renommage (sans capture) de leurs variables liées sont dits α-convertibles. L'α-conversion est une relation d'équivalence entre lambda-termes.
Exemples :
Remarque : l'α-conversion doit être définie avec précaution avant la substitution. Ainsi dans le terme λx.λy.xyλz.z, on ne pourra pas renommer brutalement x en y (on obtiendrait λy.λy.yyλz.z) en revanche on peut renommer x en z.
Définie ainsi, la substitution est un mécanisme externe au lambda-calcul, on dit aussi qu'il fait partie de la méta-théorie. À noter que certains travaux visent à introduire la substitution comme un mécanisme interne au lambda-calcul, conduisant à ce qu'on appelle les calculs de substitutions explicites.
Une manière de voir les termes du lambda-calcul consiste à les concevoir comme des arbres ayant des nœuds binaires (les applications), des nœuds unaires (les λ-abstractions) et des feuilles (les variables). Les réductions[11] ont pour but de modifier les termes, ou les arbres si on les voit ainsi ; par exemple, la réduction de (λx.xx)(λy.y) donne (λy.y)(λy.y).
On appelle rédex un terme de la forme (λx.u) v, où u et v sont des termes et x une variable. On définit la bêta-contraction (ou β-contraction) de (λx.u) v comme u[x := v]. On dit qu'un terme C[u] se réduit[12] en C[u'] si u est un rédex qui se β-contracte en u', on écrit alors C[u]→C[u'], la relation → est appelée contraction.
Exemple de contraction :
(λx.xy)a donne (xy)[x := a] = ay.
On note →* la fermeture réflexive transitive[13] de la relation de contraction → et on l'appelle réduction. Autrement dit, une réduction est une suite finie, éventuellement vide, de contractions.
On définit =β comme la fermeture réflexive symétrique et transitive de la contraction et elle est appelée bêta-conversion, β-conversion, ou plus couramment bêta-équivalence ou β-équivalence.
La β-équivalence permet par exemple de comparer des termes qui ne sont pas réductibles l'un envers l'autre, mais qui après une suite de β-contractions arrivent au même résultat. Par exemple (λy.y)x =β (λy.x)z car les deux expressions se contractent pour donner x.
Formellement, on a M =β M' si et seulement si ∃ N1, ..., Np tels que M = N1, M'=Np et, pour tout i inférieur à p et supérieur à 0, Ni→ Ni+1 ou Ni+1→ Ni .
Cela signifie que dans une conversion on peut appliquer des réductions ou des relations inverses des réductions (appelées expansions).
On définit également une autre opération, appelée êta-réduction[14], définie ainsi : λx.ux →η u, lorsque x n'apparait pas libre dans u. En effet, ux s'interprète comme l'image de x par la fonction u. Ainsi, λx.ux s'interprète alors comme la fonction qui, à x, associe l'image de x par u, donc comme la fonction u elle-même.
Le calcul associé à un lambda-terme est la suite de réductions qu'il engendre. Le terme est la description du calcul et la forme normale du terme[15] (si elle existe) en est le résultat.
Un lambda-terme t est dit en forme normale si aucune bêta-contraction ne peut lui être appliquée, c'est-à-dire si t ne contient aucun rédex, ou encore s'il n'existe aucun lambda-terme u tel que t → u. La structure syntaxique des termes en forme normale est décrite plus loin.
λx.y(λz.z(yz))
On dit qu'un lambda-terme t est normalisable s'il existe un terme u auquel on ne peut appliquer aucune bêta-contraction et tel que t =β u. Un tel u est appelé la forme normale de t.
On dit qu'un lambda-terme t est fortement normalisable si toutes les réductions à partir de t sont finies.
Posons Δ ≡ λx.xx.
Si un terme est fortement normalisable, alors il est normalisable.
Théorème de Church-Rosser : soient t et u deux termes tels que t =β u. Il existe un terme v tel que t →* v et u →* v.
Théorème du losange (ou de confluence) : soient t, u1 et u2 des lambda-termes tels que t →* u1 et t →* u2. Alors il existe un lambda-terme v tel que u1 →* v et u2 →* v.
Grâce au Théorème de Church-Rosser on peut facilement montrer l’unicité de la forme normale ainsi que la cohérence du lambda-calcul (c'est-à-dire qu'il existe au moins deux termes distincts non bêta-convertibles).
On peut décrire la structure des termes en forme normale qui forment l'ensemble . Pour cela on décrit des termes dits neutres qui forment l'ensemble . Les termes neutres sont les termes dans lesquels une variable (par exemple ) est appliquée à des termes en forme normale. Ainsi, par exemple, est neutre si ... sont en forme normale. Les termes en forme normale sont les termes neutres précédés de zéro, un ou plusieurs λ, autrement dit, des abstractions successives de termes en forme normale. Ainsi est en forme normale. On peut décrire les termes en forme normale par une grammaire.
Exemples : est neutre, donc aussi en forme normale. est en forme normale. est neutre. est en forme normale. Par contre, n'est pas en forme normale car il n'est pas neutre et il n'est pas une abstraction d'un terme en forme normale, mais aussi parce qu'il est lui-même un β-rédex, donc β-réductible.
Sur la syntaxe et la réduction du lambda-calcul on peut adapter différents calculs en restreignant plus ou moins la classe des termes. On peut ainsi distinguer deux grandes classes de lambda-calculs : le lambda-calcul non typé et les lambda-calculs typés. Les types sont des annotations des termes qui ont pour but de ne garder que les termes qui sont normalisables, éventuellement en adoptant une stratégie de réduction. On espère[16] ainsi avoir un lambda-calcul qui satisfait les propriétés de Church-Rosser et de normalisation.
La correspondance de Curry-Howard relie un lambda calcul typé à un système de déduction naturelle. Elle énonce qu'un type correspond à une proposition et un terme correspond à une preuve, et réciproquement.
Des codages simulent les objets usuels de l'informatique dont les entiers naturels, les fonctions récursives et les machines de Turing. Réciproquement le lambda-calcul peut être simulé par une machine de Turing. Grâce à la thèse de Church on en déduit que le lambda-calcul est un modèle universel de calcul.
Dans la partie Syntaxe, nous avons vu qu'il est pratique de définir des primitives. C'est ce que nous allons faire ici.
vrai = λab.a
faux = λab.b
Ceci n'est que la définition d'un codage, et l'on pourrait en définir d'autres.
Nous remarquons que : vrai x y →* x et que : faux x y →* y
Nous pouvons alors définir un lambda-terme représentant l'alternative: if-then-else. C'est une fonction à trois arguments, un booléen b et deux lambda termes u et v, qui retourne le premier si le booléen est vrai et le second sinon.
ifthenelse = λbuv.(b u v).
Notre fonction est bien vérifiée: ifthenelse vrai x y = (λbuv.(b u v)) vrai x y ; ifthenelse vrai x y → (λuv.(vrai u v)) x y ; ifthenelse vrai x y →* (vrai x y) ; ifthenelse vrai x y →* ( (λab.a) x y) ; ifthenelse vrai x y →* x.
On verra de la même manière que ifthenelse faux x y →* y.
À partir de là nous avons aussi un lambda-terme pour les opérations booléennes classiques : non = λb.ifthenelse b faux vrai ; et = λab.ifthenelse a b faux (ou bien λab.ifthenelse a b a) ; ou = λab.ifthenelse a vrai b (ou bien λab.ifthenelse a a b).
Ce qui suit est un codage en lambda-calcul des entiers que l'on appelle entiers de Church, du nom de leur concepteur. On pose : 0 = λfx.x, 1 = λfx.f x, 2 = λfx.f (f x), 3 = λfx.f (f (f x)) et d'une manière générale : n = λfx.f (f (...(f x)...)) = λfx.f nx avec f itérée n fois.
Ainsi, l'entier n est vu comme la fonctionnelle qui, au couple ≺f, x≻, associe f n(x).
Il y a deux manières de coder la fonction successeur, soit en ajoutant un f en tête, soit en queue. Au départ nous avons n = λfx.f n x et nous voulons λfx.f n+1 x. Il faut pouvoir rajouter un f soit au début des f (« sous » les lambdas), soit à la fin.
Les autres fonctions sont construites avec le même principe. Par exemple l'addition, en « concaténant » les deux lambda-termes : λnpfx.n f (p f x).
Pour coder la multiplication, on utilise le f pour « propager » une fonction sur tout le terme : λnpf.n (p f)
Le prédécesseur et la soustraction ne sont pas simples non plus. On en reparlera plus loin.
On peut définir le test à 0 ainsi : if0thenelse = λnab.n (λx.b) a, ou bien en utilisant les booléens λn.n (λx.faux) vrai.
Définissons d'abord une fonction d'itération sur les entiers : itère = λnuv.n u v
v est le cas de base et u une fonction. Si n est nul, on calcule v, sinon on calcule u n(v).
Par exemple l'addition qui provient des équations suivantes
peut être définie comme suit. Le cas de base v est le nombre p, et la fonction u est la fonction successeur. Le lambda-terme correspondant au calcul de la somme est donc : add = λnp.itère n successeur p. On remarquera que add n p →* n successeur p.
On peut coder des couples par c = λz.z a b où a est le premier élément et b le deuxième. On notera ce couple (a, b). Pour accéder aux deux parties on utilise π1 = λc.c (λab.a) et π2 = λc.c (λab.b). On reconnaîtra les booléens vrai et faux dans ces expressions et on laissera le soin au lecteur de réduire π1(λz.z a b) en a.
On peut remarquer qu'un entier est une liste dont on ne regarde pas les éléments, en ne considérant donc que la longueur. En rajoutant une information correspondant aux éléments, on peut construire les listes d'une manière analogue aux entiers : [a1 ; ... ; an] = λgy. g a1 (... (g an y)...). Ainsi : [] = λgy.y ; [a1] = λgy.g a1 y ; [a1 ; a2] = λgy.g a1 (g a2 y).
De la même manière qu'on a introduit une itération sur les entiers on introduit une itération sur les listes. la fonction liste_it se définit par λlxm.l x m comme pour les entiers. Le concept est à peu près le même mais il y a des petites nuances. Nous allons voir par un exemple.
exemple : La longueur d'une liste est définie par
x :: l est la liste de tête x et de queue l (notation ML). La fonction longueur appliquée sur une liste l se code par : λl.liste_it l (λym.add (λfx.f x) m) (λfx.x) ou tout simplement λl.l (λym.add 1 m) 0.
Le principe de construction des entiers, des couples et des listes se généralise aux arbres binaires :
Un arbre est soit une feuille, soit un nœud. Dans ce modèle, aucune information n'est stockée au niveau des nœuds, les données (ou clés) sont conservées au niveau des feuilles uniquement. On peut alors définir la fonction qui calcule le nombre de feuilles d'un arbre : nb_feuilles = λa.arbre_it a (λbc.add b c) (λn.1), ou plus simplement: nb_feuilles = λa.a add (λn.1)
Pour définir le prédécesseur sur les entiers de Church, il faut utiliser les couples. L'idée est de reconstruire le prédécesseur par itération : pred = λn.π1 (itère n (λc.(π2 c, successeur (π2 c))) (0,0)). Puisque le prédécesseur sur les entiers naturels n'est pas défini en 0, afin de définir une fonction totale, on a ici adopté la convention qu'il vaut 0.
On vérifie par exemple que pred 3 →* π1 (itère 3 (λc.(π2 c, successeur (π2 c))) (0,0)) →* π1 (2,3) →* 2.
On en déduit que la soustraction est définissable par : sub = λnp.itère p pred n avec la convention que si p est plus grand que n, alors sub n p vaut 0.
En combinant prédécesseur et itérateur, on obtient un récurseur. Celui-ci se distingue de l'itérateur par le fait que la fonction qui est passée en argument a accès au prédécesseur.
rec = λnfx.π1 (n (λc.(f (π2 c) (π1 c), successeur (π2 c))) (x, 0))
Un combinateur de point fixe permet de construire pour chaque F, une solution à l'équation X = F X . Ceci est pratique pour programmer des fonctions qui ne s'expriment pas simplement par itération, telle que le pgcd, et c'est surtout nécessaire pour programmer des fonctions partielles.
Le combinateur de point fixe le plus simple, dû à Curry, est : Y = λf.(λx.f(x x))(λx.f(x x)). Turing a proposé un autre combinateur de point fixe : Θ = (λx. λy. (y (x x y))) (λx. λy. (y (x x y))).
On vérifie que quel que soit g. Grâce au combinateur de point fixe, on peut par exemple définir une fonction qui prend en argument une fonction et teste si cette fonction argument renvoie vrai pour au moins un entier : teste_annulation = λg.Y (λfn.ou (g n) (f (successeur n))) 0.
Par exemple, si on définit la suite alternée des booléens vrai et faux : alterne = λn.itère n non faux, alors, on vérifie que : teste_annulation alterne →* ou (alterne 0) (Y (λfn.ou (alterne n) (f successeur n)) (successeur 0)) →* ou (alterne 1) (Y (λfn.ou (alterne n) (f successeur n)) (successeur 1)) →* vrai.
On peut aussi définir le pgcd : pgcd = Y (λfnp.if0thenelse (sub p n) (if0thenelse (sub n p) p (f p (sub n p))) (f n (sub p n))).
Le récurseur et le point fixe sont des ingrédients clés permettant de montrer que toute fonction partielle récursive est définissable en λ-calcul lorsque les entiers sont interprétés par les entiers de Church. Réciproquement, les λ-termes peuvent être codés par des entiers et la réduction des λ-termes est définissable comme une fonction (partielle) récursive. Le λ-calcul est donc un modèle de la calculabilité.
On annote les termes par des expressions que l'on appelle des types. Pour cela, on fournit un moyen de donner un type à un terme. Si ce moyen réussit, on dit que le terme est bien typé. Outre le fait que cela donne une indication sur ce que « fait » la fonction, par exemple, elle transforme les objets d'un certain type en des objets d'un autre type, cela permet de garantir la normalisation forte, c'est-à-dire que, pour tous les termes, toutes les réductions aboutissent à une forme normale (qui est unique pour chaque terme de départ). Autrement dit, tous les calculs menés dans ce contexte terminent. Les types simples sont construits comme les types des fonctions, des fonctions de fonctions, des fonctions de fonctions de fonctions vers les fonctions, etc. Quoi qu'il puisse paraître, le pouvoir expressif de ce calcul est très limité (ainsi, l'exponentielle ne peut y être définie, ni même la fonction ).
Plus formellement, les types simples sont construits de la manière suivante:
Intuitivement, le second cas représente le type des fonctions acceptant un élément de type et renvoyant un élément de type .
Un contexte est un ensemble de paires de la forme où est une variable et un type. Un jugement de typage est un triplet (on dit alors que est bien typé dans ), défini récursivement par:
Si on a ajouté des constantes au lambda calcul, il faut leur donner un type (via ).
Le lambda-calcul simplement typé est trop restrictif pour exprimer toutes les fonctions calculables dont on a besoin en mathématiques et donc dans un programme informatique. Un moyen de dépasser l'expressivité du lambda-calcul simplement typé consiste à autoriser des variables de type et à quantifier sur elles, comme cela est fait dans le système F ou le calcul des constructions.
Le lambda-calcul constitue la base théorique de la programmation fonctionnelle et a ainsi influencé de multiples langages de programmation. Le premier d'entre eux est Lisp qui est un langage non typé. Plus tard, ML et Haskell, qui sont des langages typés, seront développés.
Les indices de de Bruijn[17],[18] sont une notation du lambda calcul qui permet de représenter par un terme, chaque classe d'équivalence pour l'α-conversion. Pour cela, de Bruijn a proposé de remplacer chaque occurrence d'une variable par un entier naturel[19]. Chaque entier naturel dénote le nombre de λ qu'il faut croiser pour relier l'occurrence à son lieur.
Ainsi le terme λ x. x x est représenté par le terme λ 0 0 tandis que le terme λx. λy. λz .x z (y z) est représenté par λ λ λ 2 0 (1 0), parce que, dans le premier cas, le chemin de x à son lieur ne croise aucun λ, tandis que, dans le deuxième cas, le chemin de x à son lieur croise deux λ (à savoir λy et λz), le chemin de y à son lieur croise un λ (à savoir λz) et le chemin de z à son lieur ne croise aucun λ.
Comme autre exemple, le terme (λ x λ y λ z λ u . (x (λ x λ y. x))) (λ x. (λ x. x) x) et un terme qui lui est α-équivalent, à savoir (λ x λ y λ z λ u . (x (λ v λ w. v))) (λ u. (λ t. t) u) sont représentés par (λ λ λ λ (3 (λ λ 1))) (λ (λ 0) 0) (voir la figure).
Seamless Wikipedia browsing. On steroids.
Every time you click a link to Wikipedia, Wiktionary or Wikiquote in your browser's search results, it will show the modern Wikiwand interface.
Wikiwand extension is a five stars, simple, with minimum permission required to keep your browsing private, safe and transparent.