Poly
Poly
Poly
INF564 – Compilation
Jean-Christophe Filliâtre
Édition 2023–2024
ii
Avant-propos
Ce polycopié rassemble les notes d’un cours donné à l’École Normale Supérieure de-
puis 2008 et à l’École Polytechnique depuis 2016, intitulé Langages de programmation et
compilation.
Pour bien programmer, il faut comprendre le modèle d’exécution du ou des langages
de programmation qu’on utilise, c’est-à-dire la manière dont les différentes constructions
sont exécutées, que ce soit au travers d’un interprète ou d’un compilateur. Il est donc
important de comprendre tous les mécanismes qui sont en jeu dans ce processus. Les
mettre en œuvre soi-même en écrivant un compilateur constitue sans doute la meilleure
façon de comprendre un langage. Même si on ne cherche pas à réaliser un compilateur,
de nombreux concepts sous-jacents peuvent être réutilisés dans d’autres contextes. Ainsi,
les notions de sémantique formelle, de syntaxe abstraite ou encore d’analyse syntaxique
sont utilisées tous les jours dans des domaines aussi vastes que les bases de données, la
démonstration assistée par ordinateur ou encore la théorie des langages de programmation.
Ce cours ne s’appuie sur aucun ouvrage et est censé se suffire à lui-même. Cependant,
certains sujets ne sont que brièvement évoqués et le lecteur désireux d’en savoir plus
est renvoyé vers d’autres ouvrages en fin de chapitres. Par exemple, si ce cours aborde
plusieurs notions de théorie des langages (grammaires, expressions régulières, automates,
etc.) pour les besoins de l’analyse syntaxique, il ne saurait se substituer à un vrai cours
sur le sujet. Le lecteur désireux d’en apprendre d’avantage sur les langages formels pourra
consulter avec intérêt le livre d’Olivier Carton [5].
Ce cours ne suppose pas non plus de connaissances particulières en matière de lan-
gages de programmation. On utilise ponctuellement le langage OCaml pour programmer
certaines notions, mais cela reste épisodique. À d’autres moments, on explique des points
particuliers de certains langages comme Java, C et C++, notamment dans la section 6.2,
mais ces passages doivent pouvoir être lus sans pour autant connaître ces langages. Le
chapitre 7 se focalise sur la compilation d’un langage comme OCaml, mais ce fragment
est expliqué au préalable en détail dans le chapitre 2. De même, le chapitre 8 se focalise
sur la compilation de Java, mais il commence par en expliquer les concepts.
Ce cours démarre par un chapitre sur l’assembleur x86-64. C’est une manière de se
jeter dans le bain de la compilation que de démarrer par le langage cible, c’est-à-dire
l’objectif final. Ensuite, on perd de vue un moment l’assembleur pour prendre le temps
iv
de poser tous les concepts amont de la compilation. Ce n’est qu’avec la partie III que
l’assembleur refera son apparition.
Jean-Christophe.Filliatre@cnrs.fr
Historique.
— Version 1 : 7 mars 2017
— Version 2 : 7 décembre 2017
— Version 3 : 5 décembre 2018
— Version 4 : 16 décembre 2019
— Version 5 : 7 décembre 2020
— Version 6 : 17 décembre 2021
— Version 7 : 15 décembre 2022
— Version 8 : 7 décembre 2023
I Préliminaires 1
1 Assembleur x86-64 3
1.1 Arithmétique des ordinateurs . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.2 L’architecture x86-64 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.3 Le défi de la compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.4 Un exemple de compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
4 Analyse syntaxique 55
4.1 Analyse syntaxique élémentaire . . . . . . . . . . . . . . . . . . . . . . . . 55
4.2 Grammaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.3 Analyse descendante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
4.4 Analyse ascendante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
4.5 L’outil menhir . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
vi
5 Typage statique 79
5.1 Typage simple de Mini-ML . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
5.2 Sûreté du typage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
5.3 Polymorphisme paramétrique . . . . . . . . . . . . . . . . . . . . . . . . . 85
5.4 Inférence de types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
Annexes 181
A Solutions des exercices 181
Bibliographie 199
Index 201
Première partie
Préliminaires
1
Assembleur x86-64
Nous faisons le choix d’utiliser ici l’architecture x86-64, car il s’agit d’une architecture
très répandue aujourd’hui. Néanmoins, beaucoup de ce que nous allons expliquer reste
valable pour d’autres architectures.
L’architecture x86-64 est issue d’une longue lignée d’architectures conçues par Intel
depuis 1986 puis par AMD et Intel depuis 2000. Elle appartient à la famille CISC, acro-
nyme pour Complex Instruction Set Computing. Cela signifie que son jeu d’instructions
est vaste, avec notamment beaucoup d’instructions permettant d’accéder en lecture ou
écriture à la mémoire, à l’opposé de la famille RISC (pour Reduced Instruction Set Com-
puting) qui cherche au contraire à limiter le jeu d’instructions. Un exemple d’architecture
de la famille RISC est ARM (pour Advanced RISC Machine).
Les bits bn−1 , bn−2 , etc. sont dits de poids fort et les bits b0 , b1 , etc. sont dits de poids
faible. Le nombre n de bits est typiquement égal à 8, 16, 32 ou 64. Lorsque n = 8 on parle
d’un octet (en anglais byte).
Si on interprète un tel entier comme non signé, c’est-à-dire comme un entier naturel,
sa valeur est donnée par
n−1
X
bi 2i
i=0
63 31 15 87 0 63 31 15 87 0
%rax %eax %ax %ah %al %r8 %r8d %r8w %r8b
la figure. Ainsi, le registre %esi représente les 32 bits de poids faible du registre %rsi et
le registre %al représente l’octet du poids faible du registre %rax.
.text
.globl main
main:
pushq %rbp
movq %rsp, %rbp
movq $message, %rdi
call puts
movq $0, %rax
popq %rbp
ret
.data
message:
.string "hello, world"
Jeu d’instructions x86-64. Le jeu d’instructions x86-64 contient littéralement des mil-
liers d’instructions, avec pour chacune de nombreuses combinaisons possibles d’opérandes,
et il n’est pas question de les décrire toutes ici. On trouve en ligne des documentations
exhaustives de l’architecture x86-64 5 . On se contente de décrire ici les instructions les
plus courantes et en particulier celles que nous utiliserons par la suite.
Les opérandes d’une instruction peuvent être de plusieurs natures. Il peut s’agir d’une
opérande immédiate de la forme $n. L’entier n est alors limité à 32 bits. Une opérande
peut également être un registre. Enfin, une opérande peut désigner un emplacement mé-
moire, soit sous la forme d’une adresse immédiate (un entier 32 bits), soit sous la forme
d’un adressage indirect relatif à un registre r. Dans ce dernier cas, l’opérande s’écrit (r)
et signifie l’emplacement mémoire désigné par l’adresse contenue dans le registre r. De
manière plus générale, une opérande indirecte prend la forme n(r1 , r2 , m) où n est une
constante, r1 et r2 des registres et m un entier valant 1, 2, 4 ou 8. Une telle opérande
désigne l’emplacement mémoire à l’adresse n + r1 + m × r2 .
Transfert de données. On a déjà croisé l’instruction mov, pour charger une constante
dans un registre. De manière plus générale, l’instruction mov op1 , op2 effectue une copie de
l’opérande op1 vers l’opérande op2 . Lorsque les opérandes ne permettent pas de déterminer
la taille de la donnée qui doit être copiée, on peut le préciser avec movb (un octet), movw
(deux octets, ou mot), movl (quatre octets, ou mot long) ou movq (huit octets, ou quadruple
mot). Ainsi, l’instruction movq $42, (%rdi) écrit l’entier 42 sur 64 bits à l’adresse donnée
par %rdi. Le suffixe q est ici nécessaire pour préciser qu’il s’agit d’un entier 64 bits. À
noter que toutes les combinaisons d’opérandes ne sont pas possibles. En particulier, on ne
peut pas faire deux accès à la mémoire dans une même instruction. On ne pourra donc
pas écrire mov (%rax), (%rcx) et il faudra utiliser deux instructions mov successivement,
par exemple mov (%rax), %rax suivie de mov %rax, (%rcx).
Lorsqu’on copie une valeur d’une opérande plus petite vers une opérande plus grande,
il faut préciser si on souhaite faire une extension de signe (movs) ou mettre des zéros dans
les bits de poids fort (movz). Il faut aussi préciser les tailles des deux opérandes. Ainsi,
l’instruction movsbq %al, %rdi copie l’entier 8 bits signé contenu dans %al dans les 64
bits du registre %rdi. Enfin, il existe une instruction particulière movabsq pour charger
une constante 64 bits dans un registre.
%rax + 2 et sub %rdi, %rsi l’opération %rsi ← %rsi − %rdi. C’est pourquoi on parle
de code à deux adresses, par opposition à d’autres architectures où les opérations arith-
métiques prennent trois opérandes — on parle alors de code à trois adresses.
Comme pour mov, beaucoup de combinaisons d’opérandes sont possibles, tant qu’on
n’accède pas plus d’une fois à la mémoire. La multiplication exige par ailleurs que son
opérande de destination soit un registre. La division est un peu particulière, dans le sens
où elle prend une unique opérande et divise l’entier contenu dans l’ensemble des deux
registres %rdx et %rax par cette opérande, puis met le quotient dans %rax et le reste
dans %rdx. Pour cette raison, une instruction particulière (cqto) permet de copier dans
l’ensemble %rdx-%rax la valeur (signée) contenue dans %rax, c’est-à-dire qu’elle réplique le
bit de signe de %rax partout dans %rdx. Il existe également des opérations arithmétiques
unaires pour incrémenter (inc), décrémenter (dec) et prendre la négation (neg).
Une opération arithmétique particulière, lea, calcule l’adresse effective d’une opérande
indirecte n(r1 , r2 , m), c’est-à-dire la valeur n + r1 + m × r2 , sans pour autant accéder à
la mémoire. Cette valeur est stockée dans la seconde opérande. Ainsi, l’instruction leaq
-1(%rdi), %rsi affecte au registre %rsi la valeur %rdi − 1. On peut avantageusement
utiliser l’instruction lea pour effectuer des calculs arithmétiques.
D’autres opérations, dites logiques, permettent de manipuler les bits sans interpréta-
tion arithmétique particulière. Ainsi, on peut effectuer un et logique (and), un ou logique
(or) ou encore un ou exclusif (xor) entre deux opérandes. De même, on peut prendre la
négation logique (not). On peut aussi décaler les bits d’un entier, vers la gauche (sal) en
introduisant des bits 0 à droite, vers la droite (shr) en introduisant des bits 0 à gauche
ou vers la droite (sar) en répliquant le bit de signe. Les opérations de décalage à gauche
et à droite peuvent être interprétées comme des opérations arithmétiques, respectivement
de multiplication et de division par une puissance de deux. Ainsi, sarq $2, %rdi peut
être vu comme la division de %rdi par quatre.
Exercice 1. Que fait l’instruction xorq %rax, %rax ? Solution
Exercice 2. Que fait l’instruction andq $-16, %rsp ? Solution
drapeaux. Cette valeur est calculée dans une opérande 8 bits avec les instructions setz,
setnz, sets, etc. On peut aussi effectuer une opération mov conditionnellement selon la
valeur des drapeaux, avec les instructions cmovz, cmovnz, cmovs, etc.
Deux instructions permettent de positionner les drapeaux sans pour autant modifier
de registres. Il s’agit de l’instruction cmp qui affecte les drapeaux comme l’aurait fait
l’instruction sub et l’instruction test qui affecte les drapeaux comme l’aurait fait l’ins-
truction and. Ainsi, si on souhaite obtenir le résultat du test %rdi ≤ 100 dans %al, on
peut utiliser cmpq $100, %rdi suivie de setle %al. Il faut prêter attention ici au sens
de la soustraction, qui peut être perturbant.
Pour manipuler la pile, on dispose d’instruction push et pop qui permettent respecti-
vement d’empiler et de dépiler des valeurs. Ainsi, l’instruction pushq $42 empile 64 bits
représentant l’entier 42 et l’instruction popq %rdi dépile 64 bits et les écrit dans le re-
gistre %rdi. Ces deux instructions mettent à jour la valeur du pointeur de pile %rsp. Bien
entendu, on peut manipuler la pile explicitement. Par exemple, addq $48, %rsp dépile
d’un coup 48 octets.
Expliquons maintenant comment est compilé un appel de fonction. Lorsqu’une fonc-
tion f, qu’on appelle l’appelant (en anglais caller ), souhaite appeler une fonction g, qu’on
appelle l’appelé (en anglais callee), on ne peut pas se contenter d’exécuter l’instruction
jmp g car il faudra revenir dans le code de f quand g aura terminé. La solution consiste
à se servir de la pile. Deux instructions sont là pour cela. D’une part, l’instruction
call g
empile l’adresse de l’instruction située juste après le call et transfert le contrôle à l’adresse
g. D’autre part, l’instruction
ret
dépile une adresse et y transfert le contrôle. Dans notre premier programme écrit plus
haut, nous avons justement utilisé l’instruction ret pour terminer notre programme. En
réalité, il n’était pas exactement terminé. Nous n’avions fait que revenir dans un morceau
de programme installé par le système, qui avait appelé notre programme. L’adresse de
retour se situait au sommet de la pile et notre programme n’avait pas modifié l’état de la
pile — dans le cas contraire, nous aurions eu une désagréable surprise.
Nous avons donc un mécanisme pour effectuer un appel de fonction et revenir au
point d’appel une fois qu’il est terminé. Il nous reste cependant un problème de taille :
tout registre modifié par l’appelé sera potentiellement perdu pour l’appelant. Il existe de
multiples manières de s’en sortir, mais on s’accorde en général sur des conventions d’appel .
Ces conventions dépendent de l’architecture et parfois même du système d’exploitation.
Sur l’architecture x86-64, ces conventions sont les suivantes. Les six premiers arguments
sont passés dans les registres %rdi, %rsi, %rdx, %rcx, %r8, %r9. Les autres arguments sont
passés sur la pile, le cas échéant. La valeur de retour est passée dans %rax. Les registres
%rbx, %rbp, %r12, %r13, %14 et %r15 sont dits callee-saved , c’est-à-dire que l’appelé se
doit de les sauvegarder. On y met donc des données de durée de vie longue, ayant besoin
de survivre aux appels. Les autres registres sont caller-saved , c’est-à-dire que l’appelant
se doit de les sauvegarder. On y met donc typiquement des données qui n’ont pas besoin
de survivre aux appels. Bien entendu, si l’appelé ne modifie pas un registre callee-saved,
il n’aura rien à faire pour le sauvegarder ; de même pour un registre caller-saved qui n’est
pas modifié par l’appelant. Enfin, les registres %rsp et %rbp sont réservés à la gestion de
la pile.
L’appel d’une fonction f implique donc la construction au sommet de la pile d’un
plus ou moins grand nombre de données. Au minimum, on trouvera l’adresse de retour.
Mais on peut trouver également des arguments (s’il y en a plus que six), des registres
callee-saved sauvegardés que la fonction f souhaite utiliser, des variables locales qui ne
peuvent être allouées dans des registres, etc. La totalité de ces données forme ce qu’on
appelle le tableau d’activation de cet appel (en anglais stack frame). La taille de ce tableau
pouvant varier pendant l’exécution de l’appel, il est de coutume d’utiliser le registre %rbp
12 Chapitre 1. Assembleur x86-64
pour désigner le début du tableau d’activation 7 . Ainsi, les deux registres %rbp et %rsp
encadrent le tableau d’activation situé au sommet de la pile. Comme le registre %rbp fait
partie des registres callee-saved, il doit être sauvegardé, ce qui implique que son ancienne
valeur fait elle-même partie du tableau d’activation. On se retrouve donc dans la situation
illustrée ci-dessous.
...
argument 8
argument 7
adr. retour
ancien %rbp
%rbp→
registres
sauvés
variables
locales
%rsp→
↓
locale f dans ce registre pour faciliter la compilation de l’instruction return f. Pour les
deux autres variables locales, à savoir d et e, on choisit de les allouer respectivement dans
les registres %r8 et %rcx. En cas d’appel récursif, les valeurs des six variables a, b, c,
d, e et f auront besoin d’être sauvegardées, car elles sont toutes utilisées après l’appel.
Comme il s’agit de six registres caller-saved, c’est la responsabilité de l’appelant de les
sauvegarder. Dans le cas d’un appel récursif, comme ici, l’appelant et l’appelé sont la
même fonction et cela ne fait donc pas de différence qu’on choisisse des registres caller-
save sauvegardés par l’appelant ou des registres callee-saved sauvegardés par l’appelé. Si
on se dispense de l’usage de %rbp, inutile ici, le tableau d’activation de la fonction t prend
la forme suivante :
..
.
adr. retour
%rax (f)
%rcx (e)
%r8 (d)
%rdx (c)
%rsi (b)
%rsp → %rdi (a)
Le code assembleur de la fonction t est donné figure 1.4, dans la colonne de gauche. On
commence par affecter la valeur 1 à f (ligne 4) puis on teste la valeur de a (lignes 5–6). Si
a est nul, on saute à la fin de la fonction (étiquette t_return, ligne 43). Sinon, on alloue
48 octets pour le tableau d’activation (ligne 7). Puis on affecte 0 à la variable f (ligne 8)
et la valeur de l’expression a & ~b & ~c à la variable e (lignes 9–15). Vient ensuite la
boucle while. Pour n’effectuer qu’un seul branchement par tour de boucle, on peut placer
le corps de la boucle en premier (lignes 17–35) puis le test de la boucle (lignes 36–42).
1.4. Un exemple de compilation 15
Par conséquent, on commence par un saut inconditionnel vers le test (ligne 16). Le corps
de la boucle commence par la sauvegarde des six variables dans le tableau d’activation
(lignes 18–23). Puis on prépare les trois arguments de l’appel récursif (lignes 24–28) avant
d’effectuer celui-ci (ligne 29). Une fois l’appel effectué, on restaure les variables, tout en
effectuant les mises à jour des variables e et f (lignes 30–35). Enfin, le test de la boucle
consiste à affecter à d la valeur de l’expression e & -e (lignes 37–40) puis à tester si le
résultat est non nul (ligne 41). On utilise ici le fait que la dernière opération arithmétique
effectuée (andq ligne 40) a positionné les drapeaux avec la valeur de d. Lorsqu’on sort de
la boucle, la ligne 42 désalloue le tableau d’activation.
Passons maintenant au code assembleur de la fonction main, donné figure 1.4 dans la
colonne de droite. On commence par sauvegarder %rbp et positionner, ce qui a notamment
pour effet d’aligner la pile. Puis on prépare l’appel à scanf. Les deux arguments sont mis
dans %rdi et %rsi respectivement. Noter que le second argument est l’adresse de la
variable n, et non sa valeur, et c’est pourquoi on doit écrire $n et non pas n (ligne 49). La
fonction scanf étant une fonction variadique, on doit indiquer son nombre d’arguments en
virgule flottante dans %rax. Comme il n’y en a pas ici, on met %rax à zéro (ligne 50). Puis
on prépare les arguments de l’appel à la fonction t. En particulier, on calcule la valeur
de ~(~0 << n) dans %rdi (lignes 53–57). Lorsqu’il n’est pas constant, l’instruction salq
exige la valeur du décalage dans le registre %cl et c’est pourquoi on a dû copier la valeur
de n dans %rcx. Après l’appel à t (ligne 60), il ne reste plus qu’à effectuer l’appel à printf
(lignes 62–66). Comme pour scanf, on doit mettre %rax à zéro (ligne 65). On termine
notre programme avec la valeur de retour (lignes 67–69). Au delà du code, on trouve les
données allouées statiquement, à savoir la variable n et les deux chaînes de format passées
respectivement à scanf et printf. La directive .quad 0 réserve huit octets initialisés
avec la valeur 0. La directive .string alloue une chaîne terminée par un caractère de
code ASCII 0, selon la convention du langage C et en particulier des fonctions scanf et
printf qu’on utilise ici.
isqrt(n) ≡
c←0
s←1
while s ≤ n
c←c+1
s ← s + 2c + 1
return c
Exercice 5. Écrire en assembleur la fonction factorielle, d’abord avec une boucle, puis
avec une fonction récursive. Solution
1 .text
2 .globl main
3 t:
4 movq $1, %rax
5 testq %rdi, %rdi
6 jz t_return
45 main:
7 subq $48, %rsp
46 pushq %rbp
8 xorq %rax, %rax
47 movq %rsp, %rbp
9 movq %rdi, %rcx
48 movq $input, %rdi
10 movq %rsi, %r9
49 movq $n, %rsi
11 notq %r9
50 xorq %rax, %rax
12 andq %r9, %rcx
51 call scanf
13 movq %rdx, %r9
52
14 notq %r9
53 xorq %rdi, %rdi
15 andq %r9, %rcx
54 notq %rdi
16 jmp loop_test
55 movq (n), %rcx
17 loop_body:
56 salq %cl, %rdi
18 movq %rdi, 0(%rsp)
57 notq %rdi
19 movq %rsi, 8(%rsp)
58 xorq %rsi, %rsi
20 movq %rdx, 16(%rsp)
59 xorq %rdx, %rdx
21 movq %r8, 24(%rsp)
60 call t
22 movq %rcx, 32(%rsp)
61
23 movq %rax, 40(%rsp)
62 movq $msg, %rdi
24 subq %r8, %rdi
63 movq (n), %rsi
25 addq %r8, %rsi
64 movq %rax, %rdx
26 salq $1, %rsi
65 xorq %rax, %rax
27 addq %r8, %rdx
66 call printf
28 shrq $1, %rdx
67 xorq %rax, %rax
29 call t
68 popq %rbp
30 addq 40(%rsp), %rax
69 ret
31 movq 32(%rsp), %rcx
70
32 subq 24(%rsp), %rcx
71 .data
33 movq 16(%rsp), %rdx
72 n:
34 movq 8(%rsp), %rsi
73 .quad 0
35 movq 0(%rsp), %rdi
74 input:
36 loop_test:
75 .string "%d"
37 movq %rcx, %r8
76 msg:
38 movq %rcx, %r9
77 .string "q(%d) = %d\n"
39 negq %r9
40 andq %r9, %r8
41 jnz loop_body
42 addq $48, %rsp
43 t_return:
44 ret
Figure 1.4 – Le résultat de la compilation du programme de la figure 1.3.
2
Qu’est-ce qu’un langage de
programmation
Dans la syntaxe concrète d’un certain langage de programmation, une telle expression
pourrait s’écrire
2*(x+1)
mais aussi avec plus de parenthèses, ici inutiles
(2 * ((x) + 1))
ou bien encore avec un commentaire, ici également inutile
2 * (* je multiplie par deux *) ( x + 1 )
Ces trois expressions de programme, et bien d’autres encore, représentent toutes le même
arbre de syntaxe abstraite donné plus haut.
Définissons formellement ce qu’est un arbre de syntaxe abstraite pour de telles expres-
sions arithmétiques. En nous limitant aux quatre constructions utilisées ci-dessus, une
expression e est donc soit une constante, soit une variable, soit l’addition de deux expres-
sions, soit la multiplication de deux expressions. On l’exprime formellement à l’aide d’une
grammaire, de la manière suivante :
e ::= c constante
| x variable
| e+e addition
| e×e multiplication
On peut voir cela comme la définition d’un type d’arbres dont les feuilles sont des
constantes ou des variables et dont les nœuds internes sont des additions ou des multipli-
cations. Ici c représente une constante quelconque (1, 42, etc.) et x représente une variable
quelconque (a, foo, etc). En particulier, on ne doit pas confondre la méta-variable x, qui
représente n’importe quelle variable, et la variable de nom x.
Le lecteur attentif a déjà remarqué que la syntaxe abstraite ne représente pas ex-
plicitement les parenthèses parmi ses constructions. Les parenthèses sont utiles dans la
syntaxe concrète, par exemple pour distinguer les deux expressions 2*(x+1) et 2*x+1 si
on suppose la multiplication prioritaire sur l’addition comme de coutume. Dans la syntaxe
abstraite, ces deux expressions sont représentées par deux arbres différents, à savoir
2.1. Syntaxe abstraite 19
× +
2 + × 1
x 1 2 x
Il n’y a pas lieu de conserver les parenthèses de la première expression dans l’arbre de
syntaxe abstraite. La forme de ces deux arbres se suffit à elle-même. On verra dans
le chapitre 4 comment l’analyse syntaxique traduit la syntaxe concrète vers la syntaxe
abstraite et notamment comment la présence de parenthèses influe sur la construction des
arbres de syntaxe abstraite.
Dans la grammaire ci-dessus, on a réutilisé pour l’addition et la multiplication des
notations infixes qui font écho à celles de la syntaxe concrète. C’est simplement par com-
modité. On aurait pu tout aussi bien choisir des symboles différents, comme ⊕ ou Add.
C’est également par commodité que l’on ne va pas systématiquement dessiner les arbres
de syntaxe abstraite mais utiliser une écriture en ligne où les parenthèses sont utilisées
uniquement pour montrer la structure. Ainsi on s’autorisera d’écrire 2 × (x + 1), tout en
ayant conscience qu’il s’agit là de syntaxe abstraite et non de syntaxe concrète et que les
parenthèses ne sont là que pour montrer que la racine de l’arbre est la multiplication.
Il n’y a pas que les parenthèses qui constituent une différence entre la syntaxe concrète
et la syntaxe abstraite. Ainsi, certaines constructions de la syntaxe concrète peuvent être
exprimées directement à l’aide d’autres constructions de la syntaxe abstraite. On ap-
pelle cela le sucre syntaxique. Ajoutons par exemple à nos expressions arithmétiques une
nouvelle construction syntaxique succ pour représenter l’opération +1, nous permettant
d’écrire 2 * succ x à la place de 2 * (x + 1). Une telle opération pourrait être ajou-
tée à la syntaxe abstraite comme une cinquième construction. Mais elle peut être aussi
exprimée à l’aide d’opérations qui existent déjà, à savoir l’addition et la constante 1.
L’intérêt de cette seconde option est de limiter le nombre des constructions de la syn-
taxe abstraite. Ainsi dans le langage C la construction a[i] est du sucre syntaxique pour
*(a+i) et dans le langage OCaml la construction [a; b; c] est du sucre syntaxique pour
a :: b :: c :: [].
e ::= x identificateur
| c constante (1, 2, . . ., true, . . .)
| op primitive (+, ×, fst, . . .)
| fun x → e fonction anonyme
| ee application
| (e, e) paire
| let x = e in e liaison locale
Même s’il est qualifié de « mini », ce langage n’en contient pas moins toute la complexité
d’un vrai langage de programmation. En fait, la seule présence de la variable, de l’abstrac-
tion (fun) et de l’application suffit à capturer n’importe quel modèle de calcul 1 . Mini-ML
y ajoute les constantes, les opérations primitives, les paires et les variables locales pour le
rapprocher d’un langage de programmation usuel.
On reste volontairement flou sur l’ensemble des constantes et des opérations primitives.
Afin de pouvoir écrire des exemples intéressants, on peut supposer que cela inclut au moins
des entiers et des opérations arithmétiques usuelles. Ainsi on peut écrire le programme
ou encore le programme
Le lecteur peut être surpris de ne pas trouver dans ce langage de construction condi-
tionnelle de type if-then-else ou encore de fonction récursive. Si on souhaite de telles
constructions dans nos programmes, on peut les ajouter comme autant de primitives par-
ticulières. Ainsi, la conditionnelle peut être matérialisée par une primitive opif et associée
au sucre syntaxique suivant :
def
if e1 then e2 else e3 = opif (e1 , ((fun _ → e2 ), (fun _ → e3 )))
1. C’est ce qu’on appelle le λ-calcul. L’abstraction fun x → e s’y note λx.e et s’appelle pour cette
raison une λ-abstraction.
2.2. Sémantique opérationnelle 21
On note que les deux branches e2 et e3 sont représentées par des abstractions, ceci afin de
geler le calcul correspondant, dans un sens qui sera justement précisé par la sémantique
que nous nous apprêtons à définir. De la même façon, la récursivité peut être introduite
par une autre primitive, opfix, également associée à un sucre syntaxique :
def
rec f x = e = opfix (fun f → fun x → e)
Variables libres et liées. Dans l’expression fun x → e, la variable x est dite liée
dans l’expression e et on dit que la construction fun est un lieur. Le nom de la variable
importe moins que la liaison elle-même. Ainsi, on peut écrire aussi bien fun x → +(x, 1)
que fun y → +(y, 1) et ces deux expressions dénotent exactement la même abstraction. On
dit qu’elles sont α-équivalentes. De la même façon, la construction let est un lieur. Dans
l’expression let x = e1 in e2 , la variable x est liée dans l’expression e2 . En revanche, elle
n’est pas liée dans l’expression e1 . Une variable qui n’est pas liée est dite libre. L’ensemble
des variables libres d’une expression peut être défini par récurrence.
Définition 1 (variables libres). L’ensemble des variables libres d’une expression e, noté
fv (e), est défini par récurrence sur e de la manière suivante :
fv (x) = {x}
fv (c) = ∅
fv (op) = ∅
fv (fun x → e) = fv (e) \ {x}
fv (e1 e2 ) = fv (e1 ) ∪ fv (e2 )
fv ((e1 , e2 )) = fv (e1 ) ∪ fv (e2 )
fv (let x = e1 in e2 ) = fv (e1 ) ∪ (fv (e2 ) \ {x})
Une expression sans variable libre est dite close. □
La suppression de la variable x de l’ensemble calculé pour les constructions fun et let
traduit très exactement le caractère lié de cette variable. En particulier, on a correctement
parenthésé le dernier cas pour exprimer une liaison dans e2 mais pas dans e1 . Ainsi, on a
fv (let x = +(20, 1) in (fun y → +(y, y)) x) = ∅
fv (let x = z in (fun y → (x y) t)) = {z, t}
Si on a le loisir de renommer à volonté les variables liées, il faut cependant être attentif
à d’éventuels conflit avec des variables libres. Ainsi, dans l’expression
v ::= c constante
| op primitive non appliquée
| fun x → e fonction
| (v, v) paire
Cette définition est inductive, car une paire n’est une valeur que s’il s’agit d’une paire
de valeurs. On note qu’une abstraction est toujours une valeur. Son corps reste une ex-
pression, ce qui traduit notre intention de ne pas évaluer sous les abstractions. Ainsi,
l’expression fun x → +(1, 2) est une valeur, même si l’expression +(1, 2) pourrait être
évaluée.
Substitution. Pour définir la sémantique à grands pas, nous avons besoin d’une opéra-
tion de substitution pour remplacer dans le corps d’une abstraction, lors d’une application,
le paramètre formel par l’argument effectif.
x[x ← v] = v
y[x ← v] = y si y ̸= x
c[x ← v] = c
op[x ← v] = op
(fun x → e)[x ← v] = fun x → e
(fun y → e)[x ← v] = fun y → e[x ← v] si y ̸= x
(e1 e2 )[x ← v] = (e1 [x ← v] e2 [x ← v])
(e1 , e2 )[x ← v] = (e1 [x ← v], e2 [x ← v])
(let x = e1 in e2 )[x ← v] = let x = e1 [x ← v] in e2
(let y = e1 in e2 )[x ← v] = let y = e1 [x ← v] in e2 [x ← v]
si y ̸= x
Dans le cas des constructions fun et let, on peut supposer que y ∈ ̸ fv (v), sans quoi la
substitution créerait une capture de variable. Si ce n’est pas le cas, il suffit de renommer
la variable y liée par fun ou let. □
Voici quelques exemples de substitutions. On prendra soin de bien faire attention aux
parenthésage des expressions et aux lieurs.
Règles d’inférence. Pour définir la relation e ↠ v qui nous intéresse, nous allons
utiliser le concept de règles d’inférence issu de la logique. Il consiste à définir une relation
comme la plus petite relation satisfaisant un ensemble d’axiomes de la forme
P
et un ensemble d’implications de la forme
P1 P2 ... Pn
P
On peut définir ainsi la relation Pair(n), qui caractérise les entiers naturels pairs, par les
deux règles
Pair(n)
et
Pair(0) Pair(n + 2)
Ces deux règles doivent se lire comme
On peut se persuader facilement que la plus petite relation satisfaisant ces deux propriétés
coïncide avec la propriété « n est un entier naturel pair ». D’une part, les entiers naturels
pairs sont clairement dedans, par récurrence. D’autre part, s’il y avait au moins un entier
impair, on pourrait enlever le plus petit, ce qui contredirait la minimalité de la relation.
Étant donnée une relation définie par des règles d’inférence, une dérivation est un
arbre dont les nœuds correspondent aux règles et les feuilles aux axiomes. Avec l’exemple
précédent, on a entre autres l’arbre
Pair(0)
Pair(2)
Pair(4)
On peut voir cet arbre comme une preuve que Pair(4) est vrai. Plus généralement, l’en-
semble des dérivations possibles caractérise exactement la plus petite relation satisfaisant
les règles d’inférence.
Pour établir une propriété d’une relation définie par un ensemble de règles d’inférence,
on peut raisonner par récurrence structurelle sur la dérivation. Cela signifie que l’on peut
appliquer l’hypothèse de récurrence à toute sous-dérivation. De manière équivalente, on
peut dire qu’on raisonne par récurrence sur la hauteur de la dérivation. En pratique, on
raisonne par récurrence sur la dérivation et par cas sur la dernière règle utilisée. Ainsi, à
supposer que n est un entier relatif dans la définition de Pair(n) ci-dessus, on peut montrer
∀n, Pair(n) ⇒ n ≥ 0 par récurrence sur la dérivation de Pair(n).
La règle suivante définit l’évaluation d’une paire. Très naturellement, elle exprime que
chacune des deux composantes doit être évaluée en une valeur et que la valeur finale est
la paire de ces deux valeurs.
e1 ↠ v1 e2 ↠ v2
(e1 , e2 ) ↠ (v1 , v2 )
Les deux dernières règles sont les plus complexes. Elles mettent en jeu les lieurs et l’opé-
ration de substitution. Pour l’expression let x = e1 in e2 , la sémantique exprime que e1
doit s’évaluer en une valeur v1 , qui est ensuite substituée à x dans e2 avant de pouvoir
poursuivre l’évaluation.
e1 ↠ v1 e2 [x ← v1 ] ↠ v
let x = e1 in e2 ↠ v
D’une façon analogue, l’évaluation d’une application exprime le fait que l’argument doit
d’abord être évalué, puis que sa valeur doit être substituée au paramètre formel dans le
corps de l’abstraction avant d’évaluer celui-ci.
e1 ↠ (fun x → e) e2 ↠ v2 e[x ← v2 ] ↠ v
e1 e2 ↠ v
Ces deux dernières règles expriment donc le choix d’une stratégie d’appel par valeur ,
où l’argument est complètement évalué avant l’appel. On aurait pu faire d’autres choix,
comme un appel par nom ou encore un appel par nécessité 2 .
À ces règles doivent s’ajouter des règles spécifiques à chacune des primitives. Ainsi,
pour que la primitive + représente effectivement l’addition de deux entiers, on ajoute la
règle suivante :
e1 ↠ + e2 ↠ (n1 , n2 ) n = n1 + n2
e1 e2 ↠ n
Ici, n1 et n2 représentent des valeurs entières, c’est-à-dire des constantes qui se trouvent
être des entiers (par opposition à d’autres constantes d’autres types, telles que true ou
false). La prémisse n = n1 +n2 signifie que n désigne la constante entière qui est la somme
de n1 et n2 .
La figure 2.1 regroupe l’ensemble des règles définissant la sémantique à grands pas de
Mini-ML, avec les primitives opif et opfix introduites plus haut et des primitives fst et snd
pour les deux projections associées aux paires. Les deux règles (if-true) et (if-false)
exigent notamment que les deux branches s’évaluent vers des fonctions, dont les corps e3
et e4 ne sont pas évalués. Ainsi, seule l’expression e3 (resp. e4 ) est évaluée lorsque le test
est vrai (resp. faux). La dernière règle, appelée (fix), exprime le fait que la primitive opfix
représente un opérateur de point fixe vérifiant l’identité opfix f = f (opfix f ) pour toute
fonction f .
Exemple. En appliquant les règles précédentes, on peut montrer que l’expression let x =
+(20, 1) in (fun y → +(y, y)) x s’évalue en la valeur 42. L’arbre de dérivation a la forme
suivante :
..
20 ↠ 20 1 ↠ 1 .
+ ↠ + (20, 1) ↠ (20, 1) fun . . . ↠ fun . . . 21 ↠ 21 +(21, 21) ↠ 42
+(20, 1) ↠ 21 (fun y → +(y, y)) 21 ↠ 42
let x = +(20, 1) in (fun y → +(y, y)) x ↠ 42
2. Ces notions seront introduites dans le chapitre 6.
2.2. Sémantique opérationnelle 25
e1 ↠ v1 e2 ↠ v2 e1 ↠ v1 e2 [x ← v1 ] ↠ v
(pair) (let)
(e1 , e2 ) ↠ (v1 , v2 ) let x = e1 in e2 ↠ v
e1 ↠ (fun x → e) e2 ↠ v2 e[x ← v2 ] ↠ v
(app)
e1 e2 ↠ v
e1 ↠ + e2 ↠ (n1 , n2 ) n = n1 + n2
(add)
e1 e2 ↠ n
On se rappellera que les constructions rec et if-then-else ont été introduites plus haut
comme du sucre syntaxique pour les primitives opfix et opif. Solution
Il existe des expressions e pour lesquelles il n’y a pas de valeur v telle que e ↠ v.
L’exemple le plus simple est sûrement l’expression 1 2, c’est-à-dire l’application de la
constante 1 à la constante 2. Clairement, la règle (app) ne s’applique pas car 1 ne s’évalue
pas en une abstraction. Et parmi les règles spécifiques aux primitives, aucune ne traite le
cas d’une application où la fonction s’évaluerait en une constante entière. Nous ne sommes
absolument pas choqués par le fait que l’expression 1 2 n’ait pas de valeur ; au contraire,
c’est plutôt rassurant.
Il existe cependant d’autres expressions moins problématiques qui n’ont pas de valeur
pour la sémantique à grands pas. Considérons l’expression
e1 ↠ random e 2 ↠ n1 0 ≤ n < n1
e1 e2 ↠ n
Retour sur les primitives opif et opfix . Maintenant que nous connaissons la sé-
mantique de Mini-ML, nous pouvons montrer comment définir les primitives opif et opfix.
Rappelons qu’on a défini la conditionnelle comme
def
if e1 then e2 else e3 = opif (e1 , ((fun _ → e2 ), (fun _ → e3 )))
Dès lors, on peut définir les constantes booléennes et la primitive opif de la façon suivante :
def
true = fun x → fun y → x ()
def
false = fun x → fun y → y ()
def
opif = fun p → fst p (fst (snd p)) (snd (snd p))
Ici, () désigne une constante dont la valeur n’est pas significative ; on aurait pu prendre un
entier. L’idée est simple : lorsque e1 s’évalue en true (resp. false), on applique la fonction
dont le corps est e2 (resp. e3 ). On pourrait même faire plus simple, pour éviter les paires,
en définissant dès le départ if e1 then e2 else e3 comme e1 (fun _ → e2 ) (fun _ → e3 ).
De même, il est possible de donner une définition à l’opérateur opfix que nous avions
introduit pour définir des fonctions récursives, de la manière suivante :
def
opfix = fun f → (fun x → f (fun y → x x y)) (fun x → f (fun y → x x y))
Le corps de cette fonction ressemble étrangement au terme ∆ ∆ dont nous avons montré
que l’évaluation ne termine pas.
28 Chapitre 2. Qu’est-ce qu’un langage de programmation
e → e1 → e2 → · · · → v
soit l’itération bloque sur en irréductible qui n’est pas une valeur
e → e1 → e2 → · · · → en
e → e1 → e2 → · · ·
ϵ
Pour définir la relation →, on commence par définir une relation → plus simple, correspon-
dant à une réduction « en tête », c’est-à-dire impliquant la construction la plus extérieure
de l’expression. Il y a en particulier deux règles de réduction en tête correspondant à la
liaison d’une valeur à une variable :
ϵ
(fun x → e) v → e[x ← v]
ϵ
let x = v in e → e[x ← v]
Ces deux règles traduisent le choix d’une stratégie d’appel par valeur, comme pour la
sémantique à grands pas. On se donne également des règles de réduction en tête pour les
primitives :
ϵ
+ (n1 , n2 ) → n avec n = n1 + n2
ϵ
fst (v1 , v2 ) → v1
ϵ
snd (v1 , v2 ) → v2
ϵ
opfix (fun f → e) → e[f ← opfix (fun f → e)]
ϵ
opif (true, ((fun _ → e1 ), (fun _ → e2 ))) → e1
ϵ
opif (false, ((fun _ → e1 ), (fun _ → e2 ))) → e2
dans l’exemple ci-dessus, on introduit la notion de contexte. Un contexte E est défini par
la grammaire suivante :
E ::= □
| Ee
| vE
| let x = E in e
| (E, e)
| (v, E)
Un contexte est un « terme à trou », ce dernier étant représenté par le symbole □. Comme
on peut le constater, un contexte E contient exactement un trou. On définit alors E(e)
comme étant le contexte E dans lequel le trou a été remplacé par l’expression e, le résultat
étant une expression. On peut le schématiser comme ceci
la partie blanche représentant le contexte E. Dans l’exemple donné plus haut, le contexte
de la sous-expression +(1, 2) est let x = □ in + (x, x).
On peut maintenant définir la sémantique à petits pas, c’est-à-dire la relation →, en
se servant de la notion de contexte pour effectuer des réductions de tête dans n’importe
quel contexte. Une seule règle suffit pour cela :
ϵ
e1 → e2
E(e1 ) → E(e2 )
ϵ
Elle exprime qu’une réduction de tête e1 → e2 peut être effectuée à l’endroit spécifié par
le contexte E. On peut le schématiser comme ceci
e1 e2
réductions de tête
ϵ
(fun x → e) v → e[x ← v]
ϵ
let x = v in e → e[x ← v]
ϵ
+ (n1 , n2 ) → n avec n = n1 + n2
ϵ
fst (v1 , v2 ) → v1
ϵ
snd (v1 , v2 ) → v2
ϵ
opfix (fun f → e) → e[f ← opfix (fun f → e)]
ϵ
opif (true, ((fun _ → e1 ), (fun _ → e2 ))) → e1
ϵ
opif (false, ((fun _ → e1 ), (fun _ → e2 ))) → e2
contextes de réduction
E ::= □
| Ee
| vE
| let x = E in e
| (E, e)
| (v, E)
On constate que les contextes ont une forme bien particulière, qui traduit un ordre
d’évaluation de la gauche vers la droite. En effet, on a le contexte E e qui permet de
réduire à gauche d’une application mais on a seulement le contexte v E pour réduire
à droite d’une application. Dès lors, on est forcé de réduire le membre gauche d’une
application en premier lieu. De la même manière, les contextes de l’évaluation d’une
paire, respectivement (E, e) et (v, E), nous obligent à évaluer la composante de gauche
en premier lieu. Ainsi, (+(1, 2), □) n’est pas un contexte d’évaluation valide.
On aurait très bien pu faire un autre choix, comme par exemple évaluer de la droite
vers la gauche, ou encore ne pas fixer d’ordre d’évaluation en proposant aussi bien le
contexte E e que le contexte e E. Dans le cas de Mini-ML, cela ne ferait pas de différence
quant à la valeur d’une expression, mais dans un langage plus complexe avec des effets
de bord ou des comportements exceptionnels, évaluer dans un ordre plutôt que dans un
autre peut faire une différence. Nous y reviendrons dans la section 2.4.
⋆
Définition 3 (évaluation et forme normale). On note → la clôture réflexive et transitive
⋆
de la relation →, c’est-à-dire e1 → e2 si et seulement si e1 se réduit en e2 en zéro, une
ou plusieurs étapes. On appelle forme normale toute expression e telle qu’il n’existe pas
d’expression e′ telle que e → e′ . □
D’une façon évidente, les valeurs sont des formes normales. Les formes normales qui
ne sont pas des valeurs sont les expressions erronées, comme 1 2.
⋆
Proposition 3 (grands pas impliquent petits pas). Si e ↠ v, alors e → v.
Preuve. On procède par récurrence sur la dérivation de e ↠ v. Supposons que la
dernière règle soit celle d’une application de fonction :
e1 ↠ (fun x → e3 ) e2 ↠ v2 e3 [x ← v2 ] ↠ v
e1 e2 ↠ v
Par hypothèses de récurrence pour chacune des trois dérivations en prémisses, et en notant
v1 la valeur fun x → e3 , on a les trois évaluations à petits pas
e1 → · · · → v1
e2 → · · · → v2
e3 [x ← v2 ] → · · · → v
Par passage au contexte (lemme précédent) on a donc également les trois évaluations
e1 e2 → · · · → v1 e2
v1 e2 → · · · → v1 v2
e3 [x ← v2 ] → · · · → v
En insérant la réduction
ϵ
(fun x → e3 ) v2 → e3 [x ← v2 ]
entre la deuxième et la troisième ligne, on obtient la réduction complète
e1 e2 → · · · → v
E ′ (e) ↠ (fun x → e3 ) e2 ↠ v2 e3 [x ← v2 ] ↠ v
E ′ (e) e2 ↠ v
2.3 Interprète
Un interprète est un programme qui réalise l’exécution d’un autre programme, sur
des entrées données. Il se distingue du compilateur par le fait que le travail est refait à
chaque fois, pour tout nouveau programme ou toutes nouvelles entrées. Le compilateur,
au contraire, construit un programme une fois pour toute, qui sera ensuite exécuté par la
machine sur toute entrée.
On peut programmer un interprète en suivant les règles de la sémantique 3 . On se
donne un type pour la syntaxe abstraite du langage et on écrit une fonction qui, étant
donnée une expression du langage, calcule sa valeur. En substance, c’est ce que l’exercice 7
page 19 proposait de faire sur un langage minimal d’expressions arithmétique. Écrivons
ici un interprète pour Mini-ML, dans le langage OCaml, qui suit la sémantique naturelle
de la figure 2.1 page 25. On commence par se donner un type OCaml pour la syntaxe
abstraite de Mini-ML.
type expression =
| Var of string
| Const of int
| Op of string
| Fun of string * expression
| App of expression * expression
| Paire of expression * expression
| Let of string * expression * expression
Les constantes sont ici limitées aux entiers et les primitives sont représentées par des
chaînes de caractères. On réalise ensuite l’opération de substitution e[x ← v] pour une
valeur v close. Comme il n’y a pas de capture possible de variable, la définition reste
simple 4 .
3. On peut également définir la sémantique d’un langage par la donnée d’un interprète, qu’on appelle
alors interprète de référence.
4. Sans l’hypothèse que v est close, on peut être amené à devoir renommer les lieurs sont lesquels la
valeur v est substituée.
34 Chapitre 2. Qu’est-ce qu’un langage de programmation
pas une paire, ou encore si l’expression e1 s’évalue en autre chose qu’une abstraction ou
l’une de ces trois primitives 5 .
# eval (App (Const 1, Const 2));;
Exception: Match_failure ("", 87, 6).
En ce sens, notre interprète réalise un typage dynamique, par opposition au typage statique
que nous verrons dans le chapitre 5. Notre interprète peut aussi ne pas terminer, par
exemple sur (fun x → x x) (fun x → x x).
# let b = Fun ("x", App (Var "x", Var "x")) in
eval (App (b, b));;
Interrupted.
Exercice 12. Ajouter les primitives opif et opfix à cet interprète. Solution
Un interprète plus efficace. Notre interprète de Mini-ML n’est pas très efficace car
il passe son temps à effectuer des substitutions et donc à reconstruire des expressions.
Une idée simple et naturelle pour éviter ces substitutions consiste à maintenir dans un
dictionnaire les valeurs des variables qui sont connues. On appelle environnement un tel
dictionnaire. Notre fonction eval va donc prendre le type suivant
val eval: environment -> expression -> value
où le type environment peut être facilement réalisé de manière purement applicative avec
les dictionnaires du module Map de la bibliothèque standard d’OCaml :
module Smap = Map.Make(String)
type environment = value Smap.t
Il y a cependant une difficulté dans cette approche. Si on évalue l’expression
le résultat est une fonction qui doit « mémoriser » que x = 1. Dans notre premier in-
terprète, c’est la substitution de x par 1 dans fun y → +(x, y) qui assurait cela. Mais
puisque nous cherchons justement à éviter la substitution, il faut trouver un autre moyen
de mémoriser la valeur de x dans fun y → +(x, y). La solution consiste à attacher un
environnement à toute valeur de la forme fun y → e. On appelle cela une fermeture 6 . On
définit donc un nouveau type value pour les valeurs :
type value =
| Vconst of int
| Vop of string
| Vpair of value * value
| Vfun of string * environment * expression
and environment = value Smap.t
5. Bien entendu, on pourrait programmer plus proprement cet échec, avec un type option ou une
exception plus explicite.
6. Nous en reparlerons au chapitre 7 lorsque nous compilerons les langages comme Mini-ML.
36 Chapitre 2. Qu’est-ce qu’un langage de programmation
On peut maintenant écrire la fonction eval qui calcule la valeur d’une expression dans
un environnement donné. On commence par les cas simples d’une expression qui est déjà
une valeur :
let rec eval env = function
| Const n ->
Vconst n
| Op op ->
Vop op
| Fun (x, e) ->
Vfun (x, env, e)
Dans ce dernier cas, on sauvegarde l’environnement env dans la valeur de type Vfun. Pour
cette raison, il est important d’avoir choisi ici une structure purement applicative pour
représenter les environnements. Pour une paire, il suffit d’évaluer ses deux composantes :
| Pair (e1, e2) ->
Vpair (eval env e1, eval env e2)
Le principal changement par rapport à l’interprète précédent se situe dans la construction
et l’utilisation de l’environnement. On y ajoute la valeur d’une variable lorsqu’on rencontre
une construction let
| Let (x, e1, e2) ->
eval (Smap.add x (eval env e1) env) e2
et on la consulte lorsque l’expression est une variable :
| Var x ->
Smap.find x env
Vient enfin le cas d’une application e1 e2 . Comme dans l’interprète précédent, on com-
mence par évaluer e1 et par examiner sa valeur. Le cas intéressant est celui de l’application
d’une fonction fun x → e. L’environnement contenu dans la fermeture Vfun est récupéré,
étendu avec la valeur de x, c’est-à-dire la valeur de e2 , puis le corps e de la fonction est
évalué dans cet environnement.
| App (e1, e2) ->
begin match eval env e1 with
| Vfun (x, clos, e) ->
eval (Smap.add x (eval env e2) clos) e
On utilise notamment ici le fait qu’une variable y apparaissant dans e est soit la va-
riable x, soit une variable dont la valeur est nécessairement donnée par l’environnement
clos, puisqu’on est en train d’évaluer une expression close. Les autres cas de l’application
correspondent à des primitives et ne diffèrent pas de ce que nous avions écrit précédem-
ment.
| Vop "+" ->
let Vpair (Vconst n1, Vconst n2) = eval env e2 in
Vconst (n1 + n2)
| Vop "fst" ->
let Vpair (v1, _) = eval env e2 in v1
| Vop "snd" ->
let Vpair (_, v2) = eval env e2 in v2
end
2.4. Langages impératifs 37
Exercice 13. Expliquer pourquoi il serait difficile de réaliser l’environnement par une
structure impérative, par exemple type environment = value Hashtbl.t. Solution
E, e ↠ v
où v désigne la valeur de l’expression e dans l’état E. Une telle relation est facilement
définie avec des règles telles que
E, e1 ↠ n1 E, e2 ↠ n2 n = n1 + n2
etc.
E, c ↠ c E, x ↠ E(x) E, e1 + e2 ↠ n
On pourrait également choisir une sémantique à petits pas, mais c’est inutile car l’éva-
luation d’une expression termine toujours. On se donne maintenant une syntaxe abstraite
pour des instructions 7 :
s ::= x←e affectation
| if e then s else s conditionnelle
| while e do s boucle
| s; s séquence
| skip ne rien faire
L’évaluation d’une instruction pouvant ne pas terminer, on va se donner une sémantique
à petits pas pour les instructions, sous la forme d’une relation
E, s → E ′
E, s1 → E1 , s′1
E, skip; s → E, s E, s1 ; s2 → E1 , s′1 ; s2
E, e ↠ true E, e ↠ false
E, if e then s1 else s2 → E, s1 E, if e then s1 else s2 → E, s2
E, e ↠ true
E, while e do s → E, s; while e do s
E, e ↠ false
E, while e do s → E, skip
On notera en particulier comment la boucle while est « dépliée » lorsque son test est
évalué en true. Enfin, on peut dire que l’évaluation d’une instruction s termine dans un
état E si on a
E, s →⋆ E ′ , skip
pour un certain état E ′ .
Exercice 14. Donner une sémantique à grands pas pour l’évaluation des instructions.
Solution
↓ ≈
⋆
C(e) −→m v ′
code(e1 + e2 ) = code(e1 )
addq $ − 8, %rsp
movq %rdi, (%rsp)
code(e2 )
movq (%rsp), %rsi
addq $8, %rsp
addq %rsi, %rdi
2.5. Preuve de correction d’un compilateur 39
⋆
Le cas e = n s’établit trivialement, car on a e → n et code(e) = movq $n, %rdi. Dans le
⋆ ⋆ ⋆ ⋆
cas où e = e1 + e2 , on a e → n1 + e2 → n1 + n2 avec e1 → n1 et e2 → n2 , ce qui nous
permet d’invoquer l’hypothèse de récurrence sur e1 et e2 . La preuve est alors conduite
avec les étapes suivantes :
code état justification
code(e1 ) R1 , M1 par hypothèse de récurrence
R1 (%rdi) = n1 et R1 (%rsp) = R(%rsp)
∀a ≥ R(%rsp), M1 (a) = M (a)
addq $ − 8, %rsp
movq %rdi, (%rsp) R1′ , M1′ R1′ = R1 {%rsp 7→ R(%rsp) − 8}
M1′ = M1 {R(%rsp) − 8 7→ n1 }
code(e2 ) R2 , M2 par hypothèse de récurrence
R2 (%rdi) = n2 et R2 (%rsp) = R(%rsp) − 8
∀a ≥ R(%rsp) − 8, M2 (a) = M1′ (a)
movq (%rsp), %rsi
addq $8, %rsp
addq %rsi, %rdi R′ , M2 R′ (%rdi) = n1 + n2
R′ (%rsp) = R(%rsp) − 8 + 8 = R(%rsp)
∀a ≥ R(%rsp),
M2 (a) = M1′ (a) = M1 (a) = M (a)
Ceci conclut la preuve de notre micro-compilateur.
la dénotation peut être une fonction qui associe à la valeur de la variable x la valeur de
l’expression, c’est-à-dire
[[x]] = x 7→ x
[[n]] = x 7→ n
[[e1 + e2 ]] = x 7→ [[e1 ]](x) + [[e2 ]](x)
[[e1 * e2 ]] = x 7→ [[e1 ]](x) × [[e2 ]](x)
L’analyse lexicale est le découpage du texte source en « mots ». De même que dans les
langues naturelles, ce découpage en mots facilite le travail de la phase suivante, l’analyse
syntaxique, qui sera expliquée dans le chapitre suivant.
Dans le contexte de l’analyse lexicale, les mots sont appelés des lexèmes (en anglais
tokens). Si on est par exemple en train de réaliser l’analyse lexicale d’un langage tel que
OCaml, et que le texte source est de la forme
alors la liste des lexèmes construite par l’analyseur lexical sera de la forme
alors le commentaire (* et hop *) joue le rôle d’un blanc, utile pour séparer deux
lexèmes, et le commentaire (* j’ajoute un *) celui d’un blanc inutile (en plus d’être
un commentaire inutile). Les commentaires sont parfois exploités par certains outils
46 Chapitre 3. Analyse lexicale
(ocamldoc, javadoc, etc.), qui les traitent alors différemment dans leur propre analyse
lexicale.
Pour réaliser l’analyse lexicale, on va utiliser des expressions régulières d’une part, pour
décrire les lexèmes, et des automates finis d’autre part, pour les reconnaître. On exploite
notamment la capacité à construire mécaniquement un automate fini reconnaissant le
langage décrit par une expression régulière.
Dans tout ce qui suit, on se donne un alphabet A, qui représente l’ensemble des
caractères des textes sources à analyser. En pratique, il s’agira des caractères ASCII 7 bits,
des caractères UTF-8, etc. Un mot sur l’alphabet A est une séquence finie, possiblement
vide, de caractères. Si un mot est formé des caractères a1 , a2 , . . ., an , dans cet ordre, on le
note naturellement a1 a2 · · · an et on appelle n sa longueur. Le mot vide, de longueur zéro,
est noté ϵ. Un langage est un ensemble de mots, non nécessairement fini. L’ensemble de
tous les mots possibles est un langage parmi d’autres, noté A⋆ .
Pour écrire des expressions régulières par la suite, nous prenons la convention que
l’étoile a la priorité la plus forte, puis la concaténation, puis enfin l’alternative. Si r est
une expression régulière et n un entier naturel, on définit l’expression régulière rn par
récurrence sur n en posant r0 = ϵ et rn+1 = r rn .
L(∅) = ∅
L(ϵ) = {ϵ}
L(a) = {a}
L(r1 r2 ) = {w1 w2 | w1 ∈ L(r1 ) ∧ w2 ∈ L(r2 )}
L(r1 | r2 ) = L(r1 ) ∪ L(r2 )
L(r⋆) = n≥0 L(rn )
S
□
3.2. Automate fini 47
Exercice 15. Toujours sur l’alphabet A = {a, b}, donner une expression régulière défi-
nissant le langage des mots contenant au plus deux caractères b.
Solution
Exemple. Sur l’alphabet A = {a, b}, on peut considérer l’automate défini par Q =
{0, 1}, T = {(0, a, 0), (0, b, 0), (0, a, 1)}, I = {0} et F = {1}. On représente traditionnelle-
ment un tel automate sous la forme
a
0 1
a, b
avec une flèche entrante sur les états initiaux et un double encerclement des états ter-
minaux. Une transition (q, c, q ′ ) ∈ Q est représentée par un arc reliant les états q et q ′ ,
étiqueté par le caractère c. Cet automate n’est pas déterministe, car il y a deux transitions
étiquetées par a sortant de l’état 0.
Ainsi, l’automate fini donné en exemple plus haut définit le langage des mots se ter-
minant par le caractère a.
Les expressions régulières et les automates finis sont liés par un résultat fondamental
de la théorie des langages qui dit qu’ils définissent tous deux les mêmes langages. Cela
signifie qu’un langage défini par une expression régulière l’est aussi par (au moins) un
automate fini et que le langage défini par un automate fini l’est aussi par (au moins)
une expression régulière. Ainsi, l’automate fini ci-dessus définit le même langage que
l’expression régulière (a|b) ⋆ a.
Pour calculer follow, on a besoin de calculer les premières (resp. dernières) lettres pos-
sibles d’un mot reconnu. Notons first(r) (resp. last(r)) cet ensemble pour une expression
régulière r donnée. Toujours avec le même exemple, on a
first(r) = {a1 , a2 , b1 }
last(r) = {a3 , b2 }
3.2. Automate fini 49
Et pour calculer first et last, on a besoin d’une dernière notion : est-ce que le mot vide
appartient au langage L(r) ? Notons null(r) cette propriété. Il s’avère qu’il est très facile
de déterminer null(r) par récurrence structurelle sur l’expression régulière r, de la manière
suivante :
On peut en déduire alors la définition de first et last, là encore en procédant par récurrence
structurelle sur l’expression régulière r. Pour first, on procède de la manière suivante :
first(∅) = ∅
first(ϵ) = ∅
first(a) = {a}
first(r1 r2 ) = first(r1 ) ∪ first(r2 ) si null (r1 )
= first(r1 ) sinon
first(r1 | r2 ) = first(r1 ) ∪ first(r2 )
first(r⋆) = first(r)
La seule subtilité se situe dans la concaténation r1 r2 . En effet, lorsque null (r1 ) est vrai, il
faut incorporer aussi first(r2 ) dans le résultat. La définition de last est similaire et laissée
en exercice.
Exercice 16. Donner la définition de last. Solution
Il en découle enfin la définition de follow, toujours par récurrence structurelle :
follow (c, ∅) = ∅
follow (c, ϵ) = ∅
follow (c, a) = ∅
follow (c, r1 r2 ) = follow (c, r1 ) ∪ follow (c, r2 ) ∪ first(r2 ) si c ∈ last(r1 )
= follow (c, r1 ) ∪ follow (c, r2 ) sinon
follow (c, r1 | r2 ) = follow (c, r1 ) ∪ follow (c, r2 )
follow (c, r⋆) = follow (c, r) ∪ first(r) si c ∈ last(r)
= follow (c, r) sinon
Nous avons maintenant tout ce qu’il nous faut pour construire l’automate fini recon-
naissant le langage de r. On commence par ajouter à la fin de r un caractère n’appartenant
pas à l’alphabet A ; notons-le #. L’état initial de notre automate est l’ensemble first(r#).
On construit ensuite les autres états et les transitions par nécessité, avec l’algorithme
suivant :
tant qu’il existe un état s dont il faut calculer les transitions
pour chaque caractère c de l’alphabet
soit s′ l’état ci ∈s follow (ci , r#)
S
c
ajouter la transition s −→ s′
Il est clair que cette construction termine, car les états possibles sont en nombre fini (ce
sont des sous-ensembles de l’ensemble des caractères de l’expression régulière). Les états
terminaux sont les états contenant le caractère #.
Sur l’exemple (a|b) ⋆ a(a|b), on obtient l’automate suivant :
b
a
{a1 , a2 , b1 } {a1 , a2 , a3 , b1 , b2 }
a
b a
b
b
{a1 , a2 , b1 , #} {a1 , a2 , a3 , b1 , b2 , #}
On peut vérifier facilement qu’il reconnaît bien le langage des mots contenant un carac-
tère a en avant-dernière position. Il se trouve que cet automate est minimal, au sens du
nombre d’états, mais ce n’est pas nécessairement toujours le cas.
Exercice 17. Donner le résultat de cette construction sur l’expression régulière (a|b) ⋆ a.
Comparer avec l’automate donné page 47. Solution
les mots-clés, ce qui serait extrêmement pénible, on fait plutôt le choix de classer les
expressions régulières par ordre de priorité. À longueur égale, c’est l’expression régulière
la plus prioritaire qui définit l’action à effectuer.
Enfin, une particularité notable d’un analyseur lexical est qu’on ne cherche pas à faire
de retour en arrière en cas d’échec. Ainsi, si a, ab et bc sont les trois lexèmes possibles de
notre langage, notre analyseur lexical va échouer sur l’entrée
abc
alors même que ce texte est décomposable en deux lexèmes, à savoir a et bc. La raison
de cet échec est qu’après avoir reconnu le lexème le plus long possible, à savoir ab, on ne
cherche plus à revenir sur cette décision. On échoue donc sur l’entrée c, qui n’est pas dans
le langage des lexèmes. C’est uniquement une considération pragmatique qui conduit à
cette décision de ne pas faire de retour en arrière, pour de meilleures performances.
{
type token =
| CONST of int
| PLUS
| TIMES
| LEFTPAR
| RIGHTPAR
| EOF
}
reconnue par l’expression régulière avec as s. Les autres lexèmes sont traités de façon
similaire. Le lexème EOF représente la fin de l’entrée et on utilise pour cela l’expression
régulière particulière eof.
Outre la construction des lexèmes, il convient de traiter aussi deux autres cas de fi-
gure. D’une part, il faut ignorer les blancs. Pour cela, on utilise l’expression régulière
[’ ’ ’\t’ ’\n’]+ qui reconnaît une séquence de caractères espaces, tabulations et re-
tours chariot. Pour ignorer ces caractères, on rappelle l’analyseur lexical token récursi-
vement, en lui passant en argument une variable lexbuf qui est implicite dans le code
ocamllex. Elle représente la structure de données sur laquelle on est en train de réaliser
l’analyse lexicale (une chaîne, un fichier, etc.). D’autre part, il faut penser aux carac-
tères illégaux. On reconnaît un caractère quelconque avec l’expression régulière _ puis on
signale l’erreur lexicale en levant une exception. En pratique, il faudrait faire un effort
supplémentaire pour signaler la nature et la position de cette erreur lexicale.
Un tel source ocamllex est écrit dans un fichier portant le suffixe .mll, par exemple
lexer.mll. On le compile avec l’outil ocamllex, comme ceci :
> ocamllex lexer.mll
3.4. L’outil ocamllex 53
Ceci a pour effet de produit un fichier OCaml lexer.ml qui contient la définition du type
token écrite dans le prélude et une fonction token :
type token = ...
val token : Lexing.lexbuf -> token
La structure de données sur laquelle on réalise l’analyse lexicale a le type Lexing.lexbuf,
provenant du module Lexing de la bibliothèque standard d’OCaml. Ce module fournit
plusieurs moyens de construire une valeur de ce type, comme par exemple une fonction
from_channel lorsqu’on souhaite analyser le contenu d’un fichier.
Raccourcis. Il est possible de définir des raccourcis pour des expressions régulières,
avec la construction let d’ocamllex. Ainsi, on peut avantageusement écrire un analyseur
lexical contenant des identificateurs, des constantes littérales entières et flottantes de la
manière suivante :
let letter = [’a’-’z’ ’A’-’Z’]
let digit = [’0’-’9’]
let decimals = ’.’ digit*
let exponent = [’e’ ’E’] [’+’ ’-’]? digit+
(2 + (3 * ((5 + 6) *
(7 * 8))))
Bien sûr, c’est un choix tout personnel d’utilisation des boîtes. D’autres utilisations sont
possibles, avec des résultats différents.
Reste le problème des parenthèses inutiles. Si on reprend l’exemple de l’expression 2 +
5 * (4 + 4), une seule paire de parenthèses est nécessaire pour son impression. Elle est
rendue nécessaire par le fait qu’un argument de la multiplication est une addition, à savoir
4+4, c’est-à-dire une opération de priorité inférieure. On pourrait être donc tenté d’écrire
une fonction d’impression prenant un argument supplémentaire représentant la priorité
de l’opération en cours d’impression. C’est une solution possible. Il y en a cependant une
autre, équivalente mais plus élégante, consistant à écrire plusieurs fonctions d’impression,
une pour chaque niveau de priorité. Ainsi, on peut écrire une fonction print_expr pour
imprimer les expressions de plus faible priorité, c’est-à-dire les sommes, puis une fonction
print_term pour imprimer les expressions de la priorité suivante, c’est-à-dire les produits,
1. La conversion %a de la bibliothèque Format permet de passer une fonction d’impression et un
argument pour cette fonction. Ici, c’est notre propre fonction print que l’on passe récursivement.
4.1. Analyse syntaxique élémentaire 57
puis enfin une troisième fonction print_factor pour imprimer les expressions de la plus
forte priorité, c’est-à-dire les expressions parenthésées.
La première de ces fonctions imprime les sommes, c’est-à-dire les expressions de la
forme Add. Elle le fait sans utiliser de parenthèses.
let rec print_expr fmt = function
| Add (e1, e2) -> fprintf fmt "%a +@ %a" print_expr e1 print_expr e2
Si en revanche l’expression n’est pas de la forme Add, elle passe la main à la deuxième
fonction, print_term :
| e -> print_term fmt e
On procède de même dans print_term, en affichant les expressions de la forme Mul, le
cas échéant, et en passant sinon la main à la troisième fonction.
and print_term fmt = function
| Mul (e1, e2) -> fprintf fmt "%a *@ %a" print_term e1 print_term e2
| e -> print_factor fmt e
La troisième fonction se charge du cas restant, à savoir les constantes littérales.
and print_factor fmt = function
| Const n -> fprintf fmt "%d" n
Si en revanche l’expression n’est pas une constante, alors elle l’imprime entre parenthèses,
avec print_expr.
| e -> fprintf fmt "(@[%a@])" print_expr e
L’intégralité du code est donné figure 4.1. L’effet obtenu est le bon, en particulier parce
qu’un argument de Mul de type Add ne sera pas traité par print_term mais passé à
print_factor, qui l’imprimera entre parenthèses. Sur l’exemple donné plus haut, on
obtient 2 + 3 * (5 + 6) * 7 * 8, ce qui est le résultat attendu.
type expr =
| Const of int
| Add of expr * expr
| Mul of expr * expr
open Format
La fonction d’impression que nous venons d’écrire nous a mis sur la bonne voie en
distinguant les sommes, les produits et les facteurs. Écrivons donc trois fonctions d’ana-
lyse syntaxique parse_expr, parse_term et parse_factor sur la même idée. Ces trois
fonctions ont le type unit -> expr. Notre invariant est que chacune de ces trois fonctions
consomme tous les lexèmes qui composent l’expression reconnue. La fonction parse_expr
doit reconnaître une somme. Elle commence par reconnaître un premier terme en appelant
la fonction parse_term.
let rec parse_expr () =
let e = parse_term () in
Puis elle examine le lexème suivant. S’il s’agit de PLUS, c’est-à-dire du symbole +, alors
on le consomme avec next et on poursuit la lecture d’une somme avec un autre appel à
parse_expr. Sinon, la lecture de la somme est terminée et on renvoie e.
if !tok = PLUS then begin next (); Add (e, parse_expr ()) end else e
La fonction parse_term procède de façon similaire, en reconnaissant un produit de fac-
teurs séparés par le lexème TIMES.
and parse_term () =
let e = parse_factor () in
if !tok = TIMES then begin next (); Mul (e, parse_term ()) end else e
Enfin, la fonction parse_factor doit reconnaître les constantes littérales et les expressions
parenthésées. Il n’y a aucune difficulté pour les premières. Il faut juste ne pas oublier de
consommer le lexème.
and parse_factor () = match !tok with
| CONST n -> next (); Const n
4.2. Grammaire 59
Pour les expressions parenthésées, c’est plus subtil. On commence par consommer le
lexème correspondant à la parenthèse ouvrante puis on reconnaît une expression avec
parse_expr.
| LEFTPAR ->
next ();
let e = parse_expr () in
Ensuite, il convient de vérifier qu’on trouve bien une parenthèse fermante juste derrière
l’expression e. Si ce n’est pas le cas, on signale une erreur de syntaxe. Sinon, on consomme
la parenthèse fermante et on renvoie e.
if !tok <> RIGHTPAR then error ();
next (); e
Si le premier lexème n’est ni une constante, ni une parenthèse ouvrante, alors on signale
une erreur de syntaxe.
| _ ->
error ()
Pour reconnaître l’expression toute entière, il suffit d’appeler la fonction parse_expr. En
effet, si l’expression n’est pas une somme, elle se réduira à un unique terme reconnu par
parse_term. Si de même ce terme n’est pas un produit, il se réduira à un unique facteur
reconnu par parse_factor. Il faut cependant penser à vérifier que toute l’entrée a bien
été consommée. On le fait de la manière suivante :
let e = parse_expr ()
let () = if !tok <> EOF then error ()
Sans cette dernière vérification, une entrée comme 1+2 3 serait acceptée. L’intégralité du
code est donné figure 4.2.
4.2 Grammaire
On a déjà utilisé informellement la notion de grammaire dans la section 2.1, pour
définir la syntaxe abstraite. On donne maintenant une définition formelle de ce qu’est une
grammaire.
Définition 8 (grammaire). Une grammaire non contextuelle (ou hors contexte) est un
quadruplet (N, T, S, R) où
— N est un ensemble fini de symboles non terminaux ;
— T est un ensemble fini de symboles terminaux ;
— S ∈ N est le symbole de départ (dit axiome) ;
— R ⊆ N × (N ∪ T )⋆ est un ensemble fini de règles de production. □
Exemple. Voici un exemple de grammaire très simple pour des expressions arithmé-
tiques constituées de constantes entières, d’additions, de multiplications et de parenthèses.
N = {E}
T = {+, *, (, ), int}
S=E
R = { (E, E+E), (E, E*E), (E, (E)), (E, int) }
60 Chapitre 4. Analyse syntaxique
let e = parse_expr ()
let () = if !tok <> EOF then error ()
En pratique, on note les règles sous une forme plus agréable à lire, comme ceci :
E → E+E
| E*E
(4.1)
| (E)
| int
Dans notre contexte, les terminaux de la grammaire sont les lexèmes produits par l’analyse
lexicale. Ici int désigne ici le lexème correspondant à une constante entière. □
Notre objectif est maintenant de définir le langage des mots acceptés par cette gram-
maire. Pour cela, on commence par introduire la notion de dérivation.
Définition 9 (dérivation). Un mot u ∈ (N ∪ T )⋆ se dérive en un mot v ∈ (N ∪ T )⋆ , et
on note u → v, s’il existe une décomposition
u = u1 Xu2
avec X ∈ N , X → β ∈ R et
v = u1 βu2 .
Une suite w1 → w2 → · · · → wn est appelée une dérivation. On parle de dérivation gauche
(resp. droite) si le non terminal réduit est systématiquement le plus à gauche, i.e., u1 ∈ T ⋆
(resp. le plus à droite, i.e., u2 ∈ T ⋆ ). On note →⋆ la clôture réflexive transitive de →. □
Avec la grammaire ci-dessus, on a par exemple la dérivation gauche suivante :
E → E*E
→ int * E
→ int * ( E )
→ int * ( E + E )
→ int * ( int + E )
→ int * ( int + int )
Le langage défini par une grammaire vient alors sans surprise :
Définition 10. Le langage défini par une grammaire non contextuelle G = (N, T, S, R)
est l’ensemble des mots de T ⋆ dérivés de l’axiome, i.e.,
L(G) = { w ∈ T ⋆ | S →⋆ w }.
□
Toujours avec la même grammaire, on a donc établi plus haut que
int * ( int + int ) ∈ L(G).
Définition 11 (arbre de dérivation). Pour une grammaire donnée, un arbre de dérivation
est un arbre dont les nœuds sont étiquetés par des symboles de la grammaire, de la manière
suivante :
— la racine est l’axiome S ;
— tout nœud interne X est un non terminal dont les fils sont étiquetés par β ∈ (N ∪T )⋆
avec X → β une règle de la grammaire. □
Pour un arbre de dérivation dont les feuilles forment le mot w dans l’ordre infixe, il est
clair qu’on a S →⋆ w. Inversement, à toute dérivation S →⋆ w, on peut associer un arbre
de dérivation dont les feuilles forment le mot w dans l’ordre infixe (preuve par récurrence
sur la longueur de la dérivation).
62 Chapitre 4. Analyse syntaxique
F T * F int
int F int
int
4.2. Grammaire 63
E → E + T → T + T → F + T → int + T → int + T * F
→ int + T * F * F → int + F * F * F → int + int * F * F
→ int + int * int * F → int + int * int * int
Exercice 18. Montrer que cette nouvelle grammaire reconnaît bien le même langage que
la précédente. Solution
Exercice 19. Voici une grammaire possible pour les termes du λ-calcul (en supposant
que l’on utilise ce que l’on appelle des indices de de Bruijn) :
T → nat
| T T
| lam T
Montrer que cette grammaire est ambiguë. Proposer une autre grammaire qui reconnaît
le même langage et qui n’est pas ambiguë. Solution
Malheureusement, déterminer si une grammaire est ou non ambiguë n’est pas déci-
dable 2 . On va donc utiliser des critères décidables suffisants pour garantir qu’une gram-
maire est non ambiguë, pour lesquels on sait en outre décider l’appartenance au langage
efficacement (avec un automate à pile déterministe). Les classes de grammaires définies
par ces critères portent des noms étranges comme LL(1), LR(0), SLR(1), LALR(1) ou
encore LR(1), que nous introduirons dans les sections suivantes. Mais pour cela, il nous
faut quelques notions supplémentaires sur les grammaires.
La première de ces notions est la propriété pour un non terminal X de se dériver en le
mot vide, c’est-à-dire X →⋆ ϵ. On note cette propriété null(X). On la définit de manière
plus générale pour un mot quelconque.
Enfin, la troisième notion caractérise les symboles terminaux qui peuvent apparaître
après un symbole non terminal dans une dérivation.
Ces notions sont analogues de celles que nous avions introduites sur les expressions
régulières page 48. Il reste à montrer comment les calculer.
2. On rappelle que « décidable » veut dire qu’on peut écrire un programme qui, pour toute entrée,
termine et répond oui ou non.
64 Chapitre 4. Analyse syntaxique
E E′ T T′ F
false false false false false
false true false true false
false true false true false
On en déduit que le mot vide peut être dérivé des symboles non terminaux E ′ et T ′
mais pas des symboles E, T et F . Ce n’était pas totalement évident a priori, car la règle
E → T E ′ aurait pu conduire à null(E) si on avait eu aussi null(T ).
Exercice 20. Justifier que l’on cherche un plus petit point fixe pour calculer les valeurs
de null. Solution
Venons-en maintenant au calcul de first. Pour calculer first(α) pour un mot quel-
conque, il suffit de savoir calculer first(X) pour chaque non terminal X. En effet, on a
first(ϵ) = ∅
first(aβ) = {a}, si a ∈ T
first(Xβ) = first(X), si ¬null(X)
first(Xβ) = first(X) ∪ first(β), si null(X)
Et pour calculer first(X), il suffit de considérer toutes les productions pour X dans la
grammaire : [
first(X) = first(β)
X→β
On se retrouve donc de nouveau avec des équations récursives définissant les ensembles
first(X). On peut là encore se servir du théorème de Knaster–Tarski, cette fois sur le
produit cartésien A = P(T ) × · · · × P(T ) muni, point à point, de l’inclusion comme
relation d’ordre. Le plus petit élément est ε = (∅, . . . , ∅).
Si on reprend l’exemple de la grammaire (4.3), on converge en quatre étapes, de la
manière suivante :
E E′ T T′ F
∅ ∅ ∅ ∅ ∅
∅ {+} ∅ {*} {(, int}
∅ {+} {(, int} {*} {(, int}
{(, int} {+} {(, int} {*} {(, int}
{(, int} {+} {(, int} {*} {(, int}
Il nous reste à calculer follow. De façon évidente, l’ensemble first(β) fait partie de
follow(X) si on a une production de la forme Y → αXβ. Plus subtilement, si null(β),
il faut aussi ajouter à follow(X) tous les éléments de follow(Y ). On a donc
[ [
follow(X) = first(β) ∪ follow(Y )
Y →αXβ Y →αXβ, null(β)
E E′ T T′ F
{#} ∅ ∅ ∅ ∅
{#, )} {#} {+, #} ∅ {*}
{#, )} {#, )} {+, #, )} {+, #} {*, +, #}
{#, )} {#, )} {+, #, )} {+, #, )} {*, +, #, )}
{#, )} {#, )} {+, #, )} {+, #, )} {*, +, #, )}
Exercice 21. Voici une grammaire possible pour les termes du λ-calcul :
S → T #
T → nat
(4.4)
| (T T )
| lam T
S → E#
E → sym
| (L) (4.5)
L → ϵ
| EL
pile entrée
E int+int*int#
E ′T int+int*int#
E ′T ′F int+int*int#
E ′ T ′ int int+int*int#
E ′T ′ +int*int#
E′ +int*int#
E ′T + +int*int#
E ′T int*int#
E ′T ′F int*int#
E ′ T ′ int int*int#
E ′T ′ *int#
E ′T ′F * *int#
E ′T ′F int#
E ′ T ′ int int#
E ′T ′ #
E′ #
ϵ #
E → T E′
E′ → + T E′ + * ( ) int #
| ϵ E T E′ T E′
T → F T′ E′ +T E ′ ϵ ϵ
T′ → * F T′ T FT ′
FT ′
| ϵ T′ ϵ *F T ′
ϵ ϵ
F → (E) F (E) int
| int
Analysons le mot int+int*int en nous servant de cette table. Les différentes étapes
sont illustrées figure 4.3. Initialement, la pile contient le symbole de départ E et l’entrée
contient le mot à analyser suivi du terminal #. Notre première décision implique le sym-
bole E au sommet de la pile et le symbole int au début de l’entrée. Comme E est un
non terminal, on consulte la table, qui indique de faire l’expansion E → T E ′ . On dépile
donc le symbole E et on empile T E ′ , en commençant par la fin. C’est donc T qui se
retrouve en sommet de pile. On consulte à nouveau ta table, cette fois avec T et int.
Elle indique de procéder à l’expansion T → F T ′ . On dépile donc T pour empiler T ′ puis
F . Une troisième consultation de la table indique l’expansion F → int. On dépile donc
F pour empiler int. Cette fois, on se retrouve avec un symbole non terminal, int, en
sommet de pile. On vérifie qu’il coïncide avec le début de l’entrée et, puisque c’est le cas,
les deux sont supprimés. Le sommet de pile devient T ′ et le début de l’entrée devient +.
La table indique de faire l’expansion T ′ → ϵ, ce qui a pour effet de dépiler T . Et ainsi de
suite. On se retrouve au final avec une pile vide et une entrée réduite à #, ce qui achève
notre analyse sur un succès.
Un analyseur descendant se programme très facilement en introduisant une fonction
pour chaque non terminal de la grammaire. Chaque fonction examine l’entrée et, selon
68 Chapitre 4. Analyse syntaxique
Exercice 23. Calculer la table d’expansion de la grammaire (4.3) et vérifier qu’on re-
trouve bien la table donnée page 67.
Bien entendu, rien ne nous empêche de nous retrouver avec une table d’expansion
contenant plusieurs expansions différentes dans une même case. Ce n’est pas nécessaire-
ment le symptôme d’une grammaire ambiguë, mais seulement d’une grammaire qui ne
se prête pas à une telle analyse descendante. C’est pourquoi on introduit la définition
suivante.
Définition 16 (grammaire LL(1)). Une grammaire est dite LL(1) si, dans la table pré-
cédente, il y a au plus une production dans chaque case. □
Il faut souvent transformer les grammaires pour les rendre LL(1). En particulier, une
grammaire récursive gauche, i.e. contenant une production de la forme X → Xα ne sera
jamais LL(1). Il faut alors supprimer la récursion gauche (directe ou indirecte). De même,
il faut factoriser les productions qui commencent par le même terminal (factorisation
gauche).
En conclusion, les analyseurs LL(1) sont relativement simples à écrire mais ils néces-
sitent d’écrire des grammaires peu naturelles. On va se tourner vers une autre solution.
4.4. Analyse ascendante 69
a
— si la table indique une lecture, alors il doit exister une transition sn → s dans
l’automate et on empile successivement le non terminal a et l’état s ;
— si la table indique une réduction X → α, avec α de longueur p, alors on doit trouver
α en sommet de pile
s0 x1 s1 . . . xn−p sn−p X s.
[X → α • β]
où X → αβ est une production de la grammaire. Un tel état est appelé un item. L’intuition
d’un tel état est « je cherche à reconnaître X, j’ai déjà lu α et je dois encore lire β ». Les
transitions de l’automate sont étiquetées par T ∪ N et sont de trois formes :
a
[Y → α • aβ] → [Y → αa • β] pour a ∈ T,
X
[Y → α • Xβ] → [Y → αX • β] pour X ∈ N,
ϵ
[Y → α • Xβ] → [X → •γ] pour toute production X → γ.
L’étape suivante consiste à déterminiser cet automate. Pour cela, on regroupe les états
reliés (transitivement) par des ϵ-transitions et les états deviennent donc des ensembles
d’items. Ainsi, l’état [S → •E], duquel sortaient trois transitions spontanées, devient
l’ensemble suivant de quatre items :
4.4. Analyse ascendante 71
S → •E
E → •E+E
E → •(E)
E → •int
D’une manière générale, on obtient les états de l’automate déterministe en les saturant
avec les ϵ-transitions dans le sens suivant : pour un état s de l’automate déterministe et un
item i appartenant à s, si i est relié par ϵ à un item j dans l’automate non-déterministe,
alors j appartient également à s.
Au final, on obtient l’automate fini déterministe donné au milieu de la figure 4.4. (On
a ajouté à la fin de la production S → E un nouveau symbole terminal # pour représenter
la fin de l’entrée, comme pour l’analyse descendante.) On peut maintenant construire la
table d’actions à partir de cet automate. En pratique, on travaille avec deux tables. On a
d’une part une table d’actions ayant pour lignes les états et pour colonnes les terminaux,
la case action(s, a) indiquant
— shift s′ pour une lecture et un nouvel état s′ ;
— reduce X → α pour une réduction ;
— un succès ;
— un échec.
On a d’autre part une table de déplacements ayant pour lignes les états et pour colonnes
les non terminaux, la case goto(s, X) indiquant l’état résultat d’une réduction de X. On
construit ainsi ces deux tables. Pour la table action, on pose
— action(s, #) = succès si [S → E • #] ∈ s ;
a
— action(s, a) = shift s′ si on a une transition s → s′ ;
— action(s, a) = reduce X → β si [X → β•] ∈ s, pour tout a ;
— échec dans tous les autres cas.
Pour la table goto, on pose
X
— goto(s, X) = s′ si et seulement si on a une transition s → s′ .
Sur notre exemple, on obtient la table donnée figure 4.4 (en bas). La table ainsi construite
peut contenir plusieurs actions possibles dans une même case. On appelle cela un conflit.
Il y a deux sortes de conflits :
— un conflit lecture/réduction (en anglais shift/reduce), si dans un état s on peut
effectuer une lecture mais aussi une réduction ;
— un conflit réduction/réduction (en anglais reduce/reduce), si dans un état s deux
réductions différentes sont possibles.
Lorsqu’il n’y a pas de conflit, on dit que la grammaire est LR(0).
Définition 17 (grammaire LR(0)). Une grammaire est dite LR(0) si la table ainsi
construite ne contient pas de conflit. (L’acronyme LR signifie « Left to right scanning,
Rightmost derivation ».)
Dans notre exemple, il y a un conflit dans l’état 8 (dernière ligne). Si le caractère en
entrée est +, on peut tout autant lire ce caractère que réduire la production E → E + E.
L’état 8 est l’état en bas à droite de l’automate, i.e. celui qui contient à la fois E →
E+E• et E → E • +E. Ce conflit illustre précisément l’ambiguïté de la grammaire sur un
mot tel que int+int+int, après avoir lu int+int. On peut résoudre le conflit de deux
façons : soit on favorise la lecture, en traduisant alors une associativité à droite ; soit on
favorise la réduction, en traduisant alors une associativité à gauche. La figure 4.5 illustre
72 Chapitre 4. Analyse syntaxique
une grammaire
S → E
E → E+E
| (E)
| int
int int +
(
(
E → ( • E) E → E+ • E
E → •E +E E E → (E • ) + E → •E +E
E → •(E ) E → E • +E E → •(E )
E → •int E → •int
) E +
(
E → E +E •
E → (E )•
E → E • +E
sa table LR(0)
action goto
état ( ) int + # E
1 shift 4 shift 2 3
2 reduce E → int
3 shift 6 succès
4 shift 4 shift 2 5
5 shift 7 shift 6
6 shift 4 shift 2 8
7 reduce E → (E)
8 shift 6
reduce E → E+E
Exercice 26. Montrer que la grammaire du λ-calcul, donnée dans l’exercice 21 page 66,
est LR(0). Solution
[X → β•] ∈ s et a ∈ follow(X).
Définition 18 (classe SLR(1)). Une grammaire est dite SLR(1) si la table ainsi construite
ne contient pas de conflit. (SLR signifie en anglais Simple LR.) □
S → E#
E → E+T
| T
T → T *F
| F
F → (E)
| int
Exercice 28. La grammaire de LISP, donnée dans l’exercice 22 page 66, est-elle SLR(1) ?
Solution
74 Chapitre 4. Analyse syntaxique
S → E#
E → G=D
| D
G → *D
| id
D → G
Analyse LR(1). En pratique, la classe SLR(1) reste trop restrictive, comme le montre
l’exemple de l’exercice 29. C’est là un exemple réaliste, issue de la grammaire du langage C.
On introduit donc une classe de grammaires encore plus large, LR(1), au prix de tables
encore plus grandes. Les items ont maintenant la forme
[X → α • β, a]
L’état initial est celui qui contient [S → •α, #]. Comme précédemment, on peut dé-
terminiser l’automate et construire la table correspondante. On introduit une action de
réduction pour (s, a) seulement lorsque s contient un item de la forme [X → α•, a].
Définition 19 (classe LR(1)). Une grammaire est dite LR(1) si la table ainsi construite
ne contient pas de conflit.
Autres classes. La construction LR(1) pouvant être coûteuse, il existe des approxi-
mations. La classe LALR(1) (pour lookahead LR) est une telle approximation, utilisée
notamment dans les outils de la famille yacc dont nous parlerons dans la section sui-
vante. On peut chercher à comparer les différentes classes de grammaires que nous avons
introduites jusqu’ici. On peut le faire également en interprétant une classe de grammaires
comme une classe de langages, i.e. en disant par exemple qu’un langage est LL(1) s’il est
reconnu par une grammaire LL(1). Les comparaisons de ces classes de grammaires et de
langages, en termes ensemblistes, sont les suivantes :
4.5. L’outil menhir 75
grammaires langages
LR(1)
LL(1) LR(0)
LALR(1)
SLR(1)
LR(0)
Il s’agit là d’inclusion strictes. On note en particulier que la classe LR(1) est strictement
plus expressive que la classe LL(1), autant en termes de grammaires qu’en terme de
langages.
À chaque production est associée une action, qui est un code OCaml arbitraire écrit entre
accolades. Ici, ce code calcule la valeur entière de l’expression qui est reconnue. Dans un
exemple plus réaliste, ce code construirait un arbre de syntaxe abstraite pour l’expression
arithmétique. La syntaxe e1 = ... dans les règles de grammaire permet de récupérer la
76 Chapitre 4. Analyse syntaxique
%left PLUS
%left TIMES
%%
phrase:
| e = expression; EOF { e }
expression:
| e1 = expression; PLUS; e2 = expression { e1 + e2 }
| e1 = expression; TIMES; e2 = expression { e1 * e2 }
| LEFTPAR; e = expression; RIGHTPAR { e }
| i = CONST { i }
valeur d’un terminal (dans le cas de CONST ici) ou la valeur construite récursivement par un
non terminal de la grammaire (ici expression). On peut ainsi construire la valeur d’une
expression arithmétique de bas en haut, en récupérant les valeurs de ses sous-expressions.
Si un tel source menhir est contenu dans un fichier arith.mly, on le compile avec la
commande
> menhir -v arith.mly
et on obtient du code OCaml dans deux fichiers arith.ml et arith.mli. Ce module
exporte la déclaration d’un type token
type token = RPAR | PLUS | LPAR | INT of int | EOF
et une fonction phrase du type
val phrase: (Lexing.lexbuf -> token) -> Lexing.lexbuf -> int
Le premier est construit à partir des déclarations %token et le second à partir de la
déclaration %start. Plusieurs déclarations %start sont possibles et donnent alors lieu à
plusieurs fonctions exportées.
Lorsque la grammaire n’est pas LR(1), menhir annonce les conflits et les présente à
l’utilisateur dans deux fichiers. Le fichier .automaton contient une description de l’au-
tomate LR(1) et les conflits y sont mentionnés. Le fichier .conflicts contient une ex-
plication des conflits, sous la forme d’une séquence de lexèmes conduisant à deux arbres
de dérivation. En présence de conflits, menhir fait un choix arbitraire entre lecture et ré-
duction mais l’utilisateur peut indiquer comment choisir entre lecture et réduction. Pour
cela, on donne des priorités aux lexèmes et aux productions et des règles d’associativité.
Par défaut, la priorité d’une production est celle de son lexème le plus à droite (mais elle
peut être spécifiée explicitement). Si la priorité de la production est supérieure à celle du
4.5. L’outil menhir 77
lexème à lire, alors la réduction est favorisée. Inversement, si la priorité du lexème est
supérieure, alors la lecture est favorisée. En cas d’égalité, l’associativité est consultée :
un lexème associatif à gauche favorise la réduction et un lexème associatif à droite la
lecture. Dans notre exemple, on a déclaré PLUS et TIMES comme associatifs à gauche avec
la déclaration %left. De plus, on a déclaré TIMES comme plus prioritaire que PLUS en
faisant apparaître %left TIMES plus bas que %left PLUS. Dès lors, tous les conflits sont
résolus.
L’outil menhir offre de nombreux avantages par rapport aux outils traditionnels de la
famille yacc comme ocamlyacc. On renvoie au manuel de menhir pour plus de détails [24].
S → E EOF
E → IF E THEN E
| IF E THEN E ELSE E
| CONST
Notes bibliographiques. L’analyse LR a été inventée par Donald Knuth [16]. Elle est
décrite en détail dans la section 4.7 de Compilateurs : principes techniques et outils de
A. Aho, R. Sethi, J. Ullman (dit « le dragon ») [1, 2].
78 Chapitre 4. Analyse syntaxique
5
Typage statique
Cela signifie que les programmes acceptés par le typage s’exécuteront sans échec, au sens de
la sémantique opérationnelle présentée dans la section 2.2. Par exemple, un programme
bien typé ne doit pas aboutir à l’utilisation d’un entier comme une fonction. Au delà
de la propriété de sûreté, le typage doit aussi avoir la propriété d’être décidable. Il ne
serait en effet pas raisonnable qu’un compilateur ne termine pas à cause du typage, de
même qu’il ne serait pas raisonnable qu’il réponde « je ne sais pas ». Bien entendu, il
suffirait de rejeter tous les programmes pour avoir automatiquement la propriété de sûreté
mais cela ne constituerait pas un langage très intéressant. Le typage se doit donc d’être
également expressif, en ne rejetant pas trop de programmes non-absurdes. Il y a une
tension indéniable entre sûreté et expressivité du typage. Ainsi, le langage OCaml rejette
un programme comme
(fun x -> x x) (fun x -> x x)
80 Chapitre 5. Typage statique
e ::= x identificateur
| c constante (1, 2, . . ., true, . . .)
| op primitive (+, ×, fst, . . .)
| fun x → e fonction anonyme
| ee application
| (e, e) paire
| let x = e in e liaison locale
comme étant mal typé, bien que ce dernier ne pose en réalité aucune difficulté au moteur
d’exécution d’OCaml.
Dans ce chapitre, on choisit d’illustrer le typage statique sur l’exemple du langage Mini-
ML, déjà étudié au chapitre 2, dont on redonne la syntaxe abstraite dans la figure 5.1.
Ainsi, int → int × int est un type, à savoir le type des fonctions qui reçoivent un entier
en argument et renvoient une paire d’entiers. On ne précise pas ici ce que sont exactement
les types de bases, car ils dépendent des constantes et des primitives que l’on a choisies
pour Mini-ML. Pour donner un type à une expression quelconque, on va introduire la
notion de jugement de typage. Il s’agit d’une relation ternaire entre un environnement de
typage Γ, une expression e et un typage τ , que l’on note
Γ⊢e:τ
Avec ces règles de typage, on peut montrer que l’expression let f = fun x → +(x, 1) in f 2
est bien typée de type int dans un environnement vide, avec la dérivation suivante (où
5.1. Typage simple de Mini-ML 81
etc. etc.
Γ ⊢ x : Γ(x) Γ ⊢ n : int Γ ⊢ + : int × int → int
Γ + x : τ1 ⊢ e : τ2 Γ ⊢ e2 : τ1 → τ2 Γ ⊢ e1 : τ1
Γ ⊢ fun x → e : τ1 → τ2 Γ ⊢ e2 e1 : τ2
Γ ⊢ e1 : τ1 Γ ⊢ e2 : τ2 Γ ⊢ e1 : τ1 Γ + x : τ1 ⊢ e2 : τ2
Γ ⊢ (e1 , e2 ) : τ1 × τ2 Γ ⊢ let x = e1 in e2 : τ2
.. ..
. .
x : int ⊢ (x, 1) : int × int
x : int ⊢ +(x, 1) : int ... ⊢ f : int → int ... ⊢ 2 : int
∅ ⊢ fun x → +(x, 1) : int → int f : int → int ⊢ f 2 : int
∅ ⊢ let f = fun x → +(x, 1) in f 2 : int
Γ ⊢ 1 : τ′ → τ Γ ⊢ 2 : τ′
Γ⊢12:τ
f : τ → τ ⊢ (f 1, f true) : τ1 × τ2
Exercice 32. Montrer qu’on peut donner un type à ((fun x → x) (fun x → x)) 42.
Solution
82 Chapitre 5. Typage statique
En particulier, on ne peut pas donner un type satisfaisant à une primitive comme fst ;
il faudrait choisir entre une infinité de types possibles :
int × int → int
int × bool → int
bool × int → bool
(int → int) × int → int → int
etc.
Il en va de même pour les primitives opif et opfix. Si on ne peut pas donner un type satis-
faisant à opfix, on pourrait en revanche donner une règle de typage pour une construction
let rec qui serait primitive :
Γ + x : τ1 ⊢ e1 : τ1 Γ + x : τ1 ⊢ e2 : τ2
Γ ⊢ let rec x = e1 in e2 : τ2
Et si on souhaite exclure les valeurs récursives, on peut la modifier ainsi :
Γ + (f : τ → τ1 ) + (x : τ ) ⊢ e1 : τ1 Γ + (f : τ → τ1 ) ⊢ e2 : τ2
Γ ⊢ let rec f x = e1 in e2 : τ2
Exercice 33. Donner la dérivation de typage de l’expression
let rec fact n = if =(n, 0) then 1 else × (n, fact (+(n, −1))) in fact 2.
Solution
Algorithme de typage. Pour l’instant, nous avons défini une relation de typage, entre
une expression et un type, mais nous ne disposons pas d’un algorithme de typage, qui
décide si une expression est ou non bien typée. En particulier, un algorithme de typage
doit choisir un type à donner à x quand il tombe sur une expression de la forme fun x → e.
Considérons une approche simple où ce type est donné par l’utilisateur, par exemple sous
la forme fun x : τ → e. C’est ainsi qu’on procède dans de nombreux langages typés
statiquement, comme C ou Java par exemple, où les paramètres formels d’une fonction
sont accompagnés de leur type. Dès lors, il suffit de suivre les règles de la figure 5.2 pour
calculer le type d’une expression par récurrence sur la structure de cette expression. En
notant T (Γ, e) cet algorithme de calcul de type, on a :
T (Γ, x) = Γ(x)
T (Γ, n) = int
T (Γ, +) = int × int → int
T (Γ, fun x : τ1 → e) = τ1 → T (Γ + x : τ1 , e)
T (Γ, (e1 , e2 )) = T (Γ, e1 ) × T (Γ, e2 )
T (Γ, let x = e1 in e2 ) = T (Γ + x : T (Γ, e1 ), e2 )
T (Γ, e2 e1 ) = τ2 si T (Γ, e2 ) = T (Γ, e1 ) → τ2
Parce que l’algorithme de typage procède récursivement sur la syntaxe des expressions,
sans qu’on ait besoin de faire de choix ou de retour en arrière, on dit qu’il est dirigé
par la syntaxe (en anglais syntax-directed ). On note que seul le dernier cas implique une
vérification, à savoir que le type de e2 est bien un type d’une fonction attendant un
argument du type de e1 . Dans tous les autres cas, l’expression est bien typée dès lors que
ses sous-expressions sont bien typées.
5.2. Sûreté du typage 83
⋆
Théorème 2 (sûreté du typage). Si ∅ ⊢ e : τ et e → e′ avec e′ irréductible, alors e′ est
une valeur.
Preuve. On a e → e1 → · · · → e′ et par applications répétées du lemme de préserva-
tion, on a donc ∅ ⊢ e′ : τ . Par le lemme de progrès, e′ se réduit ou est une valeur. C’est
donc une valeur. □
□
Si une variable de type est libre dans un type, on peut chercher à lui substituer un
autre type, de manière analogue à la substitution d’une variable par une valeur dans une
expression. Comme pour cette dernière, on prend soin d’arrêter la substitution lorsque
l’on rencontre une variable liée portant le même nom que la variable à remplacer.
86 Chapitre 5. Typage statique
etc. etc.
Γ ⊢ x : Γ(x) Γ ⊢ n : int Γ ⊢ + : int × int → int
Γ + x : τ1 ⊢ e : τ2 Γ ⊢ e2 : τ1 → τ2 Γ ⊢ e1 : τ1
Γ ⊢ fun x → e : τ1 → τ2 Γ ⊢ e2 e1 : τ2
Γ ⊢ e1 : τ1 Γ ⊢ e2 : τ2 Γ ⊢ e1 : τ1 Γ + x : τ1 ⊢ e2 : τ2
Γ ⊢ (e1 , e2 ) : τ1 × τ2 Γ ⊢ let x = e1 in e2 : τ2
int[α ← τ ′ ] = int
α[α ← τ ′ ] = τ′
β[α ← τ ′ ] = β si β ̸= α
(τ1 → τ2 )[α ← τ ′ ] = τ1 [α ← τ ′ ] → τ2 [α ← τ ′ ]
(τ1 × τ2 )[α ← τ ′ ] = τ1 [α ← τ ′ ] × τ2 [α ← τ ′ ]
(∀α.τ )[α ← τ ′ ] = ∀α.τ
(∀β.τ )[α ← τ ′ ] = ∀β.τ [α ← τ ′ ] si β ̸= α
Les règles de typage dans ce nouveau système de types sont données figure 5.3. Ce
sont exactement les mêmes règles qu’auparavant (figure 5.2 page 81), auxquelles on adjoint
deux nouvelles règles (sur la dernière ligne). La première dit que le type d’une expression
peut être généralisé par rapport à une variable de type α dès lors que cette variable
n’apparaît pas dans le contexte Γ.
Γ⊢e:τ α ̸∈ L(Γ)
Γ ⊢ e : ∀α.τ
La seconde dit qu’un type polymorphe peut être spécialisé par n’importe quel type.
Γ ⊢ e : ∀α.τ
Γ ⊢ e : τ [α ← τ ′ ]
Le système ainsi obtenu s’appelle le système F. On note que ce système n’est pas dirigé
par la syntaxe. En effet, trois règles s’appliquent maintenant potentiellement à chaque
expression : la règle de l’ancien système, la règle de généralisation et la règle de spéciali-
sation.
5.3. Polymorphisme paramétrique 87
... ⊢ f : ∀α.α → α ..
. ..
x:α⊢x:α ... ⊢ f : int → int .
⊢ fun x → x : α → α ... ⊢ f 1 : int ... ⊢ f true : bool
⊢ fun x → x : ∀α.α → α f : ∀α.α → α ⊢ (f 1, f true) : int × bool
⊢ let f = fun x → x in (f 1, f true) : int × bool
fst : ∀α.∀β.α × β → α
snd : ∀α.∀β.α × β → β
opif : ∀α.bool × α × α → α
opfix : ∀α.(α → α) → α
Γ+x:α⊢x:α
Γ + x : α ⊢ x : ∀α.α
Γ ⊢ fun x → x : α → ∀α.α
On peut montrer que le système F est un système de types sûr pour Mini-ML, comme
nous l’avons fait plus haut pour les types simples, même si la preuve est plus complexe.
Malheureusement, on n’en déduit pas pour autant facilement un algorithme de typage.
En effet, il s’avère que le problème de l’inférence de type (étant donné e, existe-t-il τ tel
que ⊢ e : τ ?) tout comme celui de la vérification (étant donnés e et τ , a-t-on ⊢ e : τ ?)
ne sont pas décidables. Pour obtenir une inférence de types décidable, on va restreindre
la puissance du système F.
etc. etc.
Γ ⊢ x : Γ(x) Γ ⊢ n : int Γ ⊢ + : int × int → int
Γ + x : τ1 ⊢ e : τ2 Γ ⊢ e1 : τ ′ → τ Γ ⊢ e2 : τ ′
Γ ⊢ fun x → e : τ1 → τ2 Γ ⊢ e1 e2 : τ
Γ ⊢ e1 : τ1 Γ ⊢ e2 : τ2 Γ ⊢ e1 : σ1 Γ + x : σ1 ⊢ e2 : σ2
Γ ⊢ (e1 , e2 ) : τ1 × τ2 Γ ⊢ let x = e1 in e2 : σ2
σ ::= τ schémas
| ∀α.σ
Dans le système de Hindley-Milner, les types suivants sont toujours acceptés
∀α.α → α
∀α.∀β.α × β → α
∀α.bool × α × α → α
∀α.(α → α) → α
mais plus les types tels que
(∀α.α → α) → (∀α.α → α).
Un environnement de typage Γ associe à des variables des schémas de types et la relation
de typage a maintenant la forme Γ ⊢ e : σ. Les règles du système de Hindley-Milner
sont données figure 5.4. On note que la spécialisation remplace une variable de type α
par un type τ et non un schéma, sans quoi le schéma obtenu serait mal formé. On note
également que seule la construction let permet d’introduire un type polymorphe dans
l’environnement. En particulier, on peut toujours typer
let f = fun x → x in (f 1, f true)
avec f : ∀α.α → α dans le contexte pour typer (f 1, f true). En revanche, la règle de
typage
Γ + x : τ1 ⊢ e : τ2
Γ ⊢ fun x → e : τ1 → τ2
n’introduit pas un type polymorphe, car sinon τ1 → τ2 serait mal formé. En particulier,
on ne peut plus typer fun x → x x.
5.4. Inférence de types 89
τ ≤ Γ(x) τ ≤ type(op)
etc.
Γ⊢x:τ Γ ⊢ n : int Γ ⊢ op : τ
Γ + x : τ1 ⊢ e : τ2 Γ ⊢ e1 : τ ′ → τ Γ ⊢ e2 : τ ′
Γ ⊢ fun x → e : τ1 → τ2 Γ ⊢ e1 e2 : τ
Γ ⊢ e1 : τ1 Γ ⊢ e2 : τ2 Γ ⊢ e1 : τ1 Γ + x : Gen(τ1 , Γ) ⊢ e2 : τ2
Γ ⊢ (e1 , e2 ) : τ1 × τ2 Γ ⊢ let x = e1 in e2 : τ2
n’admet pas de solution, car un type → ne peut être égal à un type ×, de même que le
problème
?
α → int = α
car les types sont finis. Le pseudo-code d’un algorithme d’unification est donné figure 5.6.
Ce code est volontairement écrit sous une forme impérative, au sens où un appel à
unifier (τ1 , τ2 ) conduit à une résolution globale, par effet de bord, de certaines variables de
types. On parle d’unification destructive. Bien d’autres façons de l’écrire sont possibles,
y compris dans un style purement applicatif. Cet algorithme nous donne l’unificateur le
plus général (en anglais most general unifier ou mgu), au sens où toute spécialisation θ
telle que θ(τ1 ) = θ(τ2 ) est une instance du résultat de unifier (τ1 , τ2 ).
Avant de décrire l’algorithme W, commençons par en illustrer l’idée sur un exemple,
à savoir l’expression fun x → +(fst x, 1). Comme il s’agit d’une fonction, on donne
à x le type α1 , une nouvelle variable de type. Dans l’environnement x : α1 , on cherche
maintenant à typer l’expression +(fst x, 1). La primitive + a le type int × int → int.
Typons son argument, à savoir l’expression (fst x, 1). La primitive fst a pour type le
schéma ∀α.∀β.α × β → α, qu’il faut donc spécialiser. On lui donne donc le type α2 × β1 →
α2 , pour deux nouvelles variables de types α2 et β1 . L’application fst x impose d’unifier
α1 et α2 × β1 , ce qui donne {α1 7→ α2 × β1 }. L’expression (fst x, 1) a donc le type α2 × int.
Ensuite, l’application +(fst x, 1) unifie les int×int et α2 ×int, ce qui donne {α2 7→ int}.
Au final, on obtient le type int × β1 → int, c’est-à-dire
La variable de type restante, β1 , n’est plus une inconnue, mais une vraie variable de type,
qu’on a ici renommée en β. Le pseudo-code de l’algorithme W est donné figure 5.7. On
92 Chapitre 5. Typage statique
def
W (Γ, e) =
— si e est une variable x,
renvoyer une instance triviale de Γ(x)
— si e est une constante c,
renvoyer une instance triviale de son type
— si e est une primitive op,
renvoyer une instance triviale de son type
— si e est une paire (e1 , e2 ),
calculer τ1 = W (Γ, e1 )
calculer τ2 = W (Γ, e2 )
renvoyer τ1 × τ2
— si e est une fonction fun x → e1 ,
soit α une nouvelle variable
calculer τ = W (Γ + x : α, e1 )
renvoyer α → τ
— si e est une application e1 e2 ,
calculer τ1 = W (Γ, e1 )
calculer τ2 = W (Γ, e2 )
soit α une nouvelle variable
unifier (τ1 , τ2 → α)
renvoyer α
— si e est let x = e1 in e2 ,
calculer τ1 = W (Γ, e1 )
renvoyer W (Γ + x : Gen(τ1 , Γ), e2 )
note que le cas d’une constante c implique une instance triviale. On peut en effet avoir
des constantes polymorphes, comme par exemple la liste vide.
si W (∅, e) = τ alors ∅ ⊢ e : τ,
et il détermine le type « le plus général possible », dit aussi type principal , au sens où
Par ailleurs, on peut montrer que le système de Hindley-Milner est sûr, au sens du
théorème 2, c’est-à-dire que si ∅ ⊢ e : τ , alors la réduction de e est infinie ou se termine
sur une valeur.
où τ ref est le type d’une référence contenant une valeur de type τ . Cependant, il s’avère
que c’est incorrect, au sens où la sûreté du typage n’est plus garantie. Un contre-exemple
est le programme suivant :
Il est bien typé dans le système Hindley-Milner. En effet, la première ligne donne à r le type
polymorphe ∀α.(α → α) ref . Ensuite, l’affectation est acceptée, car le type polymorphe
de r peut être spécialisé en (int → int) ref . Enfin, la dernière ligne est également bien
typée, car le type de r peut être spécialisé de nouveau, cette fois avec le type ((int →
int) → (int → int)) ref . Mais ce programme ne s’exécute pas correctement, car il
aboutit à l’addition de la fonction fun y → y et de l’entier 1.
Ce problème, dit des références polymorphes, admet plusieurs solutions. L’une des plus
simples consiste à ne généraliser le type de e1 dans let x = e1 in e2 que lorsque e1 est
syntaxiquement une valeur 1 . Dès lors, on ne peut plus écrire
car ref (fun x → x) n’est pas une valeur, mais on peut écrire en revanche
Exercice 36. Expliquer pourquoi le programme OCaml suivant est mal typé :
let map_id = List.map (fun x -> x)
let l1 = map_id [1; 2; 3]
let l2 = map_id [true; false; true]
Proposer une solution. Solution
Notes bibliographiques. Le type de preuve réalisée dans la section 5.2 a été introduit
dans A Syntactic Approach to Type Soundness [29]. Le livre de Benjamin Pierce Types
and Programming Languages [22] en contient plusieurs exemples. Le système F est dû
indépendamment à J.-Y. Girard et J. C. Reynolds. Le résultat d’indécidabilité du typage
dans le système F a été montré en 1994 par J. B. Wells [28]. L’algorithme W est dû à
Damas et Milner [6]. Le problème des références polymorphes a été détecté en 1990 [26]
et a nécessité des rectifications de versions de SML qui présentaient ce problème. La
solution mise en place dans OCaml est relativement sophistiquée [9], notamment pour
permettre d’indiquer quelles sont les variables de types d’un type abstrait qui peuvent
être généralisées.
Troisième partie
Dans ce chapitre, nous nous intéressons à la façon dont un appel de fonction est réalisé
et notamment à la façon dont les paramètres sont transmis par l’appelant à l’appelé.
Nous illustrons différentes stratégies en la matière, en prenant des exemples parmi des
langages existants tels que C, OCaml, Java ou encore C++. Commençons par un peu de
vocabulaire. Dans la déclaration d’une fonction f 1
function f(x1, ..., xn) =
...
les variables x1, ..., xn sont appelées paramètres formels de f. Dans un appel à cette
fonction
f(e1, ..., en)
les expressions e1, ..., en sont appelées paramètres effectifs de f. Si le langage comprend
des modifications en place, une affectation
e1 := e2
modifie un emplacement mémoire désigné par l’expression e1, pour lui affecter la valeur
de l’expression e2. Le plus souvent, l’expression e1 est limitée à certaines constructions,
car des affectations comme 42 := 17 ou encore true := false n’auraient pas de sens.
On parle de valeur gauche (en anglais left value) pour désigner les expressions légales à
gauche d’une affectation.
non spécifiés. Cela laisse alors de la latitude au compilateur pour effectuer des optimisa-
tions en ordonnançant stratégiquement les calculs.
Parmi les différentes stratégies d’évaluation, on distingue notamment l’évaluation stricte
et l’évaluation paresseuse. Avec une évaluation stricte, les opérandes et paramètres effec-
tifs sont évalués avant l’opération ou l’appel. Des langages comme C, C++, Java, OCaml
ou encore Python ont adopté une évaluation stricte. Avec une évaluation paresseuse, au
contraire, les opérandes et paramètres effectifs ne sont évalués que si nécessaire. En par-
ticulier, ils ne sont pas évalués avant l’appel. Des langages comme Haskell ou Clojure ont
fait ce choix.
Un langage impératif adopte systématiquement une évaluation stricte, pour garantir
une séquentialité des effets de bord qui coïncide avec le texte source. Par exemple, le code
OCaml
let r = ref 0
let id x = r := !r + x; x
let f x y = !r
let () = print_int (f (id 40) (id 2))
affiche 42 car les deux arguments de f ont été évalués, même si la fonction f n’utilise
aucun de ses deux arguments. Il serait difficile de comprendre l’effet de l’expression f (id
40) (id 2) s’il faut commencer par déterminer quels sont ceux de ses arguments que f
utilise.
Dans les langages impératifs, une exception est faite pour les connectives logiques &&
et ||, qui n’évaluent leur seconde opérande que si nécessaire. Plus précisément, la connec-
tive && (resp. ||) n’évalue pas sa seconde opérande lorsque la première est fausse (resp.
vraie), quand bien même cette seconde opérande aurait des effets. On peut ainsi écrire
des morceaux de code tels que
while 0 < !j && v < a.(!j-1)
qui assurent qu’on n’évalue pas a.(!j-1) lorsque !j est nul.
Il est important de comprendre que la non-terminaison est également un effet. Ainsi,
le programme OCaml
let rec loop () = loop ()
let f x y = x + 1
let v = f 41 (loop ())
ne termine pas, bien que l’argument y de f n’est pas utilisé.
Un langage purement applicatif, en revanche, peut adopter la stratégie d’évaluation
de son choix, car une expression aura toujours la même valeur. On parle de transparence
référentielle. En particulier, il peut faire le choix d’une évaluation paresseuse. Ainsi, le
programme Haskell
loop () = loop ()
f x y = x
main = putChar (f ’a’ (loop ()))
termine (après avoir affiché a).
valeur (en anglais call by value), l’appel par référence (en anglais call by reference), l’appel
par nom (en anglais call by name) et l’appel par nécessité (en anglais call by need ). On
parle aussi parfois de passage par valeur, par référence, etc.
Dans l’appel par valeur, de nouvelles variables représentant les paramètres formels
reçoivent les valeurs des paramètres effectifs 2 . Ainsi, un programme comme
function f(x) =
x := x + 1
main() =
int v := 41
f(v)
print(v)
affiche 41, car le paramètre x de f est une nouvelle variable, recevant la valeur 41, et c’est
cette variable qui est incrémentée. La variable v, quant à elle, reste égale à 41, d’où le
résultat.
Dans l’appel par référence, en revanche, les paramètres formels désignent les mêmes
valeurs gauches que les paramètres effectifs. Ainsi, le même programme ci-dessus affiche
42 en appel par référence, car x désigne la même variable que v, qui se retrouve donc
incrémentée.
Dans l’appel par nom, les paramètres effectifs sont substitués aux paramètres formels,
textuellement, et donc évalués seulement si nécessaire. Ainsi, le programme
function f(x, y, z) =
return x*x + y*y
main() =
print(f(1+2, 2+2, 1/0))
affiche 25, en évaluant deux fois l’expression 1+2 et deux fois l’expression 2+2. En revanche,
l’expression 1/0 n’est jamais évaluée, ce qui explique que le programme n’échoue pas.
Enfin, dans l’appel par nécessité, les paramètres effectifs ne sont évalués que si néces-
saire, mais au plus une fois. Ainsi, le même programme ci-dessus affiche toujours 25, mais
en évaluant une seule fois l’expression 1+2 et une seule fois l’expression 2+2.
Java. Java est muni d’une stratégie d’évaluation stricte, avec appel par valeur. L’ordre
d’évaluation est spécifié comme étant de la gauche vers la droite, c’est-à-dire dans l’ordre
2. Nous avons déjà évoqué l’appel par valeur dans le chapitre 2.
100 Chapitre 6. Passage des paramètres
où sont écrits les paramètres 3 . Une valeur est soit d’un type primitif (booléen, caractère,
entier machine, etc.), soit un pointeur vers un objet alloué sur le tas. Si on passe un entier
à une fonction qui incrémente son argument, cet incrémentation ne sera pas observée par
l’appelant. On le comprend en matérialisant l’appel par valeur sur une illustration comme
celle-ci :
au début à la fin
void f(int x) {
de l’appel de l’appel
x += 1;
} . .
.
. .
.
int main() {
v 41 v 41
int v = 41; . .
f(v); .
. .
.
// v vaut toujours 41 x 41 x 42
.
. .
.
} . .
Ici, on a matérialisé les variables v et x comme allouées sur la pile, dans les tableaux
d’activations respectifs des fonctions main et f 4 . Ces variables pourraient tout aussi bien
être allouées dans des registres. Cela ne changerait en rien cet exemple.
Lorsqu’un objet est passé en argument à une fonction, c’est toujours un appel par
valeur, mais d’une valeur qui est maintenant un pointeur, même si celui-ci n’est pas
explicite en Java. En voici une illustration :
class C { int f; }
au début à la fin
void incr(C x) { de l’appel de l’appel
x.f += 1;
} .
. .
.
. .
void main () { r r
C r = new C(); .
. 41 .
. 42
. .
r.f = 41; x x
incr(r); .
. .
.
. .
// r.f vaut maintenant 42
}
Cette fois, l’incrémentation est bien observée par l’appelant, car même si une nouvelle
variable x a été créée, elle contenait seulement un pointeur vers le même objet que celui
désigné par r et c’est dans cet objet que la modification a été effectuée. En Java, un tableau
est un objet (presque) comme un autre, ce qui amène à une situation comparable :
3. Il est amusant de lire à ce propos dans The Java Language Specification la phrase « It is recom-
mended that code not rely crucially on this specification ».
4. La pile croissant vers le bas, la variable x apparaît plus bas que la variable v.
6.2. Comparaison des langages Java, OCaml, C et C++ 101
On peut simuler l’appel par nom en Java, en remplaçant les arguments par des fonc-
tions sans argument 5 . Ainsi, la fonction
Ce code n’échouera pas, car on ne cherche pas à appliquer la fonction y dans le cas où
x.get() renvoie zéro. On a bien simulé un appel par nom.
Plus subtilement, on peut aussi simuler l’appel par nécessité en Java. On peut le faire
avec un objet qui mémorise la valeur donnée par la fonction la première fois qu’elle est
appelée.
5. Une fonction sans argument n’est ici qu’un objet de type Supplier, avec une méthode get pour
l’appliquer.
102 Chapitre 6. Passage des paramètres
Lazy(Supplier<T> f) { this.f = f; }
public T get() {
if (this.cache == null) {
this.cache = this.f.get();
this.f = null; // permet au GC de récupérer f
}
return this.cache;
}
}
Ce n’est rien d’autre que de la mémoïsation, sur une fonction n’ayant qu’une seule valeur.
On utilise alors ainsi cette classe Lazy :
int w = f(new Lazy<Integer>(() -> 1),
new Lazy<Integer>(() -> { ...gros calcul... }));
OCaml. OCaml est muni d’une stratégie d’évaluation stricte, avec appel par valeur.
L’ordre d’évaluation n’est pas spécifié. Une valeur est soit d’un type primitif (booléen,
caractère, entier machine, etc.), soit un pointeur vers un bloc mémoire (tableau, enregis-
trement, constructeur non constant, etc.) alloué sur le tas en général 6 . Les valeurs gauches
sont les éléments de tableaux et les champs d’enregistrements déclarés comme mutables.
Une variable mutable, appelée une référence en OCaml, n’est qu’une valeur d’un type
enregistrement ref prédéfini comme
type ’a ref = { mutable contents: ’a }
sur lequel sont définies des opérations d’accès ! et d’affectation :=, de la manière suivante :
let (!) r = r.contents
let (:=) r v = r.contents <- v
Lorsqu’on passe une référence en argument à une fonction, c’est une valeur qui est un
pointeur qui est passée par valeur. En voici une illustration :
au début à la fin
let incr x = de l’appel de l’appel
x := !x + 1 . .
.
. .
.
r r
let main () = . .
let r = ref 41 in .
. 41 .
. 42
incr r x x
.
. .
.
(* !r vaut maintenant 42 *) . .
6. Il est frappant de noter à quel point les modèles d’exécution d’OCaml et Java sont proches, même
si leurs langages de surface sont très différents.
6.2. Comparaison des langages Java, OCaml, C et C++ 103
Cette situation est tout à fait analogue à celle vue plus haut du passage d’un objet en
Java. Il en va de même pour le passage d’un tableau en OCaml, la valeur d’un tableau
étant un pointeur vers un bloc mémoire contenant les éléments du tableau.
Comme on l’a fait en Java, on peut simuler l’appel par nom en OCaml, en remplaçant
les arguments par des fonctions. Ainsi, la fonction
let f x y =
if x = 0 then 42 else y + y
peut être réécrite en
let f x y =
if x () = 0 then 42 else y () + y ()
et appelée comme ceci
let v = f (fun () -> 0) (fun () -> failwith "oups")
On peut aussi simuler l’appel par nécessité en OCaml, en commençant par introduire un
type pour représenter les calculs paresseux
type ’a value = Value of ’a
| Frozen of (unit -> ’a)
C. Le langage C est un langage impératif relativement bas niveau, notamment parce que
la notion de pointeur, et d’arithmétique de pointeur, y est explicite. On peut le considérer
inversement comme un assembleur de haut niveau. C’est là une excellente définition du
langage C. Le langage C est muni d’une stratégie d’évaluation stricte, avec appel par
valeur. L’ordre d’évaluation n’est pas spécifié.
Parmi les types du C, on trouve des types de base tels que char, int, float, etc. Il n’y
a pas de type de booléens : toute valeur scalaire peut être utilisée comme un booléen et
elle est vraie si et seulement si elle est non nulle. On trouve aussi un type τ * des pointeurs
vers des valeurs de type τ . Si p est un pointeur de type τ *, alors *p désigne la valeur
104 Chapitre 6. Passage des paramètres
pointée par p, de type τ . Inversement, si e est une valeur gauche de type τ , alors &e est
un pointeur sur l’emplacement mémoire correspondant, de type τ *. Enfin, on trouve des
enregistrements, appelés structures, tels que
struct L { int head; struct L *next; };
Si e a le type struct L, on note e.head l’accès au champ.
En C, une valeur gauche est de trois formes possibles : une variable x, le déréféren-
cement d’un pointeur *e ou l’accès à un champ de structure e.x si e est elle-même une
valeur gauche. Par ailleurs, t[e] est du sucre syntaxique pour *(t+e) et e->x du sucre
syntaxique pour (*e).x. Ce sont donc, syntaxiquement du moins, deux autres formes de
valeurs gauches.
Le passage d’un entier par valeur ne diffère pas de celui en Java :
Le passage d’une structure, en revanche, nous donne une situation inédite jusqu’à présent,
car une structure C est une valeur et est donc copiée lors d’un appel. En voici une
illustration :
Les structures sont également copiées lorsqu’elles sont renvoyées avec return et lors d’af-
fectations de structures, c’est-à-dire d’affectations de la forme x = y, où x et y ont le type
struct S. Pour éviter le coût de telles copies, on manipule le plus souvent des pointeurs
sur des structures, comme ceci :
6.2. Comparaison des langages Java, OCaml, C et C++ 105
C’est une façon de simuler un passage par référence, mais cela reste un passage par valeur
d’une valeur qui est un pointeur. On peut faire de même avec un entier :
Une telle manipulation explicite de pointeurs peut être dangereuse. Considérons par
exemple le programme
int* p() {
int x;
...
return &x;
}
Il renvoie un pointeur obtenu en prenant l’adresse de la variable locale x, c’est-à-dire un
pointeur qui correspond à un emplacement sur la pile qui vient justement de disparaître
(à savoir le tableau d’activation de p) et qui sera très probablement réutilisé rapidement
par un autre tableau d’activation. On parle de référence fantôme (en anglais dangling
reference).
En C, les tableaux ne constituent pas vraiment un type en soi. Si on déclare un tableau
de dix entiers, avec
int t[10];
la notation t[i] n’est que du sucre syntaxique pour *(t+i) où t désigne un pointeur
sur le début d’une zone contenant 10 entiers et + désigne une opération d’arithmétique de
pointeur (qui consiste à ajouter à t la quantité 4i pour un tableau d’entiers 32 bits). Le
106 Chapitre 6. Passage des paramètres
premier élément du tableau est donc t[0] c’est-à-dire *t. Quand on passe un tableau en
paramètre, on ne fait que passer le pointeur, toujours par valeur. On ne peut affecter des
tableaux, seulement des pointeurs. Ainsi, on ne peut pas écrire
t[2]
void p() {
t[1]
int t[3];
t → t[0]
int u[3];
u[2]
u = t; // refusé
u[1]
}
u → u[0]
car t et u sont des tableaux alloués sur la pile et l’affectation de tableaux n’est pas
autorisée.
En revanche on peut écrire avant u=t après u=t
void q(int t[3], int u[3]) { t[2] t[2]
t[1] t[1]
u = t;
t[0] t[0]
} . .
.
. .
.
car c’est exactement la même chose que u[2] u[2]
u[1] u[1]
void q(int *t, int *u) { u[0] u[0]
u = t; .
. .
.
. .
} t t
u u
et l’affectation de pointeurs est autorisée. . .
.
. .
.
C++. En C++, on trouve (entre autres) les types et constructions du C, avec une
stratégie d’évaluation stricte. Le mode de passage est par valeur par défaut. Mais on
trouve aussi un passage par référence indiqué par le symbole & au niveau de l’argument
formel. Ainsi, on peut écrire
On peut aussi passer un pointeur par référence. Un exemple pertinent est celui de l’inser-
tion d’un élément dans un arbre.
struct Node { int elt; Node *left, *right; };
Résumé. Résumons les différentes situations que nous avons rencontrées avec ces quatre
langages au regard du passage d’un entier à une fonction :
.
. .
. .
.
. . .
v 41 v 41 r
.
. .
. .
. 41
. . .
x 41 x x
.
. .
. .
.
. . .
C entier par valeur pointeur par valeur pointeur par valeur
OCaml entier par valeur — pointeur par valeur
(par ex. type ref)
Java entier par valeur — pointeur par valeur
(objet)
C++ entier par valeur pointeur par valeur pointeur par valeur
entier par référence ou par référence
program fib;
var f : integer;
La stratégie d’évaluation est stricte, sauf pour les constructions and et or. Le mode
de passage est par valeur pour un paramètre déclaré avec la syntaxe x : integer et
par référence pour un paramètre déclaré avec la syntaxe var x : integer. On suppose
l’existence d’une procédure prédéfinie writeln pour afficher un entier. Les figures 6.2
et 6.3 contiennent deux programmes mini Pascal, qui calculent respectivement la suite de
Fibonacci et la suite de Syracuse.
Les procédures pouvant être imbriquées, il convient de définir soigneusement la portée
des variables et des procédures. On dit qu’une procédure p est le parent d’un identificateur
y si y est déclaré dans p, soit comme un paramètre de p, soit comme une variable ou une
procédure locale de p. On dit que p est un ancêtre de y si p est soit y soit le parent d’un
ancêtre de y. On dit que la déclaration d précède la déclaration d′ si elles ont le même
parent (ou sont toutes les deux globales) et que d vient avant d′ dans le programme. Par
ailleurs, on définit le niveau d’une déclaration ou d’un bloc de code comme le nombre de
procédures sous lesquelles elle est déclarée. En particulier, les déclarations globales et le
programme principal ont le niveau 0.
On peut alors définir la portée de la manière suivante : si le corps d’une procédure p
mentionne un identificateur alors celui-ci est soit une déclaration locale de p, soit un
ancêtre de p (y compris p lui-même), soit une déclaration précédant un ancêtre de p. En
particulier, une procédure peut être récursive mais il n’y a pas de procédures mutuellement
récursives.
Venons-en maintenant à la compilation de mini Pascal. On adopte ici un schéma de
compilation simple où toutes les variables sont sur la pile. Les paramètres sont situés en
haut du tableau d’activation, placés là par l’appelant, et les variables locales plus bas,
dans la partie allouée par l’appelé. La difficulté ici repose sur le fait qu’une variable x
qui est utilisée n’est pas nécessairement située dans le tableau d’activation qui se trouve
en sommet de pile. En effet, les règles de portée font que cette variable peut avoir été
déclarée dans la procédure parent, ou plus généralement dans un ancêtre quelconque de
la procédure en cours d’exécution. Il faut donc savoir retrouver le tableau d’activation où
se trouve cette variable. Mais avant d’expliquer comment, persuadons-nous qu’il existe
110 Chapitre 6. Passage des paramètres
program syracuse;
bien. On dit qu’une procédure est active si on n’a pas encore fini d’exécuter le corps de
cette procédure. Un résultat essentiel à la compilation de mini Pascal est le suivant :
Proposition 5. Lorsqu’une procédure p est active, alors tous les ancêtres de p sont des
procédures actives.
Preuve. La preuve se fait par récurrence sur la profondeur de p dans l’arbre d’activa-
tion. C’est vrai pour le programme principal, qui n’a que lui-même comme ancêtre. Si p
a été activée par une procédure q alors q est toujours active (par définition) et tous les
ancêtres de q sont actifs par hypothèse de récurrence ; or les règles de visibilité impliquent
que soit q est le parent de p, soit p précède un ancêtre de q, et dans les deux cas tous les
ancêtres de p sont bien actifs. □
Ce résultat implique donc que toute variable utilisée se
situe dans un tableau d’activation qui se trouve quelque part
sur la pile. Pour être en mesure de le retrouver, on va pla- e1
cer dans chaque tableau d’activation un pointeur vers la ta- ..
.
bleau d’activation de la procédure parent. Ainsi, il suffira de en
suivre ces pointeurs, un nombre de fois égal à la différence %rbp parent
de niveaux, pour retrouver le tableau d’activation d’une va- adr. retour
riable donnée. Le tableau d’activation correspondant à un %rbp → %rbp appelant
appel p(e1 , . . . , en ) est illustré ci-contre. La partie supérieure v1
est construite par l’appelant. Elle contient les valeurs des ..
.
arguments e1 , . . . , en , ainsi que l’adresse vers le tableau d’ac- vm
tivation de la procédure parent. Vient ensuite l’adresse de ..
.
6.3. Compilation d’un mini Pascal 111
fib(3)
somme()
fib(1) fib(2)
somme()
fib(0) fib(1)
À chaque instant, les tableaux d’activation sur la pile correspondent à un chemin depuis
la racine dans l’arbre d’activation. Si on prend le chemin fib(3)—somme()—fib(2) ici
colorié en rouge, alors on a quatre tableaux d’activation sur la pile, organisés de la manière
suivante :
ret
main f=1
n=3
ret
fib(3)
ret
somme tmp=1
n=2
ret
fib(2)
.
.
.
Les différents tableaux sont séparés par une ligne rouge. Le tableau le plus haut, tout
au fond de la pile, correspond au programme principal qui a appelé fib(3). Comme
il n’a ni parent ni appelant, on a deux cases non significatives dans son tableau, ici
représentées comme vides. La variable globale f est représentée comme une variable locale
du programme principal et donc matérialisée dans ce premier tableau. Vient ensuite le
tableau de fib(3), avec l’argument n et deux pointeurs vers l’appelant et le parent qui
coïncident ici. De même, le troisième tableau contient deux pointeurs égaux, ainsi qu’une
variable locale tmp. Le quatrième tableau, celui de l’appel à fib(2), est plus intéressant
car le tableau de la procédure parent (main) ne coïncide plus cette fois avec celui de la
procédure appelante (somme). En particulier, si le code de fib a besoin d’accéder à la
112 Chapitre 6. Passage des paramètres
Le cas d’une variable x est plus intéressant. On trouve le tableau contenant x en suivant
les pointeurs vers le tableau parent un nombre de fois égal à l − xl . On écrit donc
Il en va de même pour les autres opérations arithmétiques. Bien entendu, c’est extrême-
ment naïf. La compilation d’une expression comme 1+2 requiert cinq instructions et utilise
la pile, alors même qu’on dispose de seize registres. On peut imaginer de nombreuses fa-
çons de compiler plus efficacement, même sans chercher à faire de l’allocation de registres,
mais ce n’est pas le propos ici 7 . On compile de même les expressions booléennes, par
exemple vers les entiers 0 pour faux et 1 pour vrai. La seule subtilité se trouve dans le
caractère paresseux des opérateurs and et or, qui ne doivent pas évaluer leur seconde
opérande dans certains cas. Par exemple, on compile ainsi l’opérateur and :
Ici, L désigne une étiquette fraîche. Venons-en à la compilation des instructions. Certaines
ne posent pas de difficulté particulière, telles que la conditionnelle, la boucle while ou
encore un bloc d’instructions. La compilation d’une affectation réutilise le calcul de la
position de la variable que nous avons fait plus haut :
Compl (x := E) = Compl (E)
movq %rbp, %rsi
répéter l − xl fois movq 16(%rsi), %rsi
movq %rdi, xo (%rsi)
La dernière instruction à compiler est le cas d’un appel de procédure. On commence
par empiler les valeurs des n arguments, puis le pointeur vers le tableau de la procédure
parent, toujours calculé de la même façon, avant de faire call. On termine en dépilant la
place occupée par les n arguments et le pointeur, soit 8(n + 1) octets.
Compl (p(E1 , . . . , En )) = Compl (E1 ) pushq %rdi
..
.
Compl (En ) pushq %rdi
movq %rbp, %rsi
répéter l − pl fois movq 16(%rsi), %rsi
pushq %rsi
call p
addq $8(n + 1), %rsp
Reste enfin à expliquer comment compiler les déclarations de toutes les procédures. On
compile chaque procédure indépendamment et de la même façon, qu’elle soit déclarée
globablement ou localement à une autre procédure. Une procédure p de niveau l est
compilée ainsi :
Comp(p(x1 , . . . , xn ) . . . B) = p:
pushq %rbp
movq %rsp, %rbp
subq $8m, %rsp
Compl+1 (B)
movq %rbp, %rsp
popq %rbp
ret
Ici, l’entier m désigne le nombre de variables locales à la procédure p. On note que le
corps B de la procédure p est une instruction compilée au niveau l + 1. Le programme
principal peut être considéré comme une procédure de niveau −1, les variables globales
étant alors autant de variables locales de cette procédure (comme illustré plus haut avec
le programme fib).
Passage par référence. Pour l’instant, on a supposé tous les paramètres passés par
valeur. Mais le qualificatif var au niveau d’un paramètre formel permet de spécifier un
passage par référence et il faut alors modifier la compilation en conséquence. Dans le cas
d’un passage par référence, le paramètre effectif doit être une valeur gauche. Dans mini
Pascal, cela se limite donc à une variable 8 . Lors d’un appel de procédure p(e1 , . . . , en ),
8. Dans un langage plus complet, cela pourrait être un élément de tableau, un champ de structure,
etc.
114 Chapitre 6. Passage des paramètres
si le paramètre i est passé par référence, il faut donc vérifier d’une part que ei est bien
une variable et compiler cet argument différemment lors de l’appel, pour passer l’adresse
de cette variable et non plus sa valeur. Une façon élégante de procéder consiste à ajouter
une construction de « calcul de valeur gauche » dans la syntaxe des expressions. Notons-la
&x par analogie avec le langage C. On suppose que le typage a introduit cette nouvelle
construction dans les appels de procédures, au niveau de chaque paramètre passé par
référence. Commençons par expliquer comment compiler cette nouvelle construction :
La dernière ligne tient compte du cas d’une variable x elle-même passée par référence,
qu’on est donc en train de repasser par référence à une autre procédure. Il faut aussi
modifier la compilation de l’accès à une variable pour traiter le cas d’une variable passée
par référence :
On constate qu’on a seulement ajouté la dernière ligne. Enfin, il faut modifier l’affectation,
toujours pour traiter le cas d’une variable passée par référence :
En revanche, il n’y a rien à modifier dans l’appel de procédure, grâce à la nouvelle construc-
tion &. De même qu’il n’y a rien à modifier dans la compilation de la déclaration d’une
procédure.
e ::= c
| x
| fun x → e
| ee
| let [rec] x = e in e
| if e then e else e
p ::= d . . . d
fonctions locales peuvent faire référence à des variables introduites précédemment, dans la
portée desquelles elles se trouvent. Ainsi, la fonction boucle fait référence à n, l’argument
de la fonction somme, et à la fonction f introduite juste avant.
Il est également possible de prendre des fonctions en argument, comme dans cet
exemple
let carré f x =
f (f x)
et d’en renvoyer, comme dans cet autre exemple
let f x =
if x < 0 then fun y -> y - x else fun y -> y + x
Dans ce dernier cas, la valeur renvoyée par f est une fonction qui utilise x mais le tableau
d’activation de f vient précisément de disparaître. Il va donc falloir compiler une telle
fonction de manière à ce que la valeur de x survive à cet appel.
La solution consiste à utiliser une fermeture (en anglais closure). Il s’agit d’une struc-
ture de données allouée sur le tas (pour survivre aux appels de fonctions) contenant d’une
part un pointeur vers le code de la fonction à appeler et d’autre part les valeurs des
variables susceptibles d’être utilisées par ce code. Cette seconde partie de la fermeture
s’appelle l’environnement. Pour une fermeture représentant fun x → e, les variables dont
il faut enregistrer la valeur dans l’environnement sont exactement les variables libres de
fun x → e (voir définition 1 page 21).
Considérons l’exemple de cette fonction récursive pow qui calcule xi pour un flottant x
et un entier i.
let rec pow = fun i -> fun x ->
if i = 0 then 1. else x *. pow (i-1) x
code code
pow i 100
pow
sum code
eps 0.001
f
sum
Les deux fonctions integrate_xn et pow sont des fermetures. Une fois integrate_xn
appelée, l’application de pow à 100 renvoie une fonction, et donc une fermeture, stockée
dans la variable f. Enfin, la fonction sum est une quatrième fermeture. On note en par-
ticulier que les fermetures des fonctions pow et sum contiennent leur propre valeur dans
leur environnement, parce qu’il s’agit de fonctions récursives. Ainsi, l’exécution du code
d’une fermeture retrouve dans l’environnement la valeur de toute variable, sans faire de
cas particulier pour une fonction récursive. C’est pendant la construction de la fermeture
qu’il faut prendre soin de la récursivité.
Expliquons maintenant comment mécaniser la construction des fermetures et comment
les utiliser. Une façon relativement simple de compiler les fermetures consiste à procéder en
deux temps. On commence par rechercher dans le code toutes les constructions fun x → e
et on les remplace par une opération explicite de construction de fermeture
clos f [y1 , . . . , yn ]
où les yi sont les variables libres de fun x → e et f le nom donné à une déclaration globale
de fonction de la forme
letfun f [y1 , . . . , yn ] x = e′
3. D’autres solutions sont possibles, comme par exemple un environnement alloué dans un second
bloc ou encore sous forme de liste chaînée. Mais la solution utilisant un unique bloc est plus efficace en
pratique, car sollicitant moins le GC.
118 Chapitre 7. Compilation des langages fonctionnels
e ::= c
| x
| clos f [x, . . . , x]
| ee
| let [rec] x = e in e
| if e then e else e
p ::= d . . . d
On note qu’on a toujours les deux déclarations globales de pow et integrate_xn, dont les
valeurs sont des fermetures. Les quatre fonctions fun1, fun2, fun3 et fun4 introduites par
cette traduction peuvent être placées arbitrairement dans le code, car elles sont indépen-
dantes les unes des autres. Elles ne sont pas non plus récursives. En effet, une fermeture
dispose dans son environnement de toute valeur dont elle a besoin. Ainsi, la fonction fun2
trouve dans son environnement la valeur de pow pour procéder à un appel récursif. Le
langage cible de cette transformation est donné figure 7.2. On note que chaque fonction
introduite par letfun a exactement un argument.
Dans un second temps, on compile le code obtenu. Adoptons le schéma de compilation
où chaque fonction introduite par letfun reçoit son unique argument dans %rdi et sa
fermeture dans %rsi. Il n’y a pas d’autre argument et donc en particulier pas d’argu-
ment passé par la pile. Celle-ci est utilisée en revanche pour allouer les variables locales
introduites par let. On a donc un tableau d’activation de la forme
7.1. Fonctions comme valeurs de première classe 119
adresse retour
%rbp → %rbp sauvegardé
v1
..
.
vm
..
.
↓
où v1 , . . . , vm sont les variables locales. Détaillons la compilation des diverses construc-
tions. Pour compiler la construction
clos f [y1 , . . . , yn ]
on procède ainsi. On alloue un bloc de taille n + 1 sur le tas, avec une fonction de type
malloc. On stocke l’adresse de f dans le premier champ. Cette adresse est connue, comme
celle de la fonction f dans le programme compilé. On stocke les valeurs des variables
y1 , . . . , yn dans les champs 1 à n du bloc. On expliquera un peu plus loin comment se
fait l’accès à la valeur d’une variable. On renvoie l’adresse du bloc comme valeur de
l’expression. On se repose sur un GC pour libérer ce bloc lorsque ce sera possible. Il est en
effet difficile, et en toute généralité impossible, de décider à quel moment une fermeture
peut être libérée.
Expliquons maintenant comment compiler un appel, c’est-à-dire une expression de la
forme e1 e2 . On compile e1 dans le registre %rsi. Puisqu’il s’agit d’une fonction (ce que le
typage statique garantit), on sait que sa valeur est l’adresse d’une fermeture. On compile
e2 dans le registre %rdi. Enfin, on appelle la fonction dont l’adresse est contenue dans le
premier champ de la fermeture, avec call *(%rsi). Il s’agit donc d’un saut à une adresse
calculée.
Expliquons maintenant comment accéder à la valeur d’une variable x. C’est plus subtil
qu’il n’y paraît, car on doit distinguer quatre cas. S’il s’agit d’une variable globale, intro-
duite par un let au niveau global, sa valeur se trouve par exemple dans le segment de
donnée. S’il s’agit d’une variable locale, introduite par un let-in, sa valeur se trouve sur
la pile, et on y accède par n(%rbp) pour un certain n. S’il s’agit d’une variable contenue
dans la fermeture, on y accède par n(%rsi) pour un certain n. Enfin, s’il s’agit de l’argu-
ment d’une fonction letfun, sa valeur se trouve dans %rdi. Cette distinction de cas peut
être déjà faite dans l’arbre de syntaxe abstraite à l’issue de l’explicitation des fermetures.
Enfin, on compile une déclaration de fonction letfun f [y1 , . . . , yn ] x = e de manière
usuelle. On alloue le tableau d’activation, dans lequel on sauvegarde %rbp avant de le
positionner. On évalue l’expression e dans %rax. Puis on restaure %rbp et on désalloue le
tableau d’activation, avant de faire ret.
La compilation des autres constructions, à savoir une constante, une déclaration locale
et une conditionnelle, est tout à fait classique et indépendante du reste.
Une autre optimisation est possible lorsque l’on est sûr qu’une fermeture ne survivra
pas à la fonction dans laquelle elle est créée. Elle peut être alors allouée sur la pile plutôt
que sur le tas. C’est le cas par exemple de la fermeture pour f dans
let integrate_xn n =
let f = ... in
Mais pour s’assurer que cette optimisation est possible, il faut effectuer une analyse sta-
tique non triviale, dite d’échappement (en anglais escape analysis).
Fermetures dans d’autres langages. On trouve aujourd’hui des fermetures dans des
langages comme Java (depuis 2014 et Java 8) ou C++ (depuis 2011 et C++11). Dans
ces langages, les fonctions anonymes sont appelées des lambdas, en écho à la notion de
λ-abstraction. En Java, une fonction est un objet comme un autre, avec une méthode
apply. On peut écrire par exemple
LinkedList<B> map(LinkedList<A> l, Function<A, B> f) {
... f.apply(x) ...
}
où Function est une interface prédéfinie pour un tel objet. Une fonction anonyme est
introduite avec ->
map(l, x -> { System.out.print(x); return x+y; })
Le compilateur construit un objet fermeture, qui capture ici y, avec une méthode apply.
En C++, une fonction anonyme est introduite avec [] :
for_each(v.begin(), v.end(), [y](int &x){ x += y; });
On spécifie les variables capturées dans la fermeture, ici y. Par défaut, les variables sont
capturées par valeur, mais on peut spécifier une capture par référence (ici de s) :
for_each(v.begin(), v.end(), [y,&s](int x){ s += y*x; });
Si un langage ne propose pas de fermetures, on peut les construire manuellement
une fois qu’on en a compris le principe. Ainsi, avant Java 8, il était tout à fait possible
de construire ponctuellement une classe représentant une fermeture, avec les valeurs de
l’environnement dans des champs et une méthode de type apply pour le code de la
fonction. La construction d’un tel objet est moins agréable qu’en utilisant la syntaxe
fournie par Java 8 mais le résultat est identique. De même, le langage C ne propose pas
de fermetures mais il est tout à fait possible d’introduire au cas par cas une structure
contenant un pointeur de fonction et un environnement, accompagnée d’une fonction
apply.
par le compilateur. Aujourd’hui, une telle optimisation est faite dans une majorité de
compilateurs, indépendamment de la nature du langage considéré.
Définition 22. On dit qu’un appel à une fonction f qui apparaît dans le corps d’une
fonction g est terminal (en anglais tail call ) si c’est la dernière chose que g calcule avant
de renvoyer son résultat.
Par extension, on dit qu’une fonction est récursive terminale (en anglais tail recursive
function) s’il s’agit d’une fonction récursive dont tous les appels récursifs sont des appels
terminaux. □
Voici un exemple d’appel terminal à une fonction f dans une fonction g :
let g x =
let y = x * x in f y
L’appel f y est bien la dernière chose que g calcule. Une fois le résultat de f y obtenu,
il est directement transmis comme le résultat de g. Dans une fonction récursive, on peut
avoir des appels récursifs terminaux et d’autres qui ne le sont pas, comme dans la très
célèbre fonction 91 de McCarthy :
let rec f91 n =
if n <= 100 then f91 (f91 (n + 11)) else n - 10
L’intérêt d’un appel terminal du point de vue de la compilation est que l’on peut
détruire le tableau d’activation de la fonction g où se trouve l’appel avant de faire l’appel
à f , puisqu’il ne servira plus ensuite. Mieux encore, on peut le réutiliser pour l’appel
terminal que l’on doit faire. En particulier, l’adresse de retour qui s’y trouve est la bonne.
Dit autrement, on peut faire un saut avec jump plutôt qu’un appel avec call. Considérons
par exemple le programme suivant qui calcule la factorielle de n multipliée par acc :
let rec fact acc n =
if n <= 1 then acc else fact (acc * n) (n - 1)
Une compilation classique donne le programme assembleur de gauche alors qu’en optimi-
sant l’appel terminal, on obtient le programme de droite :
fact: fact:
cmpq $1, %rsi cmpq $1, %rsi
jle L0 jle L0
imulq %rsi, %rdi imulq %rsi, %rdi
decq %rsi decq %rsi
call fact jmp fact
ret
L0: movq %rdi, %rax L0: movq %rdi, %rax
ret ret
Le résultat est une boucle. Le code est en effet identique à ce qu’aurait donné la compilation
d’un programme C tel que
while (n > 1) {
acc = acc * n;
n = n - 1;
}
122 Chapitre 7. Compilation des langages fonctionnels
et ce, bien qu’on n’ait pas nécessairement de traits impératifs dans le langage considéré.
On pourrait tout à fait être en train de compiler un langage purement applicatif.
Le programme obtenu avec cette optimisation est plus efficace. D’une part, on accède
moins à la mémoire, car on n’utilise plus call et ret qui manipulent la pile. Mais surtout,
l’espace de pile utilisé devient constant. En particulier, on évite ainsi tout débordement de
pile (en anglais stack overflow ) qui serait dû à un trop grand nombre d’appels imbriqués.
Il est important de noter que la notion d’appel terminal n’a rien à voir avec les langages
fonctionnels. Sa compilation peut être optimisée dans tous les langages. Ainsi, gcc fait
cette optimisation si on lui passe l’option de ligne de commande -foptimize-sibling-
calls (incluse dans l’option -O2). Il est également important de retenir que la notion
d’appel terminal n’est pas liée à la récursivité, même si c’est le plus souvent une fonction
récursive qui fera déborder la pile et donc pour laquelle on souhaite une telle optimisation.
Application. Supposons que l’on cherche à écrire en OCaml une fonction qui calcule
la hauteur d’un arbre, pour un type d’arbres binaires défini de cette façon :
type ’a tree = Empty | Node of ’a tree * ’a * ’a tree
Il est naturel d’écrire un code de la forme
let rec height = function
| Empty -> 0
| Node (l, _, r) -> 1 + max (height l) (height r)
mais celui-ci va provoquer un débordement de pile sur un arbre de grande hauteur. En
effet, les deux appels récursifs à height ne sont pas terminaux, car il faut encore calculer
le maximum et ajouter un une fois ces deux appels terminés.
Pour éviter le débordement de pile, cherchons à écrire la fonction height en utilisant
uniquement des appels terminaux. Au lieu de calculer la hauteur h de l’arbre, calculons
k(h) pour une fonction k quelconque, appelée continuation. La fonction height aura donc
le type suivant :
val height: ’a tree -> (int -> ’b) -> ’b
On appelle cela la programmation par continuations (en anglais continuation-passing style,
abrégé en CPS). Le programme voulu s’en déduira avec la continuation identité, c’est-à-
dire height t (fun h -> h). Le code prend alors la forme suivante
let rec height t k = match t with
| Empty ->
k 0
| Node (l, _, r) ->
height l (fun hl ->
height r (fun hr ->
k (1 + max hl hr)))
On constate que tous les deux appels à height et les deux appels à k sont terminaux.
Le calcul de height se fait donc en espace de pile constant. On a remplacé l’espace sur
la pile par de l’espace sur le tas. Il est occupé par les fermetures. La première fermeture
capture r et k, la seconde hl et k.
7.3. Filtrage 123
Bien sûr, il y a d’autres solutions, ad hoc, pour calculer la hauteur d’un arbre sans faire
déborder la pile, par exemple un parcours en largeur. De même qu’il y a d’autres solutions
si le type d’arbres est plus complexe : arbres mutables, hauteur stockée dans le nœud,
pointeurs parents, etc. Mais la solution à base de CPS a le mérite d’être mécanique. On
trouvera plus de détails sur cet exemple dans l’article Mesurer la hauteur d’un arbre [8].
Exercice 38. Que faire si le langage optimise l’appel terminal mais ne propose pas de
fonctions anonymes (par exemple, le langage C) ? Solution
7.3 Filtrage
Dans les langages fonctionnels, on trouve généralement une construction appelée fil-
trage (en anglais pattern matching), utilisée dans les définitions de fonctions, comme
function p1 → e1 | . . . | pn → en ,
match e with p1 → e1 | . . . | pn → en ,
try e with p1 → e1 | . . . | pn → en .
match x with p1 → e1 | . . . | pn → en
à laquelle il est aisé de se ramener avec un let. Commençons par définir ce que représentent
les pi ci-dessus.
Définition 23. Un motif (en anglais pattern) est défini par la syntaxe abstraite
p ::= x | C(p, . . . , p)
Définition 24 (motif linéaire). On dit qu’un motif p est linéaire si toute variable apparaît
au plus une fois dans p. □
124 Chapitre 7. Compilation des langages fonctionnels
Ainsi, le motif (x, y) est linéaire, mais (x, x) ne l’est pas. Dans ce qui suit, on ne
considère que des motifs linéaires 4 . Les valeurs filtrées sont construites à partir du même
ensemble de constantes et de constructeurs que dans la définition des motifs, c’est-à-dire
v ::= C(v, . . . , v)
On commence par définir la notion de filtrage d’une valeur par un unique motif.
Définition 25 (filtrage). On dit qu’une valeur v filtre un motif p s’il existe une substi-
tution σ de variables par des valeurs telle que v = σ(p). □
On peut supposer de plus que le domaine de σ, c’est-à-dire l’ensemble des variables x
telles que σ(x) ̸= x, est inclus dans l’ensemble des variables de p. Il est clair que toute
valeur filtre p = x. D’autre part, on a le résultat suivant.
Proposition 6. Une valeur v filtre p = C(p1 , . . . , pn ) si et seulement si v est de la forme
v = C(v1 , . . . , vn ) avec vi qui filtre pi pour tout i = 1, . . . , n.
Preuve. Soit v qui filtre p. On a donc v = σ(p) pour un certain σ, soit v = C(σ(p1 ), . . . , σ(pn )),
et il suffit de poser vi = σ(pi ).
Réciproquement, si vi filtre pi pour tout i, alors il existe des σi telles que vi = σi (pi ).
Comme p est linéaire, les domaines des σi sont deux à deux disjoints et on a donc σi (pj ) =
pj si i ̸= j. En posant σ = σ1 ◦ · · · ◦ σn , on a
match x with p1 → e1 | . . . | pn → en
F (x, e, action) =
let x = e in action
F (C, e, action) =
if constr(e) = C then action else error
F (C(p), e, action) =
if constr(e) = C then F (p, #1 (e), action) else error
F (C(p1 , . . . , pn ), e, action) =
if constr(e) = C then
F (p1 , #1 (e), F (p2 , #2 (e), . . . F (pn , #n (e), action) . . . )
else error
□
Pour filtrer plusieurs lignes, on remplace error par le passage à la ligne suivante,
c’est-à-dire
code(match x with p1 → e1 | . . . | pn → en ) =
F (p1 , x, e1 , F (p2 , x, e2 , . . . F (pn , x, en , error) . . . ))
où la fonction de compilation F a maintenant quatre arguments et est définie par
La compilation de
match x with [] -> 1 | 1 :: y -> 2 | z :: y -> z
donne le code suivant
if constr(x) = [] then
1
else
if constr(x) = :: then
if constr(#1(x)) = 1 then
let y = #2(x) in 2
else
if constr(x) = :: then
let z = #1(x) in let y = #2(x) in z
else error
else
if constr(x) = :: then
let z = #1(x) in let y = #2(x) in z
else error
Comme on le constate, cet algorithme est peu efficace car on effectue plusieurs fois les
mêmes tests (d’une ligne sur l’autre) et on effectue des tests redondants (si constr(e) ̸= []
alors nécessairement constr(e) = ::). On va se tourner vers un algorithme plus efficace.
e1 e2 ... em
p1,1 p1,2 . . . p1,m → action1
.. .. .. .. .. ..
. . . . . .
pn,1 pn,2 . . . pn,m → actionn
7.3. Filtrage 127
e1 . . . em
F = error
→ action1
F .. = action1
.
→ actionn
Lorsque n > 0 et m > 0, on va se ramener à des matrices plus petites. Si toute la colonne
de gauche se compose de variables xi,1 , c’est-à-dire
e1 e2 . . . em
x1,1 p1,2 . . . p1,m → action1
M = ..
.
xn,1 pn,2 . . . pn,m → actionn
e2 . . . em
p1,2 . . . p1,m → let x1,1 = e1 in action1
F (M ) = F ..
.
pn,2 . . . pn,m → let xn,1 = e1 in actionn
Sinon, c’est que la colonne de gauche contient au moins un motif construit. Supposons
par exemple qu’il y ait dans cette colonne trois constructeurs différents, C d’arité 1, D
d’arité 0 et E d’arité 2.
e1 e2 . . . em
C(q) p1,2 . . . p1,m → action1
D p2,2 p2,m → action2
x p3,2 p3,m → action3
M=
E(r, s) p4,2 p4,m → action4
y p5,2 p5,m → action5
C(t) p6,2 p6,m → action6
E(u, v) p7,2 . . . p7,m → action7
128 Chapitre 7. Compilation des langages fonctionnels
#1 (e1 ) e2 . . . em
q p1,2 . . . p1,m → action1
MC = _ p3,2 p3,m → let x = e1 in action3
_ p5,2 p5,m → let y = e1 in action5
t p6,2 . . . p6,m → action6
e2 . . . em
p p2,m → action2
MD = 2,2
p3,2 p3,m → let x = e1 in action3
p5,2 . . . p5,m → let y = e1 in action5
#1 (e1 ) #2 (e1 ) e2 . . . em
_ _ p3,2 p3,m → let x = e1 in action3
ME = r s p4,2 p4,m → action4
_ _ p5,2 p5,m → let y = e1 in action5
u v p7,2 . . . p7,m → action7
Enfin, on définit une sous-matrice pour les autres valeurs (de constructeurs différents de
C, D et E), c’est-à-dire pour les variables apparaissant dans la première colonne.
e2 . . . em
MR = p3,2 p3,m → let x = e1 in action3
p5,2 . . . p5,m → let y = e1 in action5
F (M ) = case constr(e1 ) in
C ⇒ F (MC )
D ⇒ F (MD )
E ⇒ F (ME )
otherwise ⇒ F (MR )
Ici, case est une construction élémentaire, utilisée pour comparer constr(e1 ) avec C, D et
E. Lorsqu’il n’y a que deux constructeurs dans le type de e1 , on peut utiliser un simple
if then else. Lorsqu’il y a un nombre fini de constructeur, on peut utiliser une table de
sauts. Lorsqu’il y a une infinité de constructeurs (par exemple, des chaînes de caractères),
on peut utiliser un arbre binaire ou une table de hachage pour réaliser case. Enfin, il
n’y a parfois qu’un seul constructeur (par exemple, un n-uplet) et alors F (M ) = F (MC )
directement.
Il est important de se persuader que cet algorithme termine. C’est bien le cas, car la
grandeur X
taille(pi,j )
i,j
7.3. Filtrage 129
Les langages à objets sont apparus dans les années 1960, avec Simula I et Simula 67,
puis se sont développés avec Smalltalk (1972), puis C++ (1983) ou encore Java (1995).
Dans ce chapitre, nous expliquons ce qu’est un langage à objets et comment il peut être
compilé, c’est-à-dire notamment comment matérialiser un objet en mémoire représenté
et comment réaliser un appel de méthode. Nous utilisons principalement Java à des fins
d’illustrations, puis C++ en fin de chapitre pour souligner quelques différences notables
entre les deux.
class Polar {
double rho, theta;
Polar(double r, double t) {
if (r < 0) throw new Error("Polar: negative length");
rho = r;
theta = t;
}
}
On peut alors écrire 1 par exemple
Polar p = new Polar(2, 3.14159265);
Supposons maintenant que l’on veuille maintenir l’invariant suivant pour tous les objets
de la classe Polar :
0 ≤ rho ∧ 0 ≤ theta < 2π.
Pour cela on déclare les champs rho et theta privés, de sorte qu’ils ne sont plus visibles
à l’extérieur de la classe Polar.
class Polar {
private double rho, theta;
Polar(double r, double t) { /* garantit l’invariant */ }
}
Si on tente maintenant d’accéder au champ rho depuis une autre classe
p.rho = 1;
on obtient une erreur de typage :
complex.java:19: rho has private access in Polar
La valeur du champ rho peut néanmoins être fournie par l’intermédiaire d’une méthode,
c’est-à-dire d’une fonction fournie par la classe Polar et applicable à tout objet de cette
classe.
class Polar {
private double rho, theta;
...
double norm() { return rho; }
}
Pour un objet p de type Polar, on appelle la méthode norm ainsi :
p.norm()
On peut le voir naïvement comme l’appel norm(p) d’une fonction qui prendrait l’objet
en argument, comme ceci :
double norm(Polar x) { return x.rho; }
Les objets remplissent donc un premier rôle d’encapsulation.
1. Une fois un constructeur introduit explicitement, le constructeur par défaut ne prenant pas d’ar-
gument disparaît. On ne pourrait plus écrire maintenant new Polar() comme nous l’avions fait juste
avant.
8.1. Brève présentation des concepts objets avec Java 133
Il est possible de déclarer un champ comme statique et il est alors lié à la classe et non
aux instances de cette classe. Dit autrement, il s’apparente à une variable globale.
class Polar {
double rho, theta;
static double two_pi = 6.283185307179586;
De même, une méthode peut être statique et elle s’apparente alors à une fonction tradi-
tionnelle. En voici un exemple :
static double normalize(double x) {
while (x < 0) x += two_pi;
while (x >= two_pi) x -= two_pi;
return x;
}
}
Ce qui n’est pas statique est appelé dynamique.
Le second concept objet est celui d’héritage : une classe B peut être définie comme
héritant d’une classe A, avec la syntaxe
class B extends A { ... }
Les objets de la classe B héritent alors de tous les champs et méthodes de la classe A,
auxquels ils peuvent ajouter de nouveaux champs et de nouvelles méthodes. La notion
d’héritage s’accompagne d’une notion de sous-typage : toute valeur de type B peut être
vue comme une valeur de type A. En Java, chaque classe hérite d’au plus une classe. On
appelle cela l’héritage simple, par opposition à l’héritage multiple. La relation d’héritage
forme donc une arborescence.
class A { ... } A
class B extends A { ... }
B C
class C extends A { ... }
class D extends C { ... } D
La classe A est appelée la super classe de B et C. De même, C est la super classe de D. La
relation de sous-typage est naturellement transitive. Ainsi, toute valeur de type D peut
être utilisée comme une valeur de type A.
Illustrons l’utilité de l’héritage avec l’exemple classique d’éléments graphiques (des
rectangles, des cercles, etc.). On commence par introduire une classe Graphical pour
représenter n’importe quel élément graphique, avec une position centrale et des dimensions
horizontale et verticale.
class Graphical {
int x, y; /* centre */
int width, height;
void add(Graphical g) {
group = new GList(g, group);
// + mise à jour de x, y, width, height
}
Il reste à redéfinir les méthodes draw et move dans la classe Group :
void draw() {
for (GList l = group; l != null; l = l.next)
l.g.draw();
}
2. On s’épargne ainsi la peine d’introduire un nom différent pour le paramètre formel. À noter que
l’affectation g = g serait acceptée mais ne ferait qu’affecter le paramètre g avec sa propre valeur.
136 Chapitre 8. Compilation des langages à objets
Il est clair que pendant le typage de ces deux méthodes, le compilateur ne peut pas
connaître le type dynamique de l.g. La liste l peut en effet contenir des Rectangle
comme des Circle, arbitrairement mélangés. On pourrait même avoir dans l des objets
de sous-classes de Graphical qui n’ont pas encore été définies.
Classe abstraite. Comme il n’y a jamais lieu de créer d’instance de la classe Graphical,
on peut en faire une classe abstraite. On est alors dispensé de donner le code de certaines
méthodes, comme par exemple draw.
abstract class Graphical {
int x, y;
int width, height;
Surcharge. En Java, plusieurs méthodes d’une même classe peuvent porter le même
nom, pourvu qu’elles aient des arguments en nombre et/ou en nature différents. C’est ce
que l’on appelle la surcharge (en anglais overloading). On peut définir ainsi deux méthodes
draw dans la classe Rectangle
class Rectangle extends Graphical {
...
void draw() {
...
}
void draw(String color) {
...
}
}
puis écrire ensuite
r.draw() ... r.draw("red") ...
La surcharge est résolue au typage. Tout se passe comme si on avait écrit deux méthodes
avec des noms différents, par exemple
class Rectangle extends Graphical {
...
void draw() {
...
}
void draw_String(String color) {
...
}
}
puis
8.1. Brève présentation des concepts objets avec Java 137
On peut surcharger également les constructeurs. Ainsi, on peut ajouter à la classe Rectangle
un second constructeur ne prenant que trois arguments pour construire un carré.
Rectangle Circle
x 0 x 50
y 0 y 50
width 100 width 20
height 50 height 20
radius 10
On ne détaille pas ici sous quelle forme la classe est stockée dans l’objet, mais il est impor-
tant de comprendre que cette information est présente et immuable. La valeur d’un objet
est l’adresse du bloc qui le représente. (Nous l’avions déjà expliqué dans la section 6.2.)
On note que l’héritage simple permet de stocker la valeur d’un champ à un emplace-
ment constant dans le bloc, les champs propres venant après les champs hérités. Ainsi, la
valeur de width sera toujours stockée dans le troisième champ de l’objet, qu’il s’agisse d’un
Rectangle, d’un Circle ou bien de toute autre sous-classe de Graphical. Cette organi-
sation, dite en préfixe, permet de compiler l’accès à un champ avec la seule information
du type statique, sans connaître le type dynamique. Pour chaque champ, le compilateur
connaît la position où ce champ est rangé, c’est-à-dire le décalage à ajouter au pointeur
sur l’objet. Si par exemple le champ width est rangé à la position +32 alors l’expression
e.width est compilée comme
... # on compile e dans %rcx
movq 32(%rcx), %rax # champ width
où A_f, B_g, etc., sont les adresses des codes des différentes méthodes. Comme pour le
rangement des champs dans les objets, on a adopté ici une organisation en préfixe pour le
rangement de ces adresses dans les descripteurs. Par conséquent, pour compiler un appel
de méthode e.m(e1 , . . . , en ), il suffit de
1. calculer la valeur de e, qui est une adresse a vers un objet ;
2. accéder au descripteur de la classe de cet objet ;
3. trouver l’adresse du code de la méthode m dans ce descripteur, avec un décalage
qui ne dépend que de m ;
4. appeler cette fonction de manière traditionnelle, en lui passant la valeur de l’objet
en plus des valeurs des arguments e1 , . . . , en .
class A { class A {
A() {...} A() {...}
A(int x) {...} A_int(int x) {...}
La surcharge n’en est pas pour le moins délicate. Si on introduit par exemple
3. En pratique, le descripteur de la classe C contient également l’indication de la super classe de C,
comme un pointeur vers son descripteur.
140 Chapitre 8. Compilation des langages à objets
class A {...}
class B extends A {
void m(A a) {...}
void m(B b) {...}
}
alors dans l’extrait de programme
les deux méthodes s’appliquent potentiellement. C’est la méthode m(B b) qui est appelée,
car plus précise du point de vue de l’argument. Dans certains cas, il peut y avoir ambiguïté.
En voici un exemple :
class A {...}
class B extends A {
void m(A a, B b) {...}
void m(B b, A a) {...}
}
{ ... B b = new B(); b.m(b, b); ... }
(C, τ1 , . . . , τn ).
On ordonne les profils en posant (τ0 , τ1 , . . . , τn ) ⊑ (τ0′ , τ1′ , . . . , τn′ ) si et seulement si τi est
un sous-type de τi′ pour tout i. Pour un appel de méthode
e.m(e1 , . . . , en )
Group add
.data
Group draw
descr_Graphical: Group move
.quad 0
.quad Graphical_move Rectangle draw
descr_Circle: Graphical move
.quad descr_Graphical
Circle draw
.quad Graphical_move Graphical move
.quad Circle_draw
descr_Rectangle: Graphical move
.quad descr_Graphical 0
.quad Graphical_move (les adresses croissent vers le haut)
.quad Rectangle_draw
descr_Group:
.quad descr_Graphical
.quad Group_move
.quad Group_draw
.quad Group_add
Les constructeurs des classes Circle, Rectangle et Group sont compilés de façon similaire.
Pour les méthodes, on adopte la même convention : l’objet est dans %rdi et les arguments
de la méthode dans %rsi, %rdx, etc., et la pile si besoin.
La méthode draw de la classe Group est plus intéressante, car elle contient un appel
dynamique.
8.2. Compilation de Java 143
L’appel dynamique est compilé par un saut à une adresse calculée avec call *. Nous
avions déjà utilisé cette instruction pour compiler l’appel à une fonction de première
classe dans le chapitre précédent (voir page 119).
Expliquons enfin comment construire un objet, c’est-à-dire comment compiler la construc-
tion new, en prenant la première ligne du programme principal.
Ici, on a supposé la variable g1 alloué dans le registre %r12. On commence par allouer un
bloc de 40 octets sur le tas avec malloc, qui se décomposent en 8 octets pour le pointeur
vers le descripteur de classe et 8 octets pour chacun des champs de la classe Rectangle 4 .
Une fois le résultat de malloc obtenu, et copié dans %r12, on stocke le descripteur de
classe, c’est-à-dire l’adresse représentée par descr_Rectangle, dans le premier champ de
l’objet. Enfin, on appelle le constructeur new_Rectangle en passant l’objet dans %rdi et
les arguments dans %rsi, %rdx, %rcx et %r8.
Transtypage. Comme on l’a vu, le type statique et le type dynamique d’une expression
désignant un objet peuvent différer, à cause du sous-typage. Il est parfois nécessaire de
« forcer la main » au compilateur, en prétendant qu’un objet e appartient à une certaine
classe C, ou plus exactement à l’une des super classes de C. On appelle cela le transtypage
(en anglais cast). La construction de Java pour le transtypage est
(C)e
e instanceof C
qui détermine si la classe de e est bien une sous-classe de C. Du coup, on trouve souvent
le schéma
if (e instanceof C) {
C c = (C)e;
...
}
Dans ce cas, le compilateur effectue typiquement une optimisation consistant à ne pas gé-
nérer de second test pour le transtypage. La compilation de la construction e instanceof
C ne pose pas de difficulté. Il s’agit d’une simple boucle qui remonte la hiérarchie de classes,
en partant de la classe dynamique de e jusqu’à atteindre C ou Object, la classe tout en
haut de la hiérarchie.
Le compilateur peut optimiser les constructions (C)e et e instanceof C dans certains
cas. Si C est l’unique sous-classe de D alors on peut faire un unique test d’égalité plutôt
qu’une boucle. Si D est une sous-classe de C alors e instanceof C vaut true directement.
class Graphical {
public:
int x, y, width, height;
virtual void move(int dx, int dy) { x += dx; y += dy; }
virtual void draw() = 0;
};
class Circle : public Graphical {
public:
int radius;
Circle(int cx, int cy, int r) {
x = cx; y = cy; radius = r; width = height = 2 * radius; }
void draw() { ... /* dessin */ ... }
};
class Rectangle : public Graphical {
public:
Rectangle(int x1, int y1, int x2, int y2) {
this->x = (x1+x2)/2; this->y = (y1+y2)/2;
width = abs(x1-x2); height = abs(y1-y2); }
void draw() { ... /* dessin */ ... }
};
class GList {
public:
Graphical* g;
GList* next;
GList(Graphical* g, GList* next) { this->g = g; this->next = next; }
};
class Group : public Graphical {
GList* group;
public:
Group() { group = NULL; }
void add(Graphical* g) {
group = new GList(g, group); ... /* mise à jour x,y,width,height */ }
void draw() {
for(GList* l = group; l != NULL; l = l->next) l->g->draw(); }
void move(int dx, int dy) {
Graphical::move(dx, dy); // pour mettre à jour x,y
for(GList* l = group; l != NULL; l = l->next) l->g->move(dx, dy); }
};
int main() {
Rectangle g1 = Rectangle(0, 0, 100, 50);
g1.move(10, 5); g1.draw();
Circle g2 = Circle(10, 10, 2);
Group g3;
g3.add(&g1); g3.add(&g2);
g3.draw(); g3.move(-5,-7); g3.draw();
}
Représentation des objets. Sur cet exemple, la représentation d’un objet n’est pas
différente de Java.
Mais en C++, on trouve aussi de l’héritage multiple. Par conséquent, on ne peut plus
(toujours) utiliser le principe selon lequel la représentation d’un objet d’une super classe
de C est un préfixe de la représentation d’un objet de la classe C (et de même pour les
descripteurs de classes). Voici un exemple d’héritage multiple, où la classe FlexibleArray
hérite à la fois de la classe Array et de la classe List.
class Collection {
int cardinal; descr. FlexibleArray
}; cardinal
class Array : public Collection { ...
int nth(int i) ...
descr. List
};
...
class List {
void push(int v) ... ...
int pop() ...
};
class FlexibleArray : public Array, public List {
};
Collection
class Collection
class Array : Collection Array List
class List
class FlexibleArray : Array, List FlexibleArray
Collection Collection
class Collection
class Array : Collection Array List
class List : Collection
class FlexibleArray : Array, List FlexibleArray
Collection
class Collection
class Array : virtual Collection Array List
class List : virtual Collection
class FlexibleArray : Array, List FlexibleArray
Cette dernière situation est appelé le diamant ou encore le losange. Elle apparaît parfois
comme un problème, lorsqu’elle est accidentelle, qui jouit d’une réputation sulfureuse. Les
anglo-saxons en parlent sous le terme de deadly diamond of death, ce qui veut tout dire.
150 Chapitre 8. Compilation des langages à objets
9
Compilateur optimisant
Dans ce chapitre, nous allons écrire un compilateur pour un fragment simple du lan-
gage C vers l’assembleur x86-64, en cherchant à produire du code raisonnablement efficace.
En particulier, on va chercher à bien utiliser les seize registres et les nombreuses instruc-
tions de l’architecture x86-64.
Le fragment du langage C que nous allons compiler contient des entiers (type int uni-
quement), des pointeurs vers des structures (allouées sur le tas uniquement), des fonctions
et les structures de contrôle if, while et return. La syntaxe abstraite de ce fragment est
donnée figure 9.1. Ce fragment peut paraître modeste, mais en utilisant quelques fonc-
tions de la bibliothèque C on peut allouer des structures sur le tas (avec sbrk ou malloc)
ou encore faire des entrées-sorties (par exemple avec putchar). Pour simplifier un peu
notre compilateur, on fait ici le choix d’entiers 64 bits signés pour le type int, ce que le
standard C nous autorise à faire, même si ce n’est pas l’usage 1 . De cette manière, entiers
et pointeurs occuperont tous 64 bits.
Il est illusoire de chercher à produire du code efficace en une seule passe. La production
de code va donc être décomposée en plusieurs phases. Le nombre et la nature de ces phases
dépend des compilateurs. Nous choisissons ici l’architecture du compilateur CompCert 2
de Xavier Leroy. Cette architecture n’est pas liée au langage C. On pourrait tout autant
l’utiliser pour compiler un langage fonctionnel ou orienté objets.
Notre point de départ est l’arbre de syntaxe abstraite issu du typage. En particulier,
on suppose avoir distingué tous les identificateurs, avoir distingué également les variables
locales des variables globales, en enfin que le type de chaque sous-expression est connu.
E ::= n expression
| L
| L=E
| E op E | - E | ! E
| x(E, . . . , E)
| sizeof(struct x)
L ::= x valeur gauche
| E->x
op ::= == | != | < | <= | > | >= opérateurs binaires
| && | || | + | - | * | /
S ::= ; instruction
| E;
| if (E) S else S
| while (E) S
| return E;
| B
B ::= { V . . . V S . . . S } bloc
V ::= int x, . . . , x; variables ou champs
| struct x *x, . . . , *x;
T ::= int | struct x * type
P ::= D . . . D programme
pure(n) = true
pure(x) = true
pure(e1 + e2 ) = pure(e1 ) ∧ pure(e2 )
..
.
pure(e1 = e2 ) = false
pure(f (e1 , . . . , en )) = false (on ne sait pas)
Lorsque e n’est pas pure, il reste possible de simplifier 0 × e en 0 après avoir évalué e
pour ses effets. On s’épargne ainsi une multiplication inutile. Dans le cas d’une division
0/e il faut être encore plus soigneux : évaluer e pour ses effets, tester si e = 0 et signa-
ler une division par zéro le cas échéant et sinon donner la valeur 0 à l’expression sans
faire de division. On peut se demander pourquoi le programmeur aurait écrit en premier
lieu des expressions telles que 0 × e ou 0/e, mais il faut garder à l’esprit l’utilisation
de macros (même si elle est déconseillée) ou encore la compilation de code C produit
automatiquement.
La sélection d’instructions va transformer les arbres de syntaxe abstraite en de nou-
veaux arbres dans une syntaxe abstraite légèrement différente, où les opérations sont
maintenant celles de x86-64. Cette nouvelle syntaxe abstraite est donnée figure 9.2. La
154 Chapitre 9. Compilateur optimisant
principale différence se situe dans les expressions. Le reste consiste uniquement à oublier
les types et à regrouper les variables locales en début de fonction et les variables globales
en début de programme.
Pour réaliser la sélection d’instructions tout en incorporant de l’évaluation partielle,
on peut utiliser des constructeurs intelligents (en anglais smart constructors). Il s’agit
de fonctions se comportant comme des constructeurs de la syntaxe abstraite, tout en
effectuant des simplifications à la volée. Par exemple, on peut se donner le constructeur
suivant pour l’addition :
mkAdd(n1 , n2 ) = n1 + n2
mkAdd(0, e) = e
mkAdd(e, 0) = e
mkAdd((add n1 ) e, n2 ) = mkAdd(n1 + n2 , e)
mkAdd(n, e) = (add n) e
mkAdd(e, n) = (add n) e
mkAdd(e1 , e2 ) = add e1 e2 sinon
(Attention à ne pas confondre l’opérateur unaire add n, qui ajoute l’opérande immédiate n
à son argument, à l’opérateur binaire d’addition add.) On pourrait faire encore plus de
simplifications, par exemple en utilisant intelligemment l’instruction lea. Quoique l’on
fasse, il faut garantir que la fonction de simplification termine. Ici, la taille des arguments
de mkAdd diminue strictement lors de l’appel récursif, ce qui garantie la terminaison.
Une fois de tels constructeurs intelligents définis, la traduction peut se faire mot à mot.
On note IS(e) la traduction d’une expression e. Sa définition est donné figure 9.3. Les accès
à la mémoire au travers de la construction -> sont traduits en des accès indirects avec
décalage. Ce décalage peut être calculé facilement, car chaque champ de structure occupe
la même taille, à savoir 8 octets. Si x est le troisième champ d’une structure, par exemple,
son décalage sera d = 16. De la même façon, on connaît la taille totale occupée par une
structure et on peut donc traduire sizeof(struct x) directement par un entier. Pour
le reste de la syntaxe abstraite, c’est-à-dire les appels de fonctions, les instructions et les
déclarations, la sélection d’instructions est un morphisme. Voici un exemple de sélection
d’instructions :
E ::= n expression
| x
| x=E
| load n(E)
| store n(E) E
| binop E E
| unop E
| x(E, . . . , E)
S ::= ; instruction
| E;
| if (E) S else S
| while (E) S
| return E;
| { S ...S }
P ::= x . . . x F . . . F programme
Cet exemple de la fonction factorielle nous servira de fil conducteur tout au long des
phases suivantes.
Il faut lire ce code à l’envers : on commence par évaluer e1 dans rd , puis e2 dans un
nouveau pseudo-registre r2 , puis enfin on effectuer l’addition dans rd . Un cas de base,
réduit à une seule instruction, est par exemple celui du chargement d’une constante n
dans rd :
On procède de même avec les variables globales et les accès à la mémoire. Pour un appel de
fonction, le principe est le même que pour une addition, avec seulement plus d’arguments
à évaluer dans des pseudo-registres.
Pour les variables locales, on se donne une table où chaque variable est associée à un
pseudo-registre. La lecture ou l’écriture d’une variable locale est alors traduite avec l’ins-
truction mov (opérateur binaire).
RTL({s1 . . . sn }, Ld ) = Ln ← RTL(sn , Ld )
...
L1 ← RTL(s1 , L2 )
renvoyer L1 Le
e
Pour traduire une while(e)s, il faut construire une
boucle dans le graphe de flot de contrôle. Vu que l’on
Ld
construit le code de bas en haut, cela pose une petite dif-
ficulté. On s’en sort en utilisant l’instruction RTL goto s
pour fermer la boucle a posteriori, comme ceci :
RT L(while(e)s, Ld ) = Le ← RT Lc (e, RT L(s, L), Ld ) L
ajouter L : goto Le goto
renvoyer Le
9.3. Production de code ERTL 159
Traduction d’une fonction. Pour traduire une fonction, on commence par allouer des
pseudo-registres frais pour ses arguments, son résultat et ses variables locales. On crée
une étiquette fraîche Ld pour la sortie de la fonction. Enfin, on traduit le corps s de la
fonction avec RTL(s, Ld ) et on note le résultat comme étant l’étiquette d’entrée de la
fonction. Pour la fonction fact, on obtient ceci au final :
Chaque fonction est traduite indépendamment, avec son propre graphe de flot de contrôle.
En ce sens, notre traduction est intraprocédurale i.e. sans connaissance des autres fonctions
et en particulier sans connaissances des points d’appel de cette fonction. Par opposition,
une analyse interprocédurale pourrait prendre en compte le contexte d’appel des fonctions.
dans des registres et sur la pile, et pour récupérer le résultat dans %rax. On conserve
néanmoins l’information du nombre de paramètres passés dans des registres (qui sera uti-
lisée par la phase 4). Ainsi, on écrit call fact(1) pour signifier un appel à fact avec un
seul argument passé par registre.
Viennent enfin de nouvelles instructions pour manipuler la pile. Les deux instructions
alloc_frame et delete_frame alloue et désalloue respectivement la partie locale du ta-
bleau d’activation. Ces instructions n’ont pas d’argument, car la taille n’est pas encore
connue. Elle ne le sera qu’après l’allocation de registres, réalisée dans la phase suivante.
Les instructions push_param et get_param permettent respectivement à l’appelant de
placer un paramètre sur la pile et à l’appelé de le récupérer. L’argument n de get_param
est une position relative par rapport à %rbp. Par exemple, le dernier argument mis sur la
pile, le cas échéant, se trouve à l’adresse %rbp + 16.
Pour traduire le code RTL en code ERTL, on ne change pas la structure du graphe de
flot de contrôle ; on se contente d’insérer de nouvelles instructions. On le fait principale-
ment à trois endroits :
— au début de chaque fonction, pour allouer le tableau d’activation, sauvegarder les
registres callee-saved et copier les paramètres dans les pseudo-registres correspon-
dants ;
— à la fin de chaque fonction, pour copier le pseudo-registre contenant le résultat
dans %rax, restaurer les registres callee-saved et désallouer le tableau d’activation ;
— à chaque appel, pour copier les pseudo-registres contenant les paramètres dans
%rdi, . . ., %r9 et sur la pile avant l’appel et copier %rax dans le pseudo-registre
contenant le résultat après l’appel.
Traduction des instructions. Les instructions qui ne sont pas modifiées sont le char-
gement d’une constante, la lecture et l’écriture d’une variable globale, la lecture et l’écri-
ture en mémoire, une opération unaire, une opération binaire autre que la division et
les opérations de branchement. Ces instructions restent placées aux mêmes étiquettes et
transfèrent le contrôle vers les mêmes étiquettes qu’auparavant.
Montrons maintenant les changements apportés dans ERTL. On commence par le cas
de la division. En RTL, on a pour l’instant une instruction
L1 : div r1 r2 → L
où r1 et r2 sont deux pseudo-registres. Attention au sens : on divise ici r2 par r1 . Dans
ERTL, cela devient trois instructions 3
L1 : mov r2 %rax → L2
L2 : div r1 %rax → L3
L3 : mov %rax r2 → L
avec L2 et L3 des étiquettes fraîches. Même si on a ajouté de nouvelles instructions, le
point d’entrée est toujours L1 et le point de sortie est toujours L.
Considérons maintenant un appel de fonction, c’est-à-dire une instruction RTL
L1 : call r ← f (r1 , . . . , rn ) → L
3. L’instruction x86-64 idiv r divise en réalité l’entier représenté par la concaténation de %rdx et de
%rax par r, puis met le quotient dans %rax et le reste dans %rdx. Nous expliciterons cela un peu plus
tard, dans la phase suivante.
9.3. Production de code ERTL 161
Traduction d’une fonction. Pour traduire une fonction RTL en une fonction ERTL,
on traduit son graphe de flot de contrôle en traduisant chaque instruction RTL comme
expliqué ci-dessus. Par ailleurs, on ajoute de nouvelles instructions ERTL en entrée et en
sortie de fonction. À l’entrée de la fonction, on commence par allouer le tableau d’activa-
tion, en ajoutant l’instruction alloc_frame. Puis, on sauvegarde les registres callee-saved.
Pour cela, on se donne autant de pseudo-registres frais qu’il y a de registres callee-saved et
on copie la valeur de ces derniers dans ces pseudo-registres. Enfin, on copie les paramètres
dans leurs pseudo-registres. S’il s’agit de paramètres passés dans des registres, on les copie
avec mov. Sinon, on va les chercher sur la pile avec get_param. Si on prend l’exemple de
la fonction fact et si on suppose pour simplifier que les seuls registres callee-saved sont
%rbx et %r12, on obtient ceci :
RTL ERTL
#2 fact(#1) fact(1)
entry : L10 entry : L17
L17: alloc_frame –> L16
L16: mov %rbx #7 –> L15
L15: mov %r12 #8 –> L14
L14: mov %rdi #1 –> L10
L10: mov #1 #6 –> L9 L10: mov #1 #6 –> L9
... ...
Les registres %rbx et %r12 sont sauvegardés respectivement dans #7 et #8. On note que le
point d’entrée du graphe de flot de contrôle a changé ; c’est maintenant L17 au lieu de L10.
On a rajouté quatre instructions, qui se poursuivent ensuite par ce qui était auparavant
le code RTL, à partir de L10.
À la sortie de la fonction, on ajoute une instruction mov pour copier le pseudo-registre
contenant le résultat dans %rax. Puis on restaure les registres callee-saved, en copiant
les valeurs depuis les pseudo-registres utilisés pour leur sauvegarde. Enfin, on désalloue le
9.3. Production de code ERTL 163
tableau d’activation avec delete_frame avant de faire return. Pour la fonction fact, on
obtient ceci :
RTL ERTL
#2 fact(#1) fact(1)
entry : L10 entry : L17
exit : L1
... ...
L8 : mov $1 #2 –> L1 L8 : mov $1 #2 –> L1
... ...
L2 : imul #4 #2 –> L1 L2 : imul #4 #2 –> L1
L1 : mov #2 %rax –> L21
L21: mov #7 %rbx –> L20
L20: mov #8 %r12 –> L19
L19: delete_frame –> L18
L18: return
Dans le code RTL, on avait deux sauts vers l’étiquette de sortie L1, correspondant aux
deux instructions return dans le code C. Dans le code ERTL, on a toujours ces deux
sauts vers L1, mais des instructions ERTL ont maintenant été ajoutées à cet endroit-là.
En particulier, le résultat, contenu dans #2, est copié dans %rax et les registres callee-saved
sont restaurés en reprenant les valeurs dans #7 et #8. On note qu’il n’y a plus de notion
d’étiquette de sortie dans le code ERTL, car on a maintenant une instruction return
explicite.
L’intégralité du code ERTL de la fonction fact est donné figure 9.6 page 167. C’est
encore loin de ce que l’on imagine être un bon code x86-64 pour cette fonction. À ce point,
il faut comprendre plusieurs choses. D’une part, l’allocation de registres (phase 4) tâchera
d’associer des registres physiques aux pseudo-registres de manière à limiter l’usage de la
pile mais aussi de supprimer certaines instructions. Si par exemple on réalise #8 par %r12,
on supprime tout simplement les deux instructions L15 et L20. D’autre part, le code n’est
pas encore organisé linéairement (le graphe est seulement affiché de manière arbitraire).
Ce sera le travail de la phase 5, qui tâchera notamment de minimiser les sauts.
Définition 27 (variable vivante). En un point de programme, une variable est dite vivante
si la valeur qu’elle contient est susceptible d’être utilisée dans la suite de l’exécution. □
On emploie les mots « est susceptible d’être utilisée » car la propriété « est utilisée »
n’est pas décidable. On va donc se contenter d’une approximation. Cette approximation
devra être correcte, au sens où une réponse négative, c’est-à-dire la variable v n’est pas
vivante, signifie qu’il est garantie que la valeur de v n’est plus utilisée dans la suite du
calcul.
Prenons l’exemple du code ERTL dont le graphe de flot
de contrôle est représenté ci-contre. Les variables sont ici a, mov $0 a
b, c et %rax. Sur chaque arête du graphe, on a indiqué les va- a
riables vivantes. Par exemple, a est vivante sur la deuxième mov $1 b
arête en partant du haut, car sa valeur est utilisée dans a, b
la troisième instruction (mov a c) et a n’a pas été affectée mov a c
entre-temps. La variable b, en revanche, n’est pas vivante sur b, c
cette arête, car sa valeur est définie par la seconde instruction mov b a
(mov $1 b). Mais elle est vivante sur la troisième arête, car a, b, c a, b
sa valeur est utilisée par l’instruction mov b a, qui la copie add c b
dans a. La variable %rax n’est jamais vivante, car sa valeur a, b
n’est jamais utilisée, mais seulement définie par la toute der- jl $1000 b
nière instruction. On prendra le temps de bien comprendre a
cet exemple. mov a %rax
La notion de variable vivante se déduit des définitions et
des utilisations des variables effectuées par chaque instruc-
tion. Ainsi, l’instruction add r1 r2 utilise les variables r1 et r2 , et définit la variable r1 .
Pour une instruction située à l’étiquette l du graphe, on note def(l) l’ensemble des va-
riables définies par cette instruction et use(l) l’ensemble des variables utilisées par cette
instruction.
9.4. Production de code LTL 165
Pour calculer les variables vivantes, il est commode de les associer non pas aux arêtes
mais plutôt aux nœuds du graphe de flot de contrôle, c’est-à-dire à chaque instruction.
Mais il faut alors distinguer les variables vivantes à l’entrée d’une instruction et les va-
riables vivantes à la sortie. Pour une instruction située à l’étiquette l du graphe, on note
in(l) l’ensemble des variables vivantes sur l’ensemble des arêtes arrivant sur l et out(l)
l’ensemble des variables vivantes sur l’ensemble des arêtes sortant de l. Les équations qui
définissent in(l) et out(l) sont alors les suivantes :
in(l) = use(l) ∪ (out(l)\def(l))
out(l) = S
s∈succ(l) in(s)
Il s’agit d’équations récursives dont la plus petite solution est celle qui nous intéresse.
Nous sommes dans le cas d’une fonction monotone sur un domaine fini (les parties de
l’ensemble fini des variables) et nous pouvons donc appliquer le théorème de Knaster–
Tarski (voir page 64). Cela signifie que l’on part d’ensembles in(l) et out(l) vides pour
toutes les instructions, puis on applique les équations ci-dessus jusqu’à obtenir un point
fixe. Si on reprend l’exemple ci-dessus, en numérotant les instructions de 1 à 7 du haut
vers le bas, on converge après sept itérations 4 :
1
mov $0 a itération 1 itération 2 itération 7
2 use def in out in out in out
mov $1 b
1 a ... a
3 2 b a ... a a, b
mov a c
4
3 a c a a b ... a, b b, c
mov b a 4 b a b b b, c ... b, c a, b, c
5 5 b, c b b, c b, c b ... a, b, c a, b
add c b
6 b b b a ... a, b a, b
6
jl $1000 b
7 a a a ... a
7
mov a %rax
Calcul de def et use. Le calcul des ensembles def(l) (définitions) et use(l) (utilisations)
est immédiat pour la plupart des instructions ERTL. Voici tous les cas simples :
On fait cependant un cas particulier pour la division, qui prend le dividende dans l’en-
semble %rdx : %rax et place le quotient et le reste respectivement dans %rax et %rdx.
def use
div r %rax {%rax, %rdx} {%rax, %rdx, r}
Reste enfin les instructions call et return. Pour une instruction call f (k), l’entier k
nous indique le nombre de paramètres passés dans des registres, ce qui définit les registres
utilisés par l’appel. On exprime par ailleurs que tous les registres caller-saved peuvent
être écrasés par l’appel.
def use
call f (k) caller-saved les k premiers de %rdi,%rsi,. . .,%r9
Enfin, pour l’instruction return, on exprime le fait que %rax et tous les registres callee-
saved sont susceptible d’être utilisés.
def use
return ∅ {%rax} ∪ callee-saved
La figure 9.7 donne le résultat de l’analyse de durée de vie pour la fonction fact.
in out
L17: alloc_frame --> L16 %r12,%rbx,%rdi %r12,%rbx,%rdi
L16: mov %rbx #7 --> L15 %r12,%rbx,%rdi #7,%r12,%rdi
L15: mov %r12 #8 --> L14 #7,%r12,%rdi #7,#8,%rdi
L14: mov %rdi #1 --> L10 #7,#8,%rdi #1,#7,#8
L10: mov #1 #6 --> L9 #1,#7,#8 #1,#6,#7,#8
L9 : jle $1 #6 -> L8, L7 #1,#6,#7,#8 #1,#7,#8
L8 : mov $1 #2 --> L1 #7,#8 #2,#7,#8
L1 : goto --> L22 #2,#7,#8 #2,#7,#8
L22: mov #2 %rax --> L21 #2,#7,#8 #7,#8,%rax
L21: mov #7 %rbx --> L20 #7,#8,%rax #8,%rax,%rbx
L20: mov #8 %r12 --> L19 #8,%rax,%rbx %r12,%rax,%rbx
L19: delete_frame--> L18 %r12,%rax,%rbx %r12,%rax,%rbx
L18: return %r12,%rax,%rbx
L7 : mov #1 #5 --> L6 #1,#7,#8 #1,#5,#7,#8
L6 : add $-1 #5 --> L5 #1,#5,#7,#8 #1,#5,#7,#8
L5 : goto --> L13 #1,#5,#7,#8 #1,#5,#7,#8
L13: mov #5 %rdi --> L12 #1,#5,#7,#8 #1,#7,#8,%rdi
L12: call fact(1)--> L11 #1,#7,#8,%rdi #1,#7,#8,%rax
L11: mov %rax #3 --> L4 #1,#7,#8,%rax #1,#3,#7,#8
L4 : mov #1 #4 --> L3 #1,#3,#7,#8 #3,#4,#7,#8
L3 : mov #3 #2 --> L2 #3,#4,#7,#8 #2,#4,#7,#8
L2 : imul #4 #2 --> L1 #2,#4,#7,#8 #2,#7,#8
Figure 9.7 – Analyse de durée de vie pour la fonction fact.
168 Chapitre 9. Compilateur optimisant
mov w v
on ne souhaite pas déclarer que v et w interfèrent car il peut être précisément intéressant de
réaliser v et w par le même emplacement et d’éliminer ainsi une ou plusieurs instructions.
On adopte donc la définition suivante.
Définition 29 (graphe d’interférence). Le graphe d’interférence d’une fonction est un
graphe non orienté dont les sommets sont les variables de cette fonction et dont les arêtes
sont de deux types : interférence ou préférence. Pour chaque instruction qui définit une
variable v et dont les variables vivantes en sortie, autres que v, sont w1 , . . . , wn , on procède
ainsi :
— si l’instruction n’est pas une instruction mov w v, on ajoute les n arêtes d’interfé-
rence v − wi ;
— s’il s’agit d’une instruction mov w v, on ajoute les arêtes d’interférence v − wi pour
tous les wi différents de w et on ajoute l’arête de préférence v − w.
Si une arête v − w est à la fois de préférence et d’interférence, on conserve uniquement
l’arête d’interférence. □
La figure 9.8 montre le graphe obtenu pour la fonction fact, en considérant ses 8
pseudo-registres et uniquement 10 registres physiques (%rdi, %rsi, %rdx, %rcx, %r8, %r9,
%rax, %r10, %rbx et %r12), car on a omis certains registres pour simplifier. Les arêtes
de préférence sont indiquées en pointillés. On voit par exemple qu’on a une arête de
préférence entre #2 et %rax, qui provient de l’instruction mov #2 %rax. On observe que
certains sommets sont de fort degré. Pour #7 et #8, cela s’explique par le fait qu’on y
a sauvegardés les registres callee-saved et donc que leur durée de vie s’étend sur toute
la fonction. Le sommet #1 est également de fort degré, car il contient l’argument de la
fonction fact, qui est utilisé sur l’ensemble de la fonction et en particulier après l’appel
récursif.
#6 #7
%rdx %rbx
%r9 %r12
#1 #5
#4 %rdi
#3 #8
#2 %r10
%rcx %rsi
%rax %r8
ne peut être colorié, on peut toujours l’allouer sur la pile. On dit alors qu’il est vidé en
mémoire (en anglais on parle de spilled register ).
Quand bien même le graphe serait effectivement coloriable, le déterminer serait trop
coûteux car il s’agit là d’un problème NP-complet. On va donc colorier en utilisant des
heuristiques, avec pour objectifs une complexité linéaire ou quasi-linéaire et une bonne
exploitation des arêtes de préférence. Une technologie répandue aujourd’hui s’appelle Ite-
rated Register Coalescing. Elle exploite les idées suivantes.
Notons K le nombre de couleurs, c’est-à-dire le nombre de registres physiques (10 dans
notre exemple). Une première idée, due à Kempe, date de 1879 : si un sommet a un degré
strictement inférieur à K, alors on peut le retirer du graphe, colorier le reste, et on sera
ensuite assuré de pouvoir lui donner une couleur. Cette étape est appelée simplification.
Les sommets retirés sur donc mis sur une pile. Comme retirer un sommet diminue le degré
d’autres sommets, cela peut donc produire de nouveaux candidats à la simplification.
Lorsqu’il ne reste que des sommets de degré supérieur ou égal à K, on en choisit un
comme candidat à être vidé en mémoire (en anglais potential spill ). Il est alors retiré
du graphe, mis sur la pile et le processus de simplification peut reprendre. On choisit de
préférence un sommet qui est peu utilisé, car les accès à la mémoire coûtent cher, et qui
a un fort degré, pour favoriser de futures simplifications.
Lorsque le graphe est vide, on commence le processus de coloration proprement dit,
appelé sélection. On dépile les sommets un à un et pour chacun on lui affecte une couleur
de la manière suivante. S’il s’agit d’un sommet de faible degré, on est assuré de lui trouver
une couleur. S’il s’agit au contraire d’un sommet de fort degré, c’est-à-dire d’un candidat
à être vidé en mémoire, alors de deux choses l’une : soit il peut être tout de même colorié
170 Chapitre 9. Compilateur optimisant
car ses voisins utilisent moins de K couleurs au total (on parle de coloriage optimiste) ;
soit il ne peut pas être colorié et doit être effectivement être vidé en mémoire (en anglais
actual spill ).
Enfin, il convient d’utiliser au mieux les arêtes de préférence. Pour cela, on utilise
une technique appelée coalescence (en anglais coalescing) qui consiste à fusionner deux
sommets du graphe. Comme cela peut augmenter le degré du sommet résultant, on ajoute
un critère suffisant pour ne pas détériorer la K-colorabilité. Voici un exemple de tel critère :
Un pseudo-code pour l’allocation de registres est donné figure 9.9. Il s’écrit naturelle-
ment sous la forme de cinq fonctions mutuellement récursives, qui prennent en argument
le graphe g à colorier et qui renvoient le coloriage. La fonction fusionner(g, v1, v2)
construit un nouveau graphe en fusionnant les sommets v1 et v2 en un seul sommet v2.
Pour la notion de coût utilisée dans la fonction spill, on peut utiliser par exemple
nombre d’utilisations de v
coût(v) =
degré de v
Si on applique cet algorithme sur le graphe d’interférence de la fonction fact (figure 9.8
page 169), on va effectuer successivement les actions suivantes :
1. simplify appelle coalesce qui fusionne #2- - -#3
2. simplify appelle coalesce qui fusionne #4- - -#1
3. simplify appelle coalesce qui fusionne #6- - -#1
4. simplify appelle coalesce qui fusionne #3- - -%rax
5. simplify appelle coalesce qui fusionne #5- - -%rdi
6. simplify appelle coalesce. Cette fois il n’y a plus d’arête de préférence satisfai-
sant le critère de George. coalesce appelle alors freeze qui appelle spill qui
appelle select avec #7.
7. simplify appelle select avec #1
8. simplify appelle coalesce qui fusionne #8- - -%r12
À ce point, le graphe ne contient plus de pseudo-registre. Du coup, simplify appelle
coalesce qui appelle freeze qui appelle spill qui renvoie un coloriage vide. On dépile
donc maintenant un par un les sommets retirés du graphe, en leur attribuant une couleur
à chaque fois, soit dans coalesce, soit dans select.
1. coalesce attribue à #8 la couleur %r12
2. select attribue à #1 la couleur %rbx
3. select attribue à #7 la couleur spill
4. coalesce attribue à #5 la couleur %rdi
5. coalesce attribue à #3 la couleur %rax
9.4. Production de code LTL 171
simplify(g) =
s’il existe un sommet v sans arête de préférence
de degré minimal et < K
alors
select(g, v)
sinon
coalesce(g)
coalesce(g) =
s’il existe une arête de préférence v1-v2
satisfaisant le critère de George
alors
g <- fusionner(g, v1, v2)
c <- simplify(g)
c[v1] <- c[v2]
renvoyer c
sinon
freeze(g)
freeze(g) =
s’il existe un sommet v de degré minimal < K
alors
g <- oublier les arêtes de préférence de v
simplify(g)
sinon
spill(g)
spill(g) =
si g est vide
alors
renvoyer le coloriage vide
sinon
choisir un sommet v de coût minimal
select(g, v)
select(g, v) =
c <- simplify(g privé de v)
s’il existe une couleur r possible pour v
alors
c[v] <- r
sinon
c[v] <- spill
renvoyer c
L1 : mov n r → L
L1 : mov x r → L
pose un problème quand r est alloué sur la pile car on ne peut pas écrire 6
movq x, n(%rbp)
Il faut donc utiliser un registre intermédiaire. Le problème est qu’on vient justement de
réaliser l’allocation de registres. Une solution simple consiste à réserver deux registres
particuliers, qui seront utilisés comme registres temporaires pour ces transferts avec la
mémoire et ne seront pas utilisés par ailleurs. En pratique, on n’a pas nécessairement
le loisir de gâcher ainsi deux registres. On doit alors modifier le graphe d’interférence
et relancer une allocation de registres pour déterminer un registre libre pour le trans-
fert. Heureusement, cela converge très rapidement en pratique, en deux ou trois étapes
seulement.
Si on opte pour la première solution, par exemple en utilisant %r10 et %r11, on
peut alors facilement traduire chaque instruction ERTL. Ainsi, pour traduire l’instruction
ERTL
L1 : mov x r → L
on considère deux cas de figure. Si color(r) est un registre physique hw, on a une instruc-
tion LTL
L1 : mov x hw → L
si en revanche color(r) est un emplacement de pile n(%rbp), alors on a deux instructions
LTL
L1 : mov x %r10 → L2
L2 : mov %r10 n(%rbp) → L
où L2 est une étiquette fraîche. Il en va de même pour lire le contenu d’une variable. On
a parfois besoin des deux temporaires, par exemple dans le cas d’une instruction ERTL
L1 : store r1 n(r2 ) → L
où r1 et r2 sont tous les deux alloués sur la pile. Pendant la traduction vers LTL, on
applique un traitement spécial dans certains cas. D’une part, l’instruction mov r1 r2 → L
est traduite par goto → L lorsque r1 et r2 ont la même couleur. C’est là que l’on récolte
les fruits d’une bonne allocation de registres. D’autre part, l’instruction x86-64 imul exige
que sa seconde opérande soit un registre. Il faut utiliser un temporaire si ce n’est pas le
cas. Enfin, une opération binaire ne peut avoir ses deux opérandes en mémoire. Il faut
utiliser un temporaire si ce n’est pas le cas.
On connaît maintenant la taille du tableau d’activation.
..
Si n est le nombre d’arguments de la fonction et m le nombre .
d’emplacements sur la pile utilisés par l’allocation de re- param. 7
gistres, le tableau d’activation a la structure ci-contre, où ..
.
chaque case occupe ici 8 octets. On traduit alloc_frame par param. n
push %rbp adr. retour
mov %rsp %rbp %rbp → ancien %rbp
locale 1
add $ − 8m %rsp ..
et delete_frame par .
%rsp → locale m
mov %rbp %rsp ..
.
pop %rbp
Dans le cas où m = 0, on peut simplifier respectivement en un simple push et un simple
pop. Enfin, on peut traduire l’instruction ERTL get_param k r en terme d’accès par
rapport à %rbp, c’est-à-dire 7 mov k(%rbp) color(r).
7. Si color(r) est un emplacement de pile, il faut utiliser un temporaire.
174 Chapitre 9. Compilateur optimisant
Pour la fonction fact, on obtient au final le code LTL donné figure 9.11. Ce code
contient de nombreuses instructions goto qui vont disparaître pendant la phase suivante.
Sinon, il est possible que le code correspondant au test positif (L2 ) n’ait pas encore été
produit et on peut alors avantageusement inverser la condition de branchement.
où la condition cc est l’inverse de la condition cc. Enfin, dans le cas où le code correspon-
dant aux deux branches a déjà été produit, on n’a pas d’autre choix que de produire un
branchement inconditionnel.
instr(L1 : branch cc → L2 , L3 ) = produire L1 : jcc L2
produire jmp L3
On peut essayer d’estimer la condition qui sera vraie le plus souvent pour que le branche-
ment soit effectif le moins souvent. S’il s’agit d’un test de boucle, par exemple, on peut
considérer qu’il est plus souvent vrai, car on n’écrit rarement des boucles qu’au plus une
itération.
Comme on l’a vu, le code LTL contient de nombreux goto, d’origines diverses —
boucles while dans la phase RTL, insertion de code dans la phase ERTL, suppression
d’instructions mov dans la phase LTL. On s’efforce de les éliminer lorsque c’est possible.
= produire l’étiquette L1
appeller lin(L2 ) sinon
Au final, on obtient pour la fonction fact le code x86-64 donné figure 9.12. Ce code
n’est pas optimal, mais néanmoins de l’ordre de ce que donne gcc -O1. Il est bien meilleur
que celui donné par gcc -O0 ou clang -O0. Mais il est moins bon que celui donné par
gcc -O2 ou clang -O1, où la fonction fact est transformée en une boucle. Sans parler
d’une transformation aussi radicale, on peut noter plusieurs points sur lesquels notre code
assembleur n’est pas parfait. En premier lieu, le tableau d’activation est créé et %rbx y
est sauvegardé avant de tester si x <= 1. Lorsque c’est le cas, on aurait pu s’épargner
ce travail. Ceci est dû à notre traduction vers ERTL, qui sauvegarde systématiquement
les registres callee-saved en entrée de fonction. Par ailleurs, lorsque x <= 1, on saute en
L8, pour mettre 1 dans %rax, puis on fait un saut inconditionnel en L1 pour terminer
la fonction. On pourrait s’épargner ce saut supplémentaire, soit en dupliquant la fin de
fonction, soit en mettant 1 dans %rax dès le départ, pour sauter alors directement en L1.
On note également que l’instruction movq %rbx, %rdi est inutile, car ces deux registres
contiennent déjà la même valeur. En effet, on a copié %rdi dans %rbx trois instructions
9. Il n’est pas nécessaire d’appeler lin(L2 ) ensuite si ce code a déjà été produit, mais ce n’est pas
incorrect. Cela va juste produire une instruction jmp inutile et inatteignable.
9.5. Production de code assembleur x86-64 177
plus haut. Ici, une analyse de flot de données assez simple pourrait déterminer que les deux
registres contiennent la même valeur et supprimer cette instruction. Enfin, on aurait pu
utiliser l’instruction decq %rdi plutôt que addq $-1, %rdi, par une meilleure sélection
d’instruction, de même qu’on aurait pu utiliser push et pop pour sauvegarder et restaurer
%rbx.
Mais il est toujours plus facile d’optimiser un programme à la main.
Exercice 1, page 9
Elle met le registre %rax à zéro, car quelle que soit la valeur d’un bit b, le ou exclusif de
b et b vaut toujours 0. C’est légèrement plus économe que movq $0, %rax, car l’instruction
est représentée en machine par trois octets au lieu de sept.
Exercice 2, page 9
L’entier −16 s’écrit 111 . . . 11100002 en complément à deux (des chiffres 1 terminés
par quatre chiffres 0). Du coup, l’instruction andq $-16, %rsp a pour effet de mettre à 0
les quatre chiffres de poids faible de %rsp. On peut l’interpréter de façon arithmétique
comme le plus grand multiple de 16 inférieur ou égal à %rsp.
C’est notamment une façon d’aligner la pile avant une instruction call, lorsqu’on ne
sait pas si la pile est ou non alignée. Dans ce cas, il faut également ajouter des instructions
pour restaurer la pile dans son état initial. La section 1.3 explique comment utiliser le
registre %rbp pour cela.
Exercice 3, page 13
L’entier a représente les colonnes de l’échiquier restant à remplir. Les entiers b et c
représentent les colonnes qui sont en prise avec des reines déjà placées sur les lignes
précédentes. L’entier a & ~b & ~c représente donc les colonnes qu’il faut considérer pour
la ligne courante. On parcourt les bits à 1 de cet entier avec la boucle while, en extrayant
à chaque fois le bit à 1 le plus faible avec e & -e. Pour chaque colonne examinée, on
fait un appel récursif avec a, b et c mis à jour en conséquence. On a trouvé une solution
lorsqu’on parvient à a = 0.
Note : On trouvera une justification de l’astuce e & -e dans l’excellent livre de Henry
Warren Hacker’s Delight [27]. Ce livre contient par ailleurs beaucoup d’information utile
à celui qui écrit un compilateur, comme par exemple une méthode systématique pour
remplacer une division par une constante par des opérations moins coûteuses que la divi-
sion.
182 Chapitre A. Solutions des exercices
Exercice 4, page 15
La valeur de n est dans %rdi et n’est pas modifiée. On choisit de placer c dans %rax car
ce sera la valeur de retour. On choisit de placer s dans un %rsi, un registre caller-saved.
Voici un code possible :
isqrt:
xorq %rax, %rax
movq $1, %rsi
jmp 2f
1: incq %rax
leaq 1(%rsi, %rax, 2), %rsi
2: cmpq %rdi, %rsi
jle 1b
ret
On utilise ici le fait qu’une étiquette peut être un entier, auquel on peut faire référence
en avant ou en arrière. Ainsi, l’étiquette 1b signifie l’étiquette 1 plus haut dans le code
et l’étiquette 2f signifie l’étiquette 2 plus loin dans le code. On a placé le test de la
boucle (étiquette 2) après le corps de la boucle (étiquette 1). Initialement, on saute au
test avec jmp 2f. Avec cette façon de procéder, on n’effectue qu’une seule opération de
branchement par tour de boucle.
Pour calculer isqrt(17), il suffit d’écrire
main:
movq $17, %rdi
call isqrt
Le résultat se trouve alors dans %rax. Si on veut l’afficher, on peut utiliser par exemple
la fonction de bibliothèque printf, comme ceci :
movq $Sprint, %rdi
movq %rax, %rsi
xorq %rax, %rax # pas d’arguments en virgule flottante
call printf
Sprint:
.string "isqrt(17) = %d\n"
(La fonction printf étant une fonction variadique, on doit indiquer son nombre d’argu-
ments en virgule flottante dans %rax ; ici, il n’y en a pas.)
Exercice 5, page 15
On commence par la version réalisée avec une boucle.
fact: movq $1, %rax # r <- 1
1: imulq %rdi, %rax # r <- x * r
decq %rdi # x <- x-1
jg 1b # on continue si x > 0
ret
On a utilisé ici le fait que l’instruction decq a positionné les drapeaux. On peut donc faire
un branchement conditionnel immédiatement après.
183
Pour la version récursive, c’est plus complexe, car il faut sauvegarder l’argument sur
la pile.
factrec:cmpq $1, %rdi # x <= 1 ?
jle 1f
pushq %rdi # sauve x sur la pile
decq %rdi # x <- x-1
call factrec # appel fact(x-1)
popq %rcx # restaure x
imulq %rcx, %rax # x * fact(x-1)
ret
1: movq $1, %rax
ret
Exercice 6, page 19
let succ e =
Add (e, Cte 1)
Exercice 7, page 19
let rec eval env = function
| Cte n -> n
| Var x -> env x
| Add (e1, e2) -> eval env e1 + eval env e2
| Mul (e1, e2) -> eval env e1 * eval env e2
Exercice 8, page 20
La primitive + doit être appliquée à une paire, tandis que la fonction fun x → fun y →
+ (x, y) est appliquée successivement à deux arguments. Plus précisément, son applica-
tion à un argument nous renvoie une fonction, qu’on peut ensuite appliquer à un second
argument. En particulier, on peut donc l’appliquer partiellement à un unique argument,
à la différence de +. On dit d’une fonction qui prend ses arguments successivement, en
renvoyant une fonction à chaque fois, qu’elle est curryfiée. Le terme vient du nom du
mathématicien Haskell Curry.
Exercice 9, page 26
Pour simplifier, on note F le terme fun fact → fun n → e où e est le terme
où e′ est le terme e[f act ← opfix F ]. On laisse le lecteur (courageux) compléter cette
dérivation.
opfix (fun fact → fun n → if =(n, 0) then 1 else × (n, fact (+(n, −1)))).
f 2
→ (fun n → if =(n, 0) then 1 else × (n, f (+(n, −1)))) 2
→ if =(2, 0) then 1 else × (n, f (+(n, −1)))
→ if false then 1 else × (2, f (+(2, −1)))
→ ×(2, f (+(2, −1)))
→ ×(2, (fun n → if =(n, 0) then 1 else × (n, f (+(n, −1)))) (+(2, −1)))
→ ×(2, (fun n → if =(n, 0) then 1 else × (n, f (+(n, −1)))) 1))
→ ×(2, if =(1, 0) then 1 else × (1, f (+(1, −1))))
→ ×(2, if false then 1 else × (1, f (+(1, −1))))
→ ×(2, ×(1, f (+(1, −1))))
→ ×(2, ×(1, (fun n → if =(n, 0) then 1 else × (n, f (+(n, −1)))) (+(1, −1))))
→ ×(2, ×(1, (fun n → if =(n, 0) then 1 else × (n, f (+(n, −1)))) 0))
→ ×(2, ×(1, if =(0, 0) then 1 else × (0, f (+(0, −1)))))
→ ×(2, ×(1, if true then 1 else × (0, f (+(0, −1)))))
→ ×(2, ×(1, 1))
→ ×(2, 1)
→ 2
| Op "opif" ->
let (Paire (Const n1, Paire (Fun (_,bt), Fun (_,be)))) = eval e2 in
eval (if n1 = 0 then bt else be)
Pour opfix, on suit la règle de sémantique :
| Op "opfix" ->
let Fun (f, e) as b = eval e2 in
eval (subst e f (App (Op "opfix", b)))
Noter l’usage de as pour éviter de reconstruire Fun (f, e).
E, e ↠ v
E, x ← e ↠ E{x 7→ v}
E, e ↠ true E, s1 ↠ E1 E, e ↠ false E, s2 ↠ E2
E, if e then s1 else s2 ↠ E1 E, if e then s1 else s2 ↠ E2
186 Chapitre A. Solutions des exercices
E, e ↠ true E, s ↠ E1 E1 , while e do s ↠ E2
E, while e do s ↠ E2
E, e ↠ false
E, while e do s ↠ E
a ⋆ | a ⋆ b a ⋆ | a ⋆ b a ⋆ b a⋆
last(∅) = ∅
last(ϵ) = ∅
last(a) = {a}
last(r1 r2 ) = last(r1 ) ∪ last(r2 ) si null (r2 )
= last(r2 ) sinon
last(r1 | r2 ) = last(r1 ) ∪ last(r2 )
last(r⋆) = last(r)
{a1 , a2 , b1 } {a1 , a2 , b1 , #}
À la différence de l’automate page 47, il est déterministe, c’est-à-dire que pour chaque
état et chaque caractère, il n’y a qu’une seule transition possible.
E
E * E
E + E int
int int
doit être transformé de façon à faire apparaître la règle E → E+T à sa racine, ce qui est
un changement plus subtile. Notons L(E), L(T ) et L(F ) les langages reconnus par les
trois non terminaux de la grammaire (4.2). On a clairement L(F ) ⊆ L(T ) ⊆ L(E).
On commence par montrer que si w1 , w2 ∈ L(T ) alors w1 *w2 ∈ L(T ), par récurrence
sur la taille de la dérivation de w2 . Si la dérivation commence par T → F , c’est-à-dire
w2 ∈ L(F ), c’est évident. Sinon, la dérivation commence par T → T *F , c’est-à-dire
w2 →⋆ w2′ *f , et on peut appliquer l’hypothèse de récurrence w1 *w2′ puis conclure avec
T → T *F .
De même, on montre que si w1 ∈ L(E) et w2 ∈ L(T ) alors w1 *w2 ∈ L(E), par
récurrence sur la taille de la dérivation de w1 . Puis on montre que si w1 , w2 ∈ L(E) alors
w1 *w2 ∈ L(E), par récurrence sur la taille de la dérivation de w2 . Enfin, on montre que
si w1 , w2 ∈ L(E) alors w1 +w2 ∈ L(E), par récurrence sur la taille de la dérivation de w2 .
On peut maintenant montrer par récurrence sur la taille de l’arbre de dérivation qu’un
mot reconnu par la grammaire (4.1) est reconnu par la grammaire (4.2). Si le mot est de
la forme int, c’est évident. Si le mot est de la forme (E), alors on applique l’hypothèse de
récurrence à la dérivation de E et on ajoute E → T → F → (E). Si la dérivation est de la
forme E → E*E, alors on applique l’hypothèse de récurrence aux deux sous-dérivations
puis on utilise le résultat précédent. De même si la dérivation est de la forme E → E+E.
S T
follow(X) {#} {(, ), nat, lam, #}
S E L
first(X) {(, sym} {(, sym} {(, sym}
S E L
follow(X) {#} {(, ), sym, #} {)}
nat lam ( ) #
S T# T# T#
T nat lam T (T T )
sym ( ) #
S E# E#
E sym (L)
L EL EL ϵ
+ )
10 12
( E
5
F F
(
E + (
1 2 3
8
T T
(
4 *
F 11 int
9
*
int 6
int
T 7
int
S → •E #
E → •G =D E → G= • D
E → •D G E → G • =D = D → •G
G → •*D D → G• G → •*D
G → •id G → •id
D → •G
=
1 ... ...
2 shift 3 ...
reduce D → G
.. ..
3 . .
Le caractère = fait bien partie des suivants de G et donc la grammaire n’est pas SLR(1).
# =
1 ... ... ...
2 reduce D → G shift 3 ...
.. .. ..
3 . . .
au moment de la lecture du ElSE. En effet, on peut alors réduire IF CONST THEN CONST,
ce qui correspondrait à
E → IF E THEN E
est celle de THEN, il suffit de donner à ELSE une priorité plus forte que celle de THEN,
c’est-à-dire
%nonassoc THEN
%nonassoc ELSE
et à la seconde le type
(int → int).
..
.. .
. ···⊢×:int×int→int ···⊢n:int ···⊢fact (+(n, −1)):int
···⊢=(n, 0):bool ···⊢1:int ···⊢×(n, fact (+(n, −1))):int ···⊢fact:int→int ···⊢2:int
fact:int→int, n:int⊢if =(n, 0) then 1 else ×(n, fact (+(n, −1))):int fact:int→int⊢fact 2:int
⊢ let rec fact n = if =(n, 0) then 1 else × (n, fact (+(n, −1))) in fact 2 : int
Il s’agit là d’une structure persistante, ce qui nous sera utile plus loin. Par ailleurs, il
s’agit d’arbres équilibrés et on a donc une insertion et une recherche en O(log n) dans un
environnement contenant n entrées, ce qui est tout à fait acceptable. Écrivons maintenant
la fonction qui calcule le type d’une expression. Certains cas sont immédiats :
On note ici l’intérêt de la persistance de env : on ajoute dans env, en obtenant une
nouvelle structure, et il n’est jamais nécessaire de retirer quelque chose de env. Enfin, les
seules vérifications se trouvent dans l’application :
Du coup, la première application de map_id résout ’_a comme étant int et la seconde
application échoue. Une solution consiste à écrire map_id plutôt sous la forme
(On parle d’η-expansion quand on écrit fun x -> f x plutôt que f.) Du coup, le type de
map_id est maintenant généralisé car il s’agit d’une valeur, en l’occurrence une abstrac-
tion.
struct Kont {
enum kind kind;
union { struct Node *r; int hl; };
struct Kont *kont;
};
...
instanceof:
movq (%rdi), %rdi
L: cmpq %rdi, %rsi # même descripteur ?
je Ltrue
movq (%rdi), %rdi # on passe à la super classe
testq %rdi, %rdi
jnz L # on a atteint Object ?
Lfalse: movq $0, %rax
ret
Ltrue: movq $1, %rax
ret
On a pris soin de tester l’égalité avant de tester si la super classe existe, afin que e
instanceof Object renvoie bien true.
196 Chapitre A. Solutions des exercices
B
Petit lexique français-anglais de la
compilation
∆, 26 de syntaxe abstraite, 18
λ-abstraction, 20, 120 arithmétique
λ-calcul, 20, 63, 66, 68, 73 des ordinateurs, 3
42, 42 ARM, 3
91 (fonction), 121 arrière
partie arrière du compilateur, 97
abstraction, 20 ASCII, 46
add (instruction x86-64), 8 assembleur, 5
adresse, 4 AT&T, 5, 6, 156
algorithme W, 90 automate
alignement, 12 à pile, 55
allocation de registres, 164 fini, 47
alphabet, 46 avant
ambiguë (grammaire), 62 partie avant du compilateur, 45
analyse axiome, 59
ascendante, 69
descendante, 66 big-endian, 7
lexicale, 45 bit, 3
syntaxique, 55 de signe, 4
sémantique, 79 blanc, 45
appel boutisme, 7
par nom, 99 byte, 3
par nécessité, 99
par référence, 99 C, iii, 19, 97, 103, 120, 151
par valeur, 24, 99 C++, iii, 97, 106, 115, 120, 131, 145
terminal, 120, 163 call by name, 99
appelant, 11 call by need, 99
appelé, 11 call by reference, 99
applicatif (langage), 98 call by value, 99
application, 20 callee, 11
arbre callee-saved, 11, 159
de dérivation, 61 caller, 11
202 INDEX
type
polymorphe, 85
principal, 93
simple, 80
unification, 90
upcast, 144
UTF-8, 46
valeur gauche, 97
variable
libre, liée, 21
vidage en mémoire, 169
W (algorithme), 90
x86-64, 3, 151
add, 8
cqto, 8
idiv, 8
imul, 8
jnz, 9
jz, 9
lea, 9, 154
mov, 8
movabsq, 8
movs, 8
movz, 8
sal, 9
sar, 9
shr, 9
sub, 8
yacc, 75