Up Next

1  Logique combinatoire

Le problème que nous cherchons à traiter dans ce chapitre est le suivant: nous souhaitons écrire un programme qui prend un λ-terme en entrée et retourne sa forme normale, si elle existe.

Le premier point que nous devrons traiter est la représentation des λ-termes. Une idée est d'utiliser une structure d'arbre, correspondant au type Caml:
type lambda_terme = VAR of string (* variables nommees *)
                  | APPL of lambda_terme * lambda_terme (* applications *)
                  | ABS of string * lambda_terme;; (* abstractions *)
Ceci est cependant assez maladroit, comme le lecteur pourra s'en apercevoir s'il essaie d'écrire une fonction de réduction pour ce langage. Essayons de le faire: nous voulons écrire une fonction norm : lambda_terme -> lambda_terme qui normalise son terme d'entrée. Nous connaissons déjà une stratégie qui atteint ce but: la stratégie externe gauche. Utilisons-la:
let rec norm t =
  match t with
    VAR x -> t (* une variable est normale. *)
  | ABS (x, u) -> ABS (x, norm u) (* normaliser une abstraction
                                     revient a normaliser son corps. *)
  | APPL (u, v) ->
    let u' = norm u in (* strategie gauche : reduire
                          les redex de u avant ceux de v. *)
    match u' with
      ABS (y, u1) -> norm (subst u1 y v) (* beta-reduction *)
    | _ -> APPL (u', norm v);;
Il ne reste qu'à définir une fonction subst de substitution: ceci n'est pas trivial, à cause des problèmes particuliers posés par la capture de variables et le α-renommages. De plus, la complexité de subst u1 y v, si elle est en temps polynomial en u1, est néanmoins élevée: à chaque β-réduction, le term u1 doit être parcouru en entier. Ensuite, alors que u1 est déjà en forme normale dans le cas APPL (u, v), l'appel récursif à norm (subst u1 y v) va re-parcourir u1 avant de tomber sur les rédex dans v ou ceux créés par la substitution. Un tel re-parcours est coûteux lui aussi. Enfin, peut-on garantir que la fonction norm est correcte ?

1.1  Combinateurs SKI

Une idée qui résout la plupart de ces problèmes est due originellement à Schönfinkel et à Curry, c'est la logique combinatoire. Le principe essentiel derrière la logique combinatoire est d'éliminer les variables et les abstractions du langage, et de compiler les λ-termes vers un langage simple, du premier ordre, n'ayant que l'application comme symbole de fonction, toutes les autres constructions élémentaires étant codées via un certain nombre de constantes appelées combinateurs.

Que ceci soit faisable est surprenant. Essayons de voir comment on peut définir λ xu à β-équivalence près, en utilisant quelques λ-termes clos prédéfinis. Ceci se fait par récurrence structurelle sur u. Si u est une variable, soit u=x et on prédéfinit I =def λ xx (l'identité); soit u est une autre variable y, et alors λ xy =βKy, où K =def λ y,xy est notre deuxième λ-terme clos prédéfini. Si u est une application vw, alors λ xvw =βλ z ⋅ (λ xv) z ((λ xw) z) =βSxv) (λ xw), où S =def λ x, y, zxz (yz) est notre troisième λ-terme clos prédéfini. Finalement, si u est une abstraction, on peut supposer que cette abstraction a été éliminée préalablement en faveur des constantes S, K, I ci-dessus, et on n'a donc rien de plus à faire — à part déclarer que λ xS =βKS, λ xK =βKK, λ xI =βKI.

Formalisons cette idée. Le langage des combinateurs de Curry est:

TCurry ::= V | TCurry TCurry | S | K | I


Pour les distinguer des λ-termes, notons les termes, ou combinateurs de Curry à l'aide des lettres M, N, P, etc. Ce langage est muni de la relation de réduction:

S M N P MP(NP)
K M N M
I M M


et on compile les λ-termes en combinateurs par la transformation uu* suivante, où la compilation d'abstractions λ xu est exprimée sous forme d'une opération d'abstraction [x] M prenant un terme M de TCurry et retournant un autre terme de Curry, suivant l'explication donnée plus haut. (Noter que [x] n'est pas un symbole du langage, mais un opérateur envoyant des termes de Curry vers des termes de Curry.)

x* =def x
(uv)* =def u* v*
xu)* =def [x] (u*)
  
[x] x =def I
[x] y =def K y    (yx)
[x] a =def K a    (a ∈ {S, K, I})
[x] (MN) =def S ([x] M) ([x] N)


Les combinateurs formalisent bien la notion de β-réduction, en tout cas à première vue:

Lemme 1   Pour tous termes de Curry M et N, ([x] M) N* M [x:=N].


Preuve : Par récurrence structurelle sur M. Si M=x, alors ([x] M) N = I NN = M [x:=N]. Si M est une autre variable y ou une constante a ∈ {S, K, I}, alors ([x] M) N = K M NM = M [x:=N]. Si M est une application M1 M2, alors ([x] M1 M2) N = S ([x] M1) ([x] M2) N → ([x] M1) N (([x] M2) N) →* M1 [x:=N] (M2 [x:=N]) (par hypothèse de récurrence) = M [x:=N].     ♦

On peut alors réaliser une fonction ski_norm de termes de Curry plus simplement que dans le cas du λ-calcul, comme suit:
type ski_terme = S | K | I
               | VAR of string
               | APPL of ski_terme * ski_terme;;
let rec ski_norm m =
  match m with
    S | K | I -> m
  | VAR x -> m
  | APPL (m0, m1) ->
    match ski_norm m0 with
      I -> ski_norm m1
    | APPL (K, m') -> m'
    | APPL (APPL (S, m3), m2) -> ski_norm (APPL (APPL (m3, m1), APPL (m2, m1)))
    | m'0 -> APPL (m'0, ski_norm m1);;
La fonction ski_norm effectue une réduction externe gauche. L'exercice 4 permet de montrer qu'il s'agit d'une stratégie de réduction, au sens ou si le terme donné en argument à ski_norm a une forme normale, alors ski_norm termine et retourne une forme normale. De plus, le système de combinateurs de Curry est confluent (exercice 5), et donc ski_norm retourne l'unique forme normale du terme en entrée si elle existe.

La fonction ski_norm a cependant toujours le défaut qu'elle reparcourt les termes plusieurs fois. Par exemple, pour normaliser S x y z, ski_norm descend le long de ce dernier, normalise S, normalise x, retourne S x (dernière ligne); puis normalise y, retourne S x y. À ce stade, ski_norm appliqué à S x y z a obtenu m0=def S x y comme forme normale de S x y et m1=def z. On tombe alors dans le troisième cas du match ski_norm m0 ci-dessus, qui demande à normaliser xz(yz). De nouveau, ski_norm va s'appeler récursivement sur xz, puis sur x, puis remonter et finir par conclure que le terme est en forme normale.

Pour éviter ce problème, on peut utiliser l'astuce de Krivine, qui consiste à faire de ski_norm une fonction binaire, qui prend non seulement un terme M à normaliser, mais aussi une liste [M1, …, Mn] d'arguments auxquels appliquer M. On définit donc:
let rec ski_norm m args =
  match m with
    APPL (m0, m1) -> ski_norm m0 (m1::args) (* on accumule les arguments. *)
  | I -> (match args with
            [] -> I (* I applique a rien. *)
          | m1::rest -> ski_norm m1 rest (* I m1 ... -> m1 ... *))
  | K -> (match args with
            m1::m2::rest -> ski_norm m1 rest (* K m1 m2 ... -> m1 ... *)
          | _ -> hnf m args)
  | S -> (match args with
            m1::m2::m3::rest -> ski_norm m1 (m3::APPL(m2,m3)::rest)
              (* S m1 m2 m3 ... -> m1 m3 (m2 m3) ... *)
          | _ -> hnf m args)
  | VAR _ -> (* x m1 ... mn est normal de tete: on le reconstruit,
                en appelant recursivement ski_norm sur les arguments
                m1, ..., mn. *)
          hnf m args
and hnf m args = (* applique m a args, en evaluant recursivement les args. *)
        match args with
          [] -> m
        | arg::rest -> hnf (APPL (m, ski_norm arg [])) rest;;
Cette fonction effectue une normalisation par stratégie externe gauche. Si l'on souhaite effectuer une normalisation de tête seulement, c'est-à-dire ne pas normaliser les arguments m1, ..., mn lorsqu'on évalue une forme normale de tête m m1mn, il suffit de réécrire la définition de hnf en:
and hnf m args = (* applique m a args, en evaluant recursivement les args. *)
        match args with
          [] -> m
        | arg::rest -> hnf (APPL (m, arg)) rest
    in hnf m args;;
On notera au passage que ski_norm et hnf sont alors des fonctions récursives en queue (en anglais, tail-recursive), autrement dit quand on récurse sur ski_norm ou hnf, c'est pour obtenir un résultat qu'on se contentera de retourner directement. (Noter qu'il est important de ne faire que de la réduction de tête pour obtenir un tel résultat.) Un tel appel d'une fonction f dont le résultat sera retourné de la fonction courante est un appel en queue. Si vous avez quelques notions d'assembleur, vous vous convaincrez facilement qu'un appel en queue peut être simulé par un goto pointant vers le début du code de la fonction f. Si vous connaissez C, vous verrez au moins qu'un appel récursif en queue de f (dans le code de f, donc), peut être simulé par un goto retournant au début de la fonction. Par exemple, la fonction hnf ci-dessus peut être codée en C par:
ski_terme hnf (ski_terme m, ski_terme_liste args) {
  while (m!=nil) {
    m = APPL (m, hd (args));
    args = tl (args);
  }
  return m;
}
nil est la liste vide, hd récupère le premier élément d'une liste non-nil et tl récupère la queue de cette même liste, et APPL construit une application comme en Caml. Les types ski_terme et ski_terme_liste pourront être définis comme suit:
typedef struct skit {
  int kind; /* -1=VAR, 0=I, 1=K, 2=S, 3=APPL */
  union {
    char *var; /* lorsque kind==-1. */
    struct appl { struct skit *f, *arg; } appl; /* lorsque kind==3. */
  } what;
} skit, *ski_terme;

typedef struct skitl {
  ski_terme hd; /* liste non vide; la liste vide nil est NULL. */
  struct skitl *tl;
} skitl, *ski_terme_liste;
La conséquence majeure de l'astuce de Krivine est qu'on obtient un évaluateur de termes de Curry non récursif pour la réduction de tête, qui ne consomme donc aucune place dans la pile du processeur. Bien sûr, cette pile est en un sens simulée par la liste args des arguments en attente d'évaluation, mais on n'a par exemple pas besoin de garder (comme en C, par exemple), une pile contenant les addresses de retour des sous-programmes appelés. Ceci est une particularité de l'appel par nom. L'appel par valeur, en revanche, ne permet pas l'évaluation sans pile.

On écrira l'évaluateur de Krivine sous forme symbolique, à l'aide de règles de transition entre états machine. Un état est ici un couple (M, args), où M est un terme et args une liste d'arguments:

M0 M1, args M0, M1::args
I, M::args M, args
K, M1::M2::args M1, args
S, M1::M2::M3::args M1, M3::M2 M3::args


Lorsque ce système termine sur un état (M, [M1; …; Mn]), c'est qu'on a trouvé la forme normale de tête faible M M1Mn.

Exercice 1   Écrire la fonction ski_norm en C en profitant de la récursion en queue.


Exercice 2   Justifier le fait que la réduction appelée réduction de tête ci-dessus est bien nommée. On remarquera que cette réduction est la relation binaire (ne passant pas au contexte) définie par:

I M1 M2Mn M1 M2Mn    (n≥ 1)
K M1 M2 M3Mn M1 M3Mn    (n≥ 2)
S M1 M2 M3 M4Mn M1 M3 (M2 M3) M4Mn    (n≥ 3)


et on montrera que si u se réduit en v par réduction de tête faible dans le λ-calcul, alors u* se réduit en tête en v*. Que se passe-t-il si u se réduit en v par réduction de tête, non nécessairement faible ?


Exercice 3   Calculer (λ x, yx)*. Ce terme est-il égal à, ou se réduit-il à K?


Exercice 4   Remarquer que tout terme de Curry s'écrit de façon unique sous la forme h M1Mn, où h, la tête du terme, est une variable ou un combinateur parmi S, K, I. On définit la relation de réduction de tête par I M1 M2Mnt M1 M2Mn si n ≥ 1, K M1 M2 M3MnM1 M3Mn si n ≥ 2, S M1 M2 M3 M4MnM1 M3 (M2 M3) M4Mn si n ≥ 3. Notons →r* sa clôture réflexive transitive. Comme dans le λ-calcul, on définit la relation →s* de réduction standard en posant que Ms* N si et seulement si Mr* h M1Mn, M1s* N1, ..., Mns* Nn, et N = h N1Nn. (Ceci est une définition par récurrence structurelle sur N.)

Montrer que si M
s* NP, alors Ms* P. (On pourra s'inspirer de la preuve du théorème de standardisation en partie 1.) En déduire le théorème de standardisation pour le calcul des combinateurs de Curry.


Exercice 5   On définit une notion de réduction parallèle ⇒ par:



Montrer que ⇒ est fortement confluente. Montrer d'autre part que si MM' alors MM', et si MM' alors M
* M'. En déduire que la notion de réduction des termes de Curry est confluente.


Jusqu'ici, tout semble parfait. Mais les combinateurs de Curry ne sont pas si parfaits que cela. En premier, le lemme 1 ne suffit pas: ce n'est pas parce que ([x] M) N* M [x:= N] que la réduction des combinateurs permet de simuler la β-réduction du λ-calcul (via la traduction uu*). Ceci peut paraîitre surprenant, mais la propriété qu'on aimerait avoir est le fait que pour tous λ-termes u et v, ((λ xu) v)** (u [x:=v])*, c'est-à-dire ([x] u*) v** (u [x:=v])*. Or si le côté gauche se réduit bien en u* [x:=v*] par le lemme 1, rien ne permet d'assurer que u* [x:=v*] soit égal, ou même se réduise en (u [x:=v])*. En fait, dans notre traduction uu*, en général u* [x:=v*] et (u [x:=v])* ne sont même pas convertibles. Considérer par exemple u =def λ yx, v =def zz'. On a u* = K x, v* = zz', donc u* [x:=v*] = K (zz'), mais (u [x:=v])* = (λ yzz')* = [y] (zz') = S (K z) (K z'), et ces deux termes sont différents et normaux. Comme le calcul est confluent (exercise 5), ces deux termes ne sont même pas convertibles. Ce défaut est cependant relativement aisé à corriger: voir l'exercice 6.

Exercice 6   On définit la traduction uuβ suivante, qui est une variation mineure de la traduction uu*, comme suit:
xβ =def x
(uv)β =def uβ vβ
xu)β =def [x]β(uβ)
  
[x]βx =def I
[x]βM =def K M    (xfv(M))
[x]β(MN) =def S ([x]βM) ([x]βN)    (xfv(MN))
Démontrer que ([x]βM) N* M [x:=N], que ([x]β M) [y:=N] = [x]β(M [y:=N]) si xy et x n'est pas libre dans N, puis que uβ [x:=vβ] = (u [x:=v])β. En déduire que ((λ xu) v)β* (u [x:=v])β.


Même en effectuant la réparation de l'exercice 6, ce qu'on veut vraiment c'est que si uv par β-réduction, alors uβ* vβ. Il faut donc vérifier que si MM' alors MNM'N (trivial), que si NN' alors MNMN' (trivial), et que si MM' alors [x]βM* [x]βM'. Cette dernière implication est connue sous le nom de règle (ξ), et malheureusement elle n'est pas vraie.

Considérons par exemple M =def ([y]βz) x = K z x et M' =def z. Alors MM', mais [z]βM = S (S (K K) I) (K x) est normal, et ne se réduit donc pas à [z]β M' = I. Noter que ça ne fonctionnerait pas non plus avec ([y] z) x au lieu de ([y]βz) x, qui est en fait le même terme.

En fait, on ne connaît aucune traduction f des λ-termes vers les termes de Curry telle que u =βv si et seulement si f (u) et f (v) sont convertibles par les règles de réduction des combinateurs. On vient de voir que les traductions uu* et uuβ n'avaient pas cette propriété, mais aucune autre traduction connue ne l'a.

Toute ceci est gênant dans une optique où l'on implémenterait un assistant de preuve comme Coq [BBC+99], qui fait un usage essentiel du λ-calcul et de la relation de β-convertibilité, mais l'est moins dans le cas de la réalisation d'un langage de programmation.

Dans ce dernier cas, et en laissant de côté le fait que les notions de réduction qui nous intéressent dans ce cas sont plutôt des réductions de tête faibles, ce que l'on souhaite faire, c'est traduire un λ-terme u en un terme de Curry M, typiquement uβ, réduire M en une forme normale N, puis retraduire N en un λ-terme que l'on imprimera. La traduction naïve MM des termes de Curry en λ-termes est:

x =def x
(MN) =def MN
I =def λ xx
K =def λ x, yx
S =def λ x, y, zxz(yz)


Il s'agit bien d'une traduction inverse, mais seulement à β-équivalence près, au sens où:

Lemme 2   Pour tout λ-terme u, (u*)* u.


Preuve : Par récurrence structurelle sur u. Le seul cas non trivial est celui des abstractions u =def λ xv, où l'on doit montrer que ([x] v*)=βλ xv. Ceci étant une conséquence de l'hypothèse de récurrence (v*)=βv et du fait que ([x] M)=β λ xM, il ne reste qu'à démontrer cette dernière égalité. Ceci est par récurrence structurelle sur M: si M = x, alors ([x] M)= I= λ xx = λ xM; si M est une autre variable y, alors ([x] M)= (K y)= (λ x,x' ⋅ x) y → λ x' ⋅ y = λ xM; si M est une application, c'est trivial; si M = I, alors ([x] M)= (K I)= (λ y, xy) (λ zz) → λ x, zz = λ xM; si M = K, alors ([x] M)= (K K) = (λ y, xy) (λ z, z' ⋅ z) → λ x, z, z' ⋅ z = λ xM; si M = S, alors ([x] M)= (K S)= (λ y, xy) (λ z, z', z'' ⋅ zz''(z'z'')) → λ x, z, z', z'' ⋅ zz''(z'z'') = λ xM.     ♦

Mais ce n'est pas satisfaisant, car M n'est pas en général un terme normal dans le λ-calcul, même si M est un terme de Curry normal. Par exemple, si M = K x, on a M= (λ x, yx) x, et bien sûr la traduction inverse λ yx aurait été préférable. Or obtenir ce dernier à partir du précédent revient à effectuer une série de β-réductions, ce qui est justement ce pour quoi on avait cherché à traduire nos λ-termes en combinateurs au départ...

Il y a beaucoup de systèmes de combinateurs, et le livre [Dil88] en donne un panorama assez exhaustif, en passant par les supercombinateurs et les combinateurs catégoriques notamment. Nous verrons ces derniers en filigrane en section 2, puisque les λ-calculs à substitutions explicites y trouvent leur source.

Exercice 7   Montrer que (u)* et u ne sont même pas convertibles en général. (Prendre M =def K.) Qu'en est-il de (u)β et u ?


Exercice 8   Montrer que si M et N sont convertibles en temps que termes de Curry, alors M=βN.


Exercice 9   Montrer qu'on pouvait se passer du combinateur I dans la définition des termes de Curry, au sens où le fait de poser I =def S K N (pour n'importe quel terme N) permet de réduire I M en M.


Exercice 10   La discipline de types simples naturelle des combinateurs est héritée du λ-calcul. On a une règle de typage:



un axiome logique Γ, x : Fx : F, et trois axiomes Γ ⊢ I
: FF, Γ ⊢ K : F1F2F1, Γ ⊢ S : (F1F2F3) ⇒ (F1F2) ⇒ F1F3. Déduire de ces considérations et de l'isomorphisme de Curry-Howard qu'une formule F est déductible en logique intuitionniste minimale propositionnelle si et seulement si elle est déductible dans le système ci-dessus (qui n'a pas la règle d'introduction de l'implication). Montrer en fait que la règle d'introduction de l'implication est réalisée par l'abstraction, au sens où Γ, x : F1M : F2 implique Γ ⊢ [x] M : F1F2 (resp. Γ ⊢ [x]βM : F1F2).


Exercice 11   Montrer que tout terme de Curry typable est fortement normalisant. On s'inspirera de la méthode de réductibilité, en simplifiant légèrement l'argument: montrer les conditions (CR1), (CR2) et (CR3) (on dira qu'un terme est neutre s'il n'est pas de la forme I ou K ou K M ou S ou S M ou S M N); montrer ensuite directement par récurrence structurelle sur M que si Γ ⊢ M : F alors M est réductible de type F.


1.2  Machines à réduction de graphes

La machine ski_norm a toujours le défaut qu'ont toutes les machines à réduction par nom, à savoir que si dans u la variable x apparaît libre plusieurs fois, disons n, alors dans la réduction de (λ xu) v en u [x:=v], la valeur de v sera recalculée n fois.

Par exemple, (λ xxx) v se réduit par nom en vv, et si on suppose que la forme normale de v est v' et que v' est neutre (pas une abstraction), alors vv se réduit en v'v, puis seulement en v'v'. L'interprète ski_norm fait pareil: la traduction de (λ xxx) v est S I I vβ, qui se réduit en I vβ(I vβ), puis en vβ(I vβ). Ceci se réduit en M (I vβ), où M est la forme normale de vβ, puis en MM. Mais pour cela, on a du réduire vβ deux fois à sa forme normale.

Pour éviter ceci, on peut représenter les termes de Curry sous forme non pas d'arbres mais de graphes, de sorte à conserver le partage. Graphiquement, les règles de réduction seront: où l'argument P est bien partagé dans la dernière règle.

Ce qui est important, au passage, ce n'est pas tant de partager les sous-termes tels que P, mais que toute réduction dans P ne soit effectuée qu'une fois. Autrement dit, une fois P évalué, on doit remplacer P par sa valeur, en utilisant un effet de bord.

Concrètement, le type des graphes représentant des termes sera:
type ski_graphe = S | K | I
               | VAR of string
               | APPL of ski_graphe ref * ski_graphe ref;;
et la fonction de normalisation ski_norm (sans astuce de Krivine) va devoir être adaptée pour mémoriser les calculs en transformant le graphe représentant un terme au fur et à mesure. On définit ainsi la fonction ski_gnorm de type ski_graphe refunit, qui modifie physiquement le graphe fourni en entrée. On peut ensuite lire la forme normale en déréférençant le graphe de départ.
let rec ski_gnorm p =
  match !p with
    APPL (p0, p1) ->
    (ski_gnorm p0;
     match !p0 with
       I -> (p:=!p1; ski_gnorm p) (* on remplace I p1 par p1 physiquement et on recurse. *)
     | APPL ({contents=K}, {contents=m'}) -> (p:=m'; ski_gnorm p)
     | APPL ({contents=APPL ({contents=S}, p3)}, p2) ->
       (p:=APPL (ref (APPL (p3, p1)), ref (APPL (p2, p1)));
        ski_gnorm p)
     | _ -> ski_gnorm p1 (* on normalise l'argument *) )
  | _ -> ();;
Exercice 12   On ajoute au langage un combinateur Y de point fixe primitif, de règle de réduction Y MM (YM). Modifier ski_graphe et la machine ski_gnorm pour intégrer ce nouvel opérateur. En quoi l'utilisation de graphes est-elle ici particulièrement utile ?



Up Next