Typage et Sémantique - TD2 - Paul Brauner

publicité
Typage et Sémantique - TD2
Paul Brauner
18 janvier 2010
Le langage que nous avons vu jusqu’à présent a permis d’illustrer les concepts de sémantique, typage et type safety. Cepedant ils ne permettent pas d’écrire de « vrais » programmes.
Nous verrons aujourd’hui comment en rajoutant des fonctions dans le langage on récupère
toute la puissance des langages de programmation usuels. Nous verrons également que, à
l’instar du langage étudié lors du TD1, ce langage doit être « dompté » par un système de
type si l’on veut que ses programmes se comportent correctement. Nous verrons enfin les
restrictions que cela entraîne sur les programmes si l’on se limite à un système naïf.
1
Une parenthèse : le λ-calcul
Certaines des notions abordées ci-dessous le sont de manière informelle. C’est le cas de l’α-équivalence
par exemple. Le TD ne portant pas sur la dymanique du langage mais plutôt sur les problème de typage,
cette approche est largement suffisante. Les plus intéressés peuvent se reporter à n’importe quel cours
introductif sur le λ-calcul, comme celui de Henk Barendregt.
1.1
Histoire
Au tableau. Voir le très bon Les métamorphoses du calcul de Gilles Dowek par exemple pour
de plus amples informations sur le sujet.
1.2
Syntaxe et sémantique
C’est un langage extrêmement simple dont les seules valeurs (ce qui est retourné à la fin)
sont des fonctions. Pour former ces fonctions on a le droit à une infinité de variables et à
une construction syntaxique qui signifie « la fonction qui à x associe t ». On peut aussi, pour
déclencher un calcul, appliquer une fonction à un argument. En voici la grammaire.
x, y, z, . . .
variables
t ::= x
| λx.t
| tt
variable
abstraction
application
On notera t, u, w, . . . les λ-termes. Dans λx.t, on dit de t qu’il constitue le corps de l’abstraction. De plus, on adopte les conventions suivantes.
– La portée de l’abstraction s’étend aussi loin que possible. Concrètement, cela signifie
que λx.x y = λx.(x y) et non (λx.x) y.
– L’application est associative à gauche. Concrètemenent, t u w = (t u) w et non t (u w).
1
Ces conventions sont celles adoptées par les langages haskell et ocaml entre autres.
Avant de commencer, quelques exemples de λ-termes lus en langue naturelle :
– λx.x : la fonction qui à x associe x ;
– λx.x x : la fonction qui à x associe x appliqué à x ;
– (λx.x x) (λy.y) : la fonction qui à x associe x appliqué à x, appliquée à la fonction qui à
y associe y ;
– λf.λx.t : la fonction qui à f associe la fonction qui à x associe t ;
– ...
Exercice 1. Représenter sous forme d’arbres les termes suivants. On représentera l’application par le
symbole @, au lieu d’un vide comme c’est le cas dans la grammaire.
– fgh
– (λx.f x x) u
– (λf.λx.f x) (λy.y)
La fonction qui à x associe x et la fonction qui à y associe y sont intuitivement les mêmes.
On dit qu’elles sont α-équivalentes. On considère habituellement les λ-termes modulo cette
équivalence. Autrement dit, on confond ces deux fonctions, et on se donne le droit de renommer les variables introduites par les abstractions quand cela nous arrange.
Exercice 2. Pour chacun des couples de λ-termes ci-dessous, dites s’ils sont α-équivalents ou non.
– λx.x et λx.x x
– λx.x x et λx.x x
– λx.x x et λy.y y
– λx.x y et λy.y y
– λx.λy.x y et λy.λx.y x
En λ-calcul, il n’y a pas de fonctions à plusieurs arguments. À la place, on utilise une
astuce du mathématicien Haskell Curry, qui consiste à remplacer la fonction qui à (x, y)
associe t par la fonction qui à x associe la fonction qui à y associe t.
(λ(x, y).t) (u, w)
est simulé par
(λx.λy.t) u w
Par exemple, en scheme :
Welcome to MzScheme v4.1.4 [3m], Copyright (c) 2004-2009 PLT Scheme Inc.
R5RS legacy support loaded
> ((lambda (x y) (+ x x y)) 2 3)
7
> (((lambda (x) (lambda (y) (+ x x y))) 2) 3)
7
Voyons à présent la sémantique opérationelle du langage. Plutôt que de s’encombrer de
définitions formelles comme la dernière fois, on se contente de dire qu’un pas d’exécution du
langage consiste en l’application de la règle suivante n’importe où dans le terme :
(λx.t) u → t[x := u]
où la notation t[x := u] signifie « t où l’on a remplacé toutes les occurences de x par u ». Par
exemple (x + x + y)[x := 42] = 42 + 42 + y. Et c’est tout ! Il n’y a qu’une seule règle.
2
Attention . Il faut bien prendre garde à ne pas changer le sens d’une fonction lors de la substitution.
Par exemple (λy.x y)[x := z] = λy.z y, mais si l’on applique naïvement la même substitution à
(λz.x z), on obtient λz.z z ce qui n’est plus du tout la même chose ! Pour éviter ce genre de problème,
on renomme λz.x z en λz 0 .x z 0 (par exemple) avant d’aller substituer x par z.
On remarque que le langage est fortement indéterministe. En effet, à chaque pas, on peut
choisir d’appliquer la règle n’importe-où dans le terme.
Exercice 3. Exécutez les programmes suivant pas à pas selon la stratégie de votre choix.
– λx.x
– (λx.x) (λy.y)
– (λf.λx.f f x) (λy.y)
– (λx.λy.x) (λz.z) (λz.z z)
Ces termes ne sont pas très excitants. Church a montré comment on pouvait à proprement
parler programmer avec ce langage, en encodant la plupart des constructions de base des
langages actuels. Voyons deux exemples : (if ... then ... else) et les entiers.
Exercice 4. On décide d’appeller true le terme λx.λy.x et false le terme λx.λy.y. Écrivez une
fonction qui prend trois arguments : c, a et b, et qui se comporte comme if c then a else b.
Voyons à présent comment encoder les entiers.
Exercice 5 (Entiers de Church). On encode l’entier n par le terme λo.λs.s (s · · · (s o) · · · ) où s
apparaît n fois. Écrivez les encodages des entiers 0, 1 et 2. Écrivez les fonctions successeur, addition et
multiplication pour cet encodage des entiers.
Jusqu’à présent, tout se passe bien, ce qui est surprenant pour un langage aussi puissant.
Voyons un cas problématique.
Exercice 6.
1. Exécutez le terme (λx.x x) (λx.x x) que nous appellerons Ω. Qu’observez-vous ?
2. Exécutez le terme (λx.λy.y) Ω (λz.z) selon la stratégie de votre choix. Que se passe-t-il ? Quelle
est la stratégie adoptée par la plupart des langages de programmation ?
Pour illustrer ce dernier point, voici deux sessions interactives. Une dans l’interpréteur du
langage scheme, l’autre dans celui du langage haskell.
Welcome to MzScheme v4.1.4 [3m], Copyright (c) 2004-2009 PLT Scheme Inc.
R5RS legacy support loaded
> (define (omega) (omega))
> ((lambda (x y) y) (omega) 42)
L’interpréteur boucle sans fin.
GHCi, version 6.10.3: http://www.haskell.org/ghc/
Loading package ghc-prim ... linking ... done.
Loading package integer ... linking ... done.
Loading package base ... linking ... done.
Prelude> let omega () = omega ()
Prelude> (\x y -> y) (omega ()) 42
42
Prelude>
3
:? for help
Ces deux comportements correspondent à deux stratégies d’évaluations différentes. Celle
de scheme, la plus courament rencontrée dans les langages de programmation, est appelée
call by value1 , tandis que celle de haskell, plus rare, est appelée call by name2 . L’appel par valeur
consiste à évaluer les arguments d’une fonction avant d’appeler la fonction. L’appel par nom
passe les arguments tel quels et ne les évalue que s’ils sont utilisés. Voir discussion au tableau
concernant l’appel par nom.
2
Un langage de programmation avec des fonctions
Le λ-calcul seul étant un langage un peu austère, on lui ajoute les constructions vues dans
le TD1. On commence à se rapprocher d’un langage de programmation réaliste.
2.1
Langage non typé
En s’inspirant du λ-calcul, on ajoute des fonctions dans le langage du TD1.
t ::= · · · | x | λx.t | t t
On étend la notion de valeur aux fonctions.
v ::= · · · | λx.t
Enfin, pour que notre sémantique opérationnelle soit déterministe, on fixe une stratégie d’évaluation : l’appel par valeur, ce que traduisent les règles d’évaluation.
(λx.t) v → t[x := v] (E-AppAbs)
t → t0
t u → t0 u
t → t0
(E-App1)
v t → v t0
(E-App2)
Remarquez les valeurs, indiquées par un v, dans les règles de réduction. Celle de la règle
E-AppAbs signifie qu’une fonction n’est appelée que sur un argument évalué. Celle de la règle
E-App2 signifie qu’on évalue l’argument de f seulement après que f a été évalué vers une
valeur, et on espère implicitement qu’il s’agit d’une fonction.
Exercice 7. Vers quoi le programme (λn.succ n) ((λx.x) 0) s’évalue-t-il en un pas ? A-t-on le choix ?
Quelle est sa forme finale (après évaluation tant que possible) ?
Comme pour le langage du TD1, certains programmes ne terminent pas sur des valeurs.
Il y a bien sûr les programmes problématiques du TD1, qui sont inclus dans les programmes
que l’on peut former dans ce nouveau langage, mais il y en a de nouveaux.
Exercice 8. Donnez au moins trois programmes qui ne terminent pas sur une valeur et dont la forme
« bloquée » n’est pas exprimable dans le langage du TD1.
1 appel
2 appel
par valeur
par nom
4
2.2
Langage typé
Pour éviter ce genre de problèmes, on étend le système de types du TD1 aux nouvelles
construction du langage. L’idée est de donner le type A ⇒ B à la fonction qui prend un
argument de type A et renvoie une valeur de type B. Il y a donc maintenant une infinité de
types, dont voici la grammaire.
T ::= Nat | Bool | T ⇒ T
Un programme est alors bien typé si, chaque fois qu’on applique une fonction à un argument,
il est bien du type qu’elle attend. Pour qu’on puisse vérifier qu’un programme est bien typé,
il faut un peu d’aide de la part du programmeur : il doit préciser le type de l’argument d’une
fonction. On change donc légèrement la grammaire des programmes.
t ::= · · · | λx : T .t | · · ·
La construction λx : T .t se lit : « la fonction qui à x de type T associe t ». Avant d’énoncer
formellement ce système de types, forgeons-nous une intuition de son fonctionnement.
Exercice 9. Les programmes suivant sont-ils bien typés ? Si oui donnez leur type.
– λx : Bool.x
– λx : Nat.succ x
– λx : Bool.x x
– λf : Nat ⇒ (Nat ⇒ Nat).λx : Nat.f x
– λf : Nat ⇒ Bool.if f then (λx : Nat.true) else f
– λf : Nat ⇒ Bool.if (f 0) then (λx : Nat.true) else f
On définit maintenant formellement la relation de typage. Cette fois, elle ne peut simplement concerner un programme et un type. En effet, pour vérifier que λx : T .t est bien de type
T ⇒ U, on voudrait vérifier que t a le type U sachant que x a le type T . Ce « sachant que » se
traduit par l’introduction de la notion de contexte. Un contexte est un ensemble de couples
de la forme x : T , où x est une variable et T un type. Par exemple {x : Bool, y : Nat ⇒ Bool}
est un contexte. La relation de typage est alors une relation ternaire entre un contexte, un
programme et un type, qui s’écrit Γ ` t : T , et se lit « t a le type T dans le contexte Γ ».
On adapte les règles du TD1 en leur rajoutant toutes un contexte. Il n’y a rien de nouveau.
Γ ` true : Bool
(T-True)
Γ ` 0 : Nat
Γ ` false : Bool
(T-False)
Γ ` t : Nat
Γ ` t : Bool
Γ `a:T
Γ `b:T
Γ ` if t then a else b : T
Γ ` t : Nat
Γ ` iszero t : Bool
(T-Zero)
(T-If)
Γ ` succ t : Nat
Γ ` t : Nat
(T-IsZero)
5
Γ ` pred t : Nat
(T-Succ)
(T-Pred)
Voici enfin les règles de typage des nouvelles constructions ajoutées au langage.
x:T ∈Γ
(T-Var)
Γ `x:T
Γ, x : A ` t : B
Γ ` λx : A.t : A ⇒ B
(T-Abs)
Γ `f:A⇒B
Γ `t:A
Γ `ft:B
(T-App)
Exercice 10. Donnez une dérivation de typage pour les programmes typables de l’exercice précédent.
3
La prochaine fois
On voit bien que dans l’exercice 9, on aurait pu se passer des annotations de type du
programmeur sur les abstractions, et deviner quand même le type des programmes. C’est
l’idée de ce qu’on appelle l’inférence de type, qui permet de programmer dans un langage typé
sans jamais en parler explicitement, et qui est décidable pour un langage encore plus expressif
que celui que nous venons de voir.
6
Téléchargement