Vous êtes sur la page 1sur 162

Compilateurs

et interprètes
(Notes de cours)
Jerzy Karczmarczuk

Janvier 2004
Compilateurs et interprètes
Table Des Matières
Construction d’un simple interprète p29
Développement de l’interprète p34
De l’interprète vers le compilateur p52
Machine virtuelle à pile p57
Nouveaux mécanismes décisionnels p75
Gestion des erreurs ; exceptions p85
Le traçage p96
Modèle CPS p100
Techniques de précédence p121
Grammaires et parseurs p139
Parseurs monadiques p150
Parsing phrasal général p176
Récursivité à gauche p188
Intermezzo monadique p197
Analyse sémantique générale p217
Parsing, quelques compléments p225
Génération du code, continuation p243
Machine avec registres p258
Optimisation du parsing p262
Types p272
Allocation de la mémoire pendant l’exécution p283
Garbage Collection p287

Annexe A : Entrées et sorties en Haskell


Annexe B : Un exemple du parsing
Compilateurs et interprètes
(Jerzy Karczmarczuk, 2003 – 2004)

(Approche fonctionnelle)

– Présentation globale
– Ce que vous devez connaître déjà : Haskell ; cours : langages et automates ;
autres langages de programmation, comme Scheme, C++ : paradigmes d’al-
gorithmique comme la récursivité, piles, files, traitement des listes, etc.
– Programme (provisoire/préliminaire, mais assez détaillé)
– Devoir (généralités) et autres administrivia
– Quelques conseils (ceux qui redoublent ce cours expliqueront aux autres
pourquoi leur prise en compte peut vous éviter cette expérience). . .
1

Philosophie générale
Les chances que vous allez un jour faire un véritable, efficace (« natif »compila-
teur d’un langage évolué sont dérisoires. Par contre, les chances de faire un petit
compilateur/interprète, un module de communication/scripting, etc., – sont assez
bonnes ! Petits langages interprétés prolifèrent partout : Python, Ruby, PHP. . .

On voit partout des applications scriptables (ce qui implique la présence d’un mo-
dule d’exécution (interprétaion) de petits programmes), ou équippées d’un mo-
dule de communication structurée, ce qui implique la présence d’un analyseur
syntaxique/sémantique du texte.

Word, Excel, etc., sont scriptables, possèdent des macro-processeurs/interprètes


du Visual Basic. Le modeleur 3D : Blender permet d’écrire des scripts en Python.
Les navigateurs comme Netscape, etc. possèdent à l’intérieur une bonne partie
de la machine virtuelle de Java (sans compilateur), mais aussi un compilateur
complet + la machine virtuelle du JavaScript. 2
Alors, ce cours est important. Sa vocation est pratique. Nous allons exploiter les
connaissances acquises en cours Langages et Automates, mais la perspective est
différente, le style aussi, et les techniques d’algorithmisation et de programmation
seront assez spécifiques.

Nous allons utiliser de manière cohérente et assez poussée des techniques


de programmation fonctionnelle, et nous allons programmer presque tout en
Haskell. Pourquoi? La programmation fonctionnelle est simple !

Conseil 1. Oubliez tout pseudo-code. Nous ne le verrons jamais en cours, il est


nuisible à votre santé psychique. Il ne sera accepté ni en TD ni en examen. Appre-
nez à formuler les solutions des problèmes de programmation posés, directement
sous forme de programmes.

Les problèmes posés en TD, ainsi que les sujets d’examen seront concrets, il fau-
dra coder quelque chose. Pseudo-codes, discussion qualitative, philosophique etc.,
ne passeront pas. 3

Ce cours ne se trouve pas dans le vide. Il y aura des références à d’autres langages ;
nous allons exploiter les structures syntaxiques du C (C++, Pascal, Java, etc.)
comme des « motifs » évolués qui doivent être analysés et compris avant d’en
générer un code de bas niveau. Scheme nous permettra de jeter un pont entre
les structures de données arborescentes, et un code intermédiaire, interprété très
facilement.

Style de travail
La cohérence entre le cours et les TD sera importante, même si les différences
seront visibles. En tout cas, les TD font partie intégrale de ce module, et je n’hé-
siterai pas à exploiter les sujets traités seulement en TD comme sujets potentiels
d’examen.

En TD, souvent on invente les problèmes ad hoc et on improvise. Gilles Lebrun


probablement viendra avec des feuilles d’exercices, mais moi – non. Ceci dit, les
2 groupes de TD seront synchronisés malgré quelques différences. 4
Il est possible que je vous donne quelques exercices en cours, ou laissés sur mes
pages Web, et je demanderai à ce que vous prépariez les solutions chez vous.
Ceci peut influencer les notes de contrôle continu de quelques uns. Je vous po-
serai quelques centaines de fois la question : y a-t-il des questions? Attention :
Je réponds aux questions, mais : pas de questions? — alors pas de réponses.
Tout sujet qui n’a pas été signalé comme difficile, est par défaut considéré comme
maîtrisé. Posez des questions via e-mail et lisez les FAQs.

Contrôle continu ; devoir

Il y aura le travail permanent en TD, la participation, l’activité personnelle, le dia-


logue etc. Vous allez présenter vos propositions devant vos collègues. Il y aura
aussi un devoir plus conséquent, dont le sujet n’a pas encore été établi ; il est pos-
sible qu’il vous faudra construire un parseur qui ajoute des opérateurs surchargés
à Java, ou un interprète spécialisé. 5

Votre devoir sera réalisé en Haskell, et utilisera le compilateur GHC. Pour plu-
sieurs exercices l’usage des interprètes (Hugs et GHCi) sera suffisant, mais pour
le devoir – non.

Pour le travail courant l’interprète préconisé est GHCi. Ceux qui veulent utiliser
Hugs chez eux peuvent le faire, mais attention aux incompatibilités !

Polycopié, autre support écrit


Mon polycopié précédent est sur le Web. Ne l’imprimez pas !, l’interdiction est
formelle. Le PDF contenant ces transparents sera placé sur le Web également,
mais pas tout de suite. La totalité sera probablement imprimé officiellement avant
l’examen.

Lisez SVP la documentation de Haskell. En cas de problèmes, demandez de


l’aide, je vous expliquerai tout, « mais : pas de questions? Alors pas de réponses ». . . 6
Programme provisoire du cours

• Un exemple démontrant nos objectifs : la compréhension de la séman-


tique des langages de programmation

1. Simple modèle d’un « programme » – une expression arithmétique, et


son évaluation (interprétation) par une petite « machine virtuelle » style
Scheme (plutôt : calcul lambda).
2. Complications : variables et notion d’environnement. Structures associa-
tives et indexées.
3. Structures de contrôle (conditionnelles) et leur rapport aux fonctions pa-
resseuses.
4. Construction des fonctions utilisateur.
5. Passage de l’interprète à un très simple compilateur (générateur du code
postfixe) ; la notion d’évaluation partielle.
7

• Généralités
1. Les notions d’interprétation et de compilation, expliquées de manière
plus structurée et disciplinée.
2. Phases de compilation. Analyse lexicale, syntaxique, sémantique. Géné-
ration du code (aussi – peut-être – en quelques phases). Assemblage des
modules indépendants. Le rôle (et la structure) de la table des symboles ;
techniques de hachage.
3. Structure d’un programme exécutable (code, données, systèmes d’en-
trées/sorties ; gestion de mémoire).
4. Paradigmes classiques de programmation : constructions fonctionnelles,
impératives ; la logique et programmation relationnelle. Programmation
par objets ; héritage et fonctions virtuelles.
5. Autres paradigmes de programmation : co-procédures et pseudo-parallé-
lisme ; programation pilotée par les événements ; programmation par flots
de données et langages visuels. 8
6. Éléments de la sémantique calculatoire (paradigmes mentionnés ci-dessus)
vus de la perspective fonctionnelle. Haskell, quelques compléments, no-
tamment le rappel de la sémantique paresseuse. . .
7. Notion de continuations et leur usage ; pont conceptuel entre la séman-
tique fonctionnelle et les structures impératives (instructions de branche-
ment).

• Le « Back-end » du processus de compilation : Machine (virtuelle) à pile ;


interprétation du code de bas niveau.

1. Construction d’un interprète de bas niveau style FORTH/PostScript (code


postfixe). Notion de threaded code (code « enfilé »), comme paradigme
de séquencement des instructions.
2. Évolution de l’interprète ; environnement, structures de contrôle (condi-
tionnelles, boucles ; appels fonctionnels et retours) ; complications sé-
rieuses : traçage et gestion des exceptions. 9

3. Notion de continuation (dans le contexte spécifique de cette section),


et un autre modèle (très fonctionnel) de l’interprète. Plusieurs exemples;
discussion des extensions possibles.
4. Discussion de quelques techniques de génération du code, en utilisant la
programmation paresseuse en Haskell.
5. Deux mots sur l’optimisation du code. Récursivité terminale. Allocation
des registres.
6. Quelques nouveaux éléments de la sémantique ; co-procédures et proces-
sus quasi-parallèles (déjà introduits auparavant) ; leur réalisation.
7. Peut-être quelques mots sur l’implémentation des fermetures (fonctions
qui préservent à l’intérieur le contexte de leurs définitions, p.ex. les va-
riables non-locales).
8. Peut-être quelques mots sur l’implémentation du backtracking dans la
programmation logique
10
• Analyse – généralités
1. Séparation (conceptuelle) de la partie analytique de la compilation en
sous-analyses : lexicale, syntaxique et sémantique. Leur coopération et
intégration ; communication entre les modules.
2. Description générale de l’approche lexico/syntaxique commune : gram-
maires à plusieurs niveaux. Exemples. Stratégies du parsing : descendante
et ascendante.
3. Analyse sémantique. Notion d’attribut ; leur classification et propagation.
4. Systèmes de types. Logique de l’inférence des types.
• Intermezzo monadique
• Parsing fonctionnel (La Grande Usine à Gaz. . . )
1. Définition d’un parseur. Réalisation fonctionnelle du parsing descendant.
Parseurs primitifs et leur combinateurs. Exemples, exemples. . .
2. Développement et optimisation des parseurs descendants. Génération du
code pilotée par la syntaxe. 11

3. Grammaires de précédences, grammaires opérationnelles et la technique


opérationnelle (ascendante) du parsing.
4. Techniques LR – description superficielle, plus quelques exemples.
• Le Run-time (Support d’exécution : gestion de mémoire, librairies, I/O, in-
terfaçage avec le système, etc.)
1. Allocation hiérarchique de mémoire ; piles et leur gestion.
2. Stratégies d’allocation dynamique (sur le tas). Compteurs des références.
3. Stratégies du ramassage des miettes (Garbage collection). Technique mar-
quage-balayage ; technique de recopie intégrale. Garbage-collector géné-
rationnel. Ramassage incrémental (en « temps réel »).

Ni l’ordre ni les détails mentionnés ci-dessus ne sont garantis. Le programme ré-


flète la vision actuelle des choses. Plusieurs éléments essentiels du cours naissent
des digressions et de vos questions. 12
Pourquoi techniques fonctionnelles? Pourquoi Haskell?

La réponse est simple : la programmation fonctionnelle est simple. . . Les exemples


sont parfois très tordus, mais leur sémantique est claire et disciplinée. On ne
cache rien !

Les techniques fonctionnelles sont universelles, elles permettent la description


de toutes les structures informatiques qui constituent les paradigmes d’autres ap-
proches (logique, par objets, etc.) On peut les spécifier et réaliser de manière
lisible et propre. Programmation fonctionnelle est très compacte, on ne dilue pas
l’essentiel algorithmique par des redondances syntaxiques.

Haskell grâce à l’évaluation paresseuse permet aisément la construction fonc-


tionnelle des structures de contrôle, y compris celles appartenant au monde im-
pératif : les processus itératifs. 13

Le typage, statique, fort, et basé sur l’inférence automatique est une merveille,
permettant d’éliminer lors de la compilation de nos procédures en Haskell plus de
80% des erreurs qui en – disons – Lisp auraient été découvertes pendant l’exécu-
tion. Mais on a moins de « liberté » ou de souplesse. . .

Bien sûr, nous avons à notre disposition toute la machinerie de structures de don-
nées composites, polymorphisme contrôlé (système de classes), gestion automa-
tique de mémoire, etc. Actuellement de plus en plus les langages fonctionnels
servent comme outils de construction des compilateurs. Ils perdent la bataille
contre les langages impératifs seulement là, où une efficacité brute (vitesse de
compilation) est essentielle.

Lisez la doc de Haskell si vous trouvez des problèmes ; posez des questions. 30%
des échecs à l’examen est le résultat de la mauvaise connaissance du langage. 14
Introduction aux outils fonctionnels de programmation

Conseil. Je répète : le langage Haskell est considéré comme connu. Si vous trou-
vez un détail mal compris, obscur ou inconnu, réagissez immédiatemment. Ce
conseil ne sera plus répété ! Une maîtrise imparfaite du langage ne sera aucune
circonstance atténuante. Je ne peux consacrer trop de temps à cela, c’est un cours
de compilation. . .

Combinateurs

Dans le style fonctionnel on focalise l’attention plutôt sur les opérations que sur
les données soumises à ces opérations ; ceci met en relief l’universalité desdites
opérations. On fait donc des abstractions fonctionnelles sous forme de combina-
teurs – des fonctions pures qui d’habitude servent à combiner d’autres fonctions,
de les transformer et intégrer. 15

L’exemple typique d’un combinateur est la fonction qui compose séquentielle-


ment les opérations, par exemple (f ◦ g)(x) = f (g(x)).

f . g = \x -> f (g x)

Le compositeur est prédéfini, tout comme les combinateurs :

id x = x
flip f x y = f y x
const x y = x

D’autres combinateurs, p. ex. appl x f = f x peuvent être écrits comme :

appl x f = f x = id f x = flip id x f ⇓
appl = flip id
(boost f p) x = p (f x) ⇓
boost = flip (.) 16
En général la « simplification combinatoire » qui consiste à supprimer les para-
mètres des fonctions dans leur définition, est basée sur la règle d’ordre normal
d’évaluation : f a b c = (f a) b c = ((f a) b) c. Ceci permet de sim-
plifier l’équivalence f x = g x à f = g. Mais attention ! La dernière forme est
beaucoup plus générale, selon la première f et g sont des fonctions, ce qui n’est
pas évident en analysant f=g.

D’autres combinateurs peuvent nous être utils, p. ex. le duplicateur :

dupl f x = f x x

ou le combinateur de substitution (très universel ; il aurait pu être prédéfini. . . )

subs f g x = f x (g x)

On note des équivalences : 17

dupl f x = subs f id x
id = subs const const
subs const const x = const x (const x) = x

etc. Les combinateurs jouent un rôle très important dans la compilation des lan-
gages fonctionnels, mais pour nous c’est un outil de composition des éléments
d’un compilateur ou d’un interprète ; c’est pour simplifier la notation.

Typage polymorphe

Le mot « polymorphisme » a deux significations pratiques.

1. Polymorphisme ad hoc ou la surcharge, est la possibilité d’utiliser les mêmes


noms pour des opérations différentes, parfois incongrues, comme l’usage de
(+) pour l’addition des nombres entiers, flottants, parfois pour la concaténa-
tion des chaînes, etc. 18
La surcharge a un très fort goût orienté objet, et en Haskell elle est réalisée
par les classes de types, qui seront fréquemment exploitées ici.
2. Polymorphisme sémantique, ou généricité (restreinte) dénote la possibilité
de définir des fonctions qui ont exactement la même définition pour des ar-
guments de différents types. La recherche du troisième élément d’une liste ne
dépend pas du tout du type des éléments de cette liste.
La puissance : xn = x · x · · · · x pour n entier positif, c’est le même al-
gorithme pour x appartenant aux différents types. Mais attention, même si
la fonction (^) est structurellement polymorphe, son implantation utilise la
multiplication qui dépend de l’argument x. (·) et (^) sont surchargés.

Nous allons travailler avec les types tout le semestre, et une question garantie sur
l’examen porte sur le typage polymorphe de quelque chose (souvent un combina-
teur composite). 19

Par exemple, le combinateur subs possède le type

subs :: (t -> t1 -> t2) -> (t -> t1) -> t -> t2

et la composition (.) :

(.) :: (b -> c) -> (a -> b) -> a -> c

où a, t etc. sont des « variables-type », des types quelconques. Vous devez sa-
voir analyser et déduire de telles spécifications. Ce qui est intéressant en Haskell
est le polymorphisme (« constructeur ») des constantes numériques. La constante
explicite 7 n’est pas un nombre entier. Son type est : (Num t) => t, un type ar-
bitraire t, mais contraint à appartenir à la classe des nombres ! La constante 7.1
est spécifiée comme (Fractional t) => t, un nombre non-entier.

Les classes de types, indispensables pour définir nos propres surcharges et contraintes,
seront évoquées plus tard. 20
Autres exemples
Quel est le type de ad donné par ad n1 n2 x y = n1 x (n2 x y) ?

C’est une fonction de 4 paramètres, ad :: a->b->c->d->e, où e est le type du


résultat. Mais n2 s’applique à x et y et fournit le second argument de la fonction
n1. Le résultat retourné par n1 est le résultat de ad. La réponse est :

ad :: (c -> h -> e) -> (c -> d -> h) -> c -> d -> e

(Les identificateurs n’ont pas de signification). Et le type de unf?

unf p f g x
| p x = []
| otherwise = f x : unf p f g (g x)

unf :: (t -> Bool) -> (t -> a) -> (t -> t) -> t -> [a]

(p est un prédicat ; f transforme x, g aussi, mais sans changer le type). 21

Programmation paresseuse

Comme vous le savez, en Haskell l’expression f (g x) force l’évaluation de x


seulement si la fonction g en a besoin. Mais même si c’est le cas, si la fonction f
n’a pas besoin de son argument (p. ex. elle est constante), g n’est lancée jamais,
et x n’est évalué non plus.

L’implémentation d’une telle sémantique n’est pas compliquée : le code du pro-


gramme qui utilise f ne force pas l’évaluation de son argument d’abord, mais la
fonction f reçoit le code de g x comme son argument. S’il est devenu nécessaire,
la fonction f y accède, ce qui déclenche l’exécution de ce code. Ceci nous permet
de définir des processus itératifs comme des structures de données « statiques »,
par exemple la liste infinie de nombres [0,1,2,...] peut être construite comme

integs = intgs 0 where


intgs n = n : intgs (n+1) 22
Notez la récursivité sans la clause terminale. Les fonctionnelles classiques comme
map ou zipWith :

map f [] = []
map f (x:xq) = f x : map f xq

zipWith op [] [] = []
zipWith op (x:xq) (y:yq) = (x ‘op‘ y) : zipWith op xq yq

marchent bien avec les listes infinies (ce sont des algorithmes co-récursifs), et
peuvent être utilisées pour une autre construction de la liste des entiers :

uns = 1 : uns -- [1,1,1,1,. . . ]


integs = 0 : (zipWith (+) uns integs) 23

il faudra donc s’habituer à un autre usage de la récursivité que l’on voit en Deug
et en Licence. Pour nous ce n’est pas la possibilité de faire des listes de longueur
∞ qui présente l’intérêt : listes paresseuses représentent des processus. Aussi,
ce protocole permet de référencer dans le programme les structures de données
qui n’ont pas encore été construites ; ceci facilite la compilation des boucles.

Continuations

Cette notion constitue la base du pont entre la programmation fonctionnelle et


impérative – elle permet de définir la sémantique de l’instruction de branchement
(goto). En général, la continuation représente le « futur » d’une évaluation dans le
programme, elle répond la question et, ensuite, on fait quoi?

Andrew Appel a écrit un livre entier consacré à l’usage des continuations dans la
compilation. Nous les verrons dans la construction de nos machines virtuelles, et
de nos parseurs. Mais aussi comme un outil permettant de « linéariser » le code,
de le séquencer ou sérialiser. 24
Faisons un exemple concret. Une expression « normale », p. ex. numérique, sera
transforme en « expression continuée », qui est une fonction d’un paramètre spé-
cial – la continuation (au sens spécifique). Cette continuation est une fonction qui
récupère la valeur de la première expression , et qui en fait quelque chose. (Par
exemple, transforme-la, et passe le résultat à sa continuation).

Nous pouvons « lifter » un objet atomique, une constante ou une variable dans le
monde des continuations par le combinateur suivant, qui est une « application à
l’envers » :

lift x cnt = cnt x


-- = id cnt x = flip id x cnt ⇓
lift = flip id 25

et le programme converti utilisera de manière conséquente (lift x) à la place


du x. Une fonction qui normalement prend un argument : f x = ... est liftée
comme suit, elle prendra deux arguments, un x (non lifté), et la continuation :

lift1 f x cnt = cnt (f x) = lift (f x) cnt ⇓


lift1 f x = lift (f x) = (lift . f) x
lift1 f = lift . f
lift1 = (.) lift

etc. On peut jouer avec de telles transformations assez longtemps. Ce qui compte
c’est l’universalité et la lisibilité (régularité ; intuition). . . Il faudra lifter aussi des
opérateurs binaires

lift2 op x y = lift (op x y) = (lift . (op x)) y


lift2 op x = (.) lift (op x) = (((.) lift) . op) x
lift2 op = ((.) lift) . op
lift2 = (.) ((.) lift) = ((lift .) .) 26

Supposons que l’on veut évaluer l’expression x2 + y 2, ou ff = sqrt (x*x
+ y*y). C’est une arborescence, une structure syntaxique en forme de graphe,
qui aura besoin de représenter les opérateurs binaires :

∗ ∗

x x y y

Il faut lifter les opérateurs arithmétiques ; supposons que ceci a été fait, et nous
avons les fonctions add x y cnt, mul et csqrt qui prennent l’argument sup-
plémentaire. 27

Supposons aussi que les args d’origine ont déjà été liftés à xc et yc. Introduisons
un opérateur standard, prédéfini de Haskell : ($) qui est l’application : f $ x =
f x, mais il est de très faible précédence, et associatif à droite (f $ g $ x =
f $ (g $ x)). Ceci a pour but éliminer trop de parenthèses. La formule ff est
liftée de manière suivante :

ffc cnt = xc $ \x ->


yc $ \y ->
mul x x $ \a ->
mul y y $ \b ->
add a b $ \c ->
csqrt c $ cnt

Notez que cette forme n’est pas loin du code assembleur d’une machine à re-
gistres ! Le code a été linéarisé, et les arguments des opérations primitives ne sont
plus imbriqués. Question : comment faire de telles transformations automatique-
ment sera abordée plus tard. 28
Construction d’un simple interprète
Voici une approche de haut niveau à l’exécution d’un code arithmétique, p. ex. f
= 2.3*(1.5 - 1.1 + (2.4 -1.0)/4.3) - 2.4 ce qui correspond à

∗ 2.4

2.3 +

− /

1.5 1.1 − 4.3

2.4 1.0

Construisons une donnée qui correspond à cette expression, et donnons lui une
sémantique par intermédiaire d’un algorithme d’évaluation. 29

Un élément de cet arbre peut être une feuille (donnée primitive, nombre), ou un
noeud intermédiaire, l’application d’un opérateur binaire. Définissons donc

data Node = F Double | N OpN Node Node

Qu’est-ce qu’un opérateur OpN? Si nous voulons séparer complètement la forme


source et l’intérieur du système Haskell, le meux serait de considérer l’opérateur
comme un nom : entité symbolique :

type OpN = String -- [Char]

L’algorithme d’évaluation se réduit à la « recette de cuisine »: si c’est une feuille,


rend sa valeur, sinon évalue les branches, et applique l’opérateur.

eval (F x) = x
eval (N opn g d) = let fct = findop opn
in fct (eval g) (eval d) 30
Il faut compléter la « machine » par un décodeur des opérateurs. Supposons avoir
défini une liste associative

listop = [("+",(+)),("-",(-)),("*",(*)),("/",(/)),
("sqr2",(\x y -> x*x+y*y))]

etc. La recherche séquentielle, simple (mais inefficace !) est définie par

findop opn = fnd listop where


fnd [] = error "Opérateur inexistant"
fnd ((x,f):lq) | opn==x = f
| otherwise = fnd lq

Mais si nous décodons un op. symbolique, notre calculatrice peut aussi opérer
avec des variables symboliques, x,alpha, etc. Il suffit d’ajouter un environne-
ment, une liste où on met des associations : [("x",2.3),("alpha",1.1),...].
31

Ceci demande tout de même une généralisation de la structure de l’interprète :

data Leaf = F Double | V String


data Node = L Leaf | N OpN Node Node

eval (L a) = case a of
(F x) -> x
(V s) -> findvar s -- dans : environment
...

Plusieurs questions se posent à présent.

1. Comment généraliser l’interprète à des opérateurs unaires, ternaires, etc.?


2. Comment permettre la co-existence de plusieurs types (entiers et flottants,
etc.)?
3. Comment introduire des structures de contrôle, notamment la conditionnelle
if-then-else? 32
4. Comment permettre la définition des fonctions utilisateur?
5. Comment éviter trop d’inefficacité, le décodage multiple de la même chose?
6. Comment éviter l’usage très intense de la pile (ce qui risque même de débor-
der) pour des expressions compliquées?
7. Comment permettre la définition des constructions impératives, notamment
l’affectation d’une variable?
8. . . . et les boucles?
9. Comment contrôler les erreurs d’exécution (débordement numérique, variable
non-définie, etc.)?
10. Comment pouvoir signaler les erreurs de structure (mauvaise expression ;
arité incongrue d’un opérateur, arguments manquants, etc.) avant de lancer
l’exécution?
11. Comment gérer les instructions d’entrée et de sortie?

. . . et encore plusieurs autres. 33

Développement de l’interprète

Pour l’instant nous allons garder seulement les nombres flottants, mais nous al-
lons enrichir la gamme des opérateurs. Il faudra répondre aussi – plus tard, car
c’est la tâche du parseur (analyseur syntaxique) – comment transformer la forme
linéaire de la source f = 2.3*(1.5 - 1.1 + (2.4 -1.0)/4.3) - 2.4 en
notre graphe arborescent.

(D’ailleurs, pour tester, il faut aussi savoir afficher une arborescence (trouver sa
représentation extérieure), à l’aide de l’instance de la classe Show correspondante,
mais les détails de ce genre ne seront pas présentés ici. Rappelons seulement
que l’affichage se réduit à l’aplâtissement de l’arbre dans l’ordre infixe (branche
gauche – opérateur – branche droite), et que les optimisations visant à éliminer les
concaténations en cascade sont de rigueur.) 34
Arités différentes

Il ne semble utile – dans ce modèle simpliste – de traiter tous les cas différents
séparemment. La solution minimale (et inefficace) consiste à passer à tous les
opérateurs la liste des arguments. On définit les ops, on les place dans listop :
[("*",mul),...], etc., et on modifie l’interprète.

mul [x,y] = x*y


add [x,y] = x+y
msqrt [x] = sqrt x
ifelse [cond,exprT,exprE] = ...

data Node = L Leaf | N OpN [Node]


eval (N opn lst) = let fct = findop opn
larg = map eval lst
in fct larg 35

La conditionnelle

Le problème principal de l’expression

if cond then exprT else exprE

n’est pas sa syntaxe ; on aurait pu écrire ifelse(cond,exprT,exprE) sous


forme fonctionnelle à 3 arguments.

Mais ifelse n’est pas une fonction normale ! On n’a pas le droit d’évaluer cond,
exprT et exprE avant de prendre une décision quelconque. Si cond est vrai,
ifelse n’a pas besoin de exprE, et son évaluation ne doit jamais avoir lieu (et
vice-versa ).

Donc, la forme ifelse peut être assimilé à une fonction paresseuse, et implé-
mentée comme telle. 36
Il faut alors modifier l’interprète afin d’empêcher l’évaluation de la liste des ar-
guments avant de passer le contrôle à l’opérateur. On peut en principe passer à
chaque opérateur la liste des arguments non-évalués et lui dire : débrouille toi.
Cependant, tous les opérateurs normaux lanceront eval. . .

Il faut donc signaler à la machine qu’un opérateur est une forme spéciale, qui
reçoit ses arguments non-évalués, et qui lance l’évaluation des sous-expressions
en sa gestion selon son avis.

(D’ailleurs, un petit problème psychologique se pose : et si on voulait faire notre


interprète en Scheme ou C++ qui n’ont pas de fonctions paresseuses, comment
procéder? Vous aurez la réponse plus tard).

mais indépendamment de tout cela, ici nous avons nos premiers sacrés pro-
blèmes de typage ! 37

D’abord, on peut en principe définir les opérateurs relationnels comme lth [x,y]
= x<y, associé avec "<". Mais on ne peut pas l’insérer dans listop, car une liste
en Haskell est homogène, et les autres paires associatives contiennent des fonc-
tions qui retournent des nombres. Ici le résultat est Booléen.

Ensuite, une fonction spéciale, disons ifelse, qui reçoit ses arguments sans éva-
luation, donc une liste de Nodes viole également le typage. Si nous avions utilisé
Scheme pour nos exercices, la vie serait plus douce. Mais le débogage pire. . . Un
programme bête ne se compilera pas. Donc, encore un

Conseil : Durant l’examen les erreurs de typage bêtes et évidentes sont considé-
rées comme des fautes graves, signalant que vous n’avez pas compris le sens de
vos expressions.

Nous allons revoir notre domaine du calcul, enrichir l’ensemble des types, ce qui
accessoirement nous permettra de définir les conditionnelles de manière séman-
tiquement propre, et sans tricher avec « pointeurs sur n’importe quoi », tous les
objets seront typés statiquement. 38
D’abord, définissons un type universel de valeurs, balisé. Ceci peut être

data Value = D Double | I Integer | S String | B Bool


| E Expr -- etc.

où Expr est notre code, appelé Node auparavant :

data Expr = C Value | V String | N OpN [Expr]

(pour éviter des balises multiples). La fonction eval aura le type : eval :: Expr
-> Value. Nous pouvons définir à présent les opérateurs connus et autres :

add [D x,D,y] = D (x+y)


...
lth [D x,D y] = B (x<y)
lor [B x,B y] = B (x || y) 39

Le décodage des opérateurs doit être légèrement modifié pour faciliter la distinc-
tion entre les opérateurs normaux et les fonctions (formes) spéciales. Gardons
OpN comme le nom de l’opérateur, mais lui-même sera une entité plus complexe,
un descripteur contenant le nom, l’attribut faux pour les formes spéciales (et vrai
pour les autres), et la fonction qui le réalise :

data Op = Op OpN Bool ([Expr]->Value)

Les formes spéciales seront du même type, mais leurs arguments seront des ex-
pressions converties formellement en valeurs. La liste des opérateurs peut être :

listop = [Op "+" True add,Op "<" True lth,Op "sqrt" True sqrt,
Op "if" False ifelse] -- ... etc.

Le décodage et la fonction eval : 40


findop opn = fnd listop where
fnd [] = error "Operateur inexistant"
fnd (Op nom nrml fct:xq) | nom==opn = (nrml,fct)
| otherwise = fnd xq

eval c@(D _) = c
eval (V s) = findvar s
eval (N opn lst) =
let (nrml,fct)=findop opn
in fct (if nrml then (map eval lst) else lst)

Il nous manque encore la forme conditionnelle ifelse

ifelse [cnd,expT,expE] = case (eval cnd) of


B True -> eval expT
B False -> eval expE
_ -> error "Conditionnelle pourrie..." 41

On voit clairement un apparent regressus ad infinitum : comment réellement im-


plémenter la conditionnelle? Le if dans notre code source appelle ifelse – la
conditionnelle en Haskell (peu importe la syntaxe, if, case, ou clauses (|). . .

Sans doute la conditionnelle en GHCi sera exécutée comme une conditionnelle en


“C” (langage d’implémentation). Celle-là – comme le branchement conditionnel
en assembleur. Qui, et comment, prend enfin une décision? Comment réaliser le
if sans avoir un if du niveau plus bas?

La solution de ce dilemme existe, mais en dehors d’un simple typage fort. Une
valeur Booléenne transformée en nombre entier (p. ex. 0 ou 1) peut être ajoutée
à l’adresse/indice dans la mémoire qui pointe sur l’expression suivante à évaluer.
Ainsi on « saute » l’expression qui doit rester passive, et on exécute celle qui a été
prévue. Mais l’arithmétique des pointeurs devient inéluctable ! 42
Fonctions utilisateur

Sans la possibilité de définir nos propres fonctions, notre machine n’est qu’une
calculatrice intéractive. On peut supposer que pour avoir une puissance calcula-
toire plus grande il faut d’abord penser à introduire les variables locales, et ainsi
partager les valeurs, mais rappelons nous que le vieux calcul lambda (la possibilité
de créer des fonctions anonymes) remplace souvent les variables. La définition

sinh x = let w=exp x


in 0.5*(w-1.0/w) -- ou :
sinh x = 0.5*(w-1.0/w) where w=exp x

peut être aussi défini comme

sinh x = (\w -> 0.5*(w-1.0/w)) (exp x)

donc les lambdas remplacent le let (main non pas les let explicitement récur-
sifs : let x = g ... x ... in ... 43

Nous allons donc augmenter la puissance de notre interprète par l’ajout des objets
fonctionnels. Il faudra traiter

• La représentation de tels objets : constructions des structures de données qui


représentent un code « paramétrable ».
• L’usage des fonctions utilisateur dans le code principal :
– Leurs association avec les noms et l’extension du mécanisme de recherche ;
– Le passage des arguments, et l’évaluation du code qui contient les fonc-
tions utilisateur.
• Gestion de la récursivité. (Est-ce un problème?. . . )

Nous verrons en tout cas qu’il est préférable de ne pas utiliser les environnements
(listes associatives) globaux, mais de les passer explicitement à la procédure de
recherche, car elles peuvent dynamiquement changer pendant l’exécution du pro-
gramme. 44
Il nous faudra répondre aux questions suivantes :

• Comment représenter une fonction utilisateur?


• Comment abstraire les propriétés communes des opérateurs primitifs, et fonc-
tions utilisateur? (Il faut avoir une procédure de recherche commune).
• Comment modifier l’interprète?

Ceci introduira une légère complication, le balisage des opérateurs (les primitifs
seuls avaient le même type et n’en avaient pas besoin).

type Var=String
type Envir = [(String,Value)]
data Operator = Pr ([Value] -> Value)
| Sp (Envir -> [Expr] -> Value)
| Lam [Var] Expr 45

La forme Lam ... représente la fonction utilisateur. Elle contient la liste des
paramètres (variables ; identificateurs), et une expression qui doit être évaluée,
avec les paramètres liés aux valeurs correspondantes.

Un problème essentiel se pose : les formes spéciales comme ifelse (balisées par
Sp) qui agissent sur les expressions et non pas sur les valeurs, doivent avoir accès
à l’environnement actuel. Les opérateurs normaux – jamais.

Pour éviter la confusion, construisons la machine complète, avec des commen-


taires. Commençons par le type des valeurs hétérogènes, les expressions, et les
opérateurs :

data Value = D Double | I Integer | S String | B Bool


| E Expr deriving (Eq,Show) -- etc.

data Expr = C Value | V String | N OpN [Expr] deriving (Eq,Show)


type OpN = String 46
type Var=String
type Envir = [(String,Value)]
data Operator = Pr ([Value] -> Value)
| Sp (Envir -> [Expr] -> Value)
| Lam [Var] Expr

La clause deriving (...,...) est une simplification de la vie du program-


meur. Pour des structures explicites, balisées, assemblées depuis des éléments
également explicits, le compilateur peut déduire seul comment définir l’égalité
(les mêmes balises, et le même contenu, récursivement), ainsi que trouver la re-
présentation textuelle par défaut (intuitive). Définissons quelques opérateurs

mul [D x,D y] = D (x*y) -- etc.

sqrl [D x] = D(sqrt x) 47

lth [D x,D y] = B(x<y)


lor [B x,B y] = B (x || y)

cube = Lam ["x"] (N "*" [V "x",(N "*" [V "x",V "x"])] )

ifelse env [c,t,e] =


let b = eval env c
in eval env (if b==B True then t else e)

Les associations, et les fonctions de recherche :

listop = [("+",Pr add),("-",Pr sub),("*",Pr mul),("/",Pr dvd),


("<",Pr lth),(">",Pr gth),("sqrt",Pr sqrl),
("sqr",Pr (\[D x] -> D(x*x))), ("cube",cube),
("or",Pr lor),("not",Pr lnot),("if",Sp ifelse)] 48
findop lop op = fnd lop where
fnd [] = error "Opérateur manquant"
fnd ((x,f):lq)
| op==x = f
| otherwise = fnd lq

envir = [("x",D 2.4),("y",D 4.3),("z",D 1.1),("alpha",D 3.1416)]


findvar env v = fnd env where
fnd [] = error "Variable inexistante"
fnd ((x,n):lq) | v==x = n
| otherwise = fnd lq

Pour évaluer Lam ["x"] ... appliqué à, disons [D 2.5] il faut augmenter
l’environnement par l’association ("x", D2.5) et évaluer le corps de la forme
lambda. Ensuite il faut « libérer » "x". 49

Voici donc la fonction qui ajoute des nouvelles liaisons à l’environnement, et


l’évaluateur :

addvars xs vals env = zip xs vals ++ env

eval envir (C a) = a
eval envir (V a) = findvar envir a
eval envir (N op lst) =
let fct = findop listop op
in case fct of
Pr prim -> prim (map (eval envir) lst)
Sp prim -> prim envir lst
Lam vrs xpr -> let args = map (eval envir) lst
nenv = addvars vrs args envir
in eval nenv xpr 50
L’exécution :

ff3 = N "if" [N "<" [N "-" [V "x",V "z"], C (D 0.0)],


N "sqr" [V "z"],
N "cube" [V "y"] ]
res3 = eval envir ff3

donne 79.5067 sans problèmes.

On voit dans la solution proposée quelques redondances et beaucoup d’ineffica-


cité. Nous n’allons pas corriger tous les détails, car il faut passer à un modèle plus
efficace et plus professionnel.. 51

De l’interprète vers le compilateur


Code postfixe et évaluation partielle

Rappelons-nous de l’expression en forme arborescente :

∗ 2.4

2.3 +

− /

1.5 1.1 − 4.3

2.4 1.0

L’interprétation consistait à appliquer récursivement l’évaluation aux branches, et


ensuite réduire le résultat en applicant l’opérateur sur la racine. 52
L’usage de mémoire est lourd. Cette machine virtuelle est « trop intelligente », ce
qui demande des ressources importantes au niveau implémentation.

Les vraies, professionnelles machines virtuelles font minimum des choses. Le pro-
gramme interprété doit être stocké sous une forme qui facilite son évaluation sans
trop de surcharge. Or, on connaît le sens sémantique du protocole récursif men-
tionné. L’appel récursif de la fonction eval consiste à stocker temporairement sur
une pile l’état actuel, les valeurs des variables locales, les arguments, etc., et à
redémarrer la fonction. En retournant on dépile l’état sauvegardé.

Ici la seule chose qui doit être empilé ce sont les résultats intermédiaires : il faut
préserver la valeur obtenue par l’évaluation de la branche gauche pendant l’éva-
luation de la branche droite. Nous pouvons effectuer ce stockage nous mêmes, sur
une pile gérée par nos programmes, sans engager la pile système sous le contrôle
du run-time de Haskell (ou autre langage d’implémentation).

On empile 2.3, ensuite il faudra empiler 1.5 et 1.1 et réduire le résultat par la
soustraction avant de continuer. 53

Le code linéaire qui ralise la totalité des calculs est le code postfixe, avec les
arguments qui précèdent l’opérateur. On n’a pas besoin de parenthèses si chaque
opérateur « sait » combien d’arguments sur la pile lui appartiennent.

Voici le code (structurel) qui correspond à l’arborescence ci-dessus.

2.3 1.5 1.1 − 2.4 1.0 − 4.3 / + ∗ 2.4 −

Intuitivement, l’exécution d’un tel programme se réduit à la séquence (boucle)


d’opérations : Si l’objet actuel est une donnée, il faut l’empiler. Si c’est un opéra-
teur, on dépile un certain nombre de données depuis la pile (en les stockant dans
les registres fixes, temporaires), on applique l’opérateur, et on empile le résultat.
ensuite on boucle, jusqu’à la fin du programme.

Il est inutile de donner plus de détails, ce n’est qu’une calculatrice : il nous faudra
traiter les conditionnelles, les fonctions utilisateur, etc. 54
Alors, on jète l’interprète précédent à la poubelle?

Non ! Il constitue un très bon modèle conceptuel qui transforme l’expression-


arbre en code postfixe. Brièvement, on parcourt l’arbre en l’ordre « postfixe »
(branche, branche, racine), et on l’aplâtit. C’est justement le flot de contrôle de
notre interprète : si l’objet est une donnée (feuille) on l’« évalue » symbolique-
ment, en le transportant (« compilant ») sur le flux de sortie, le programme « com-
pilé ». Si c’est un noeud intermédiaire, on compile la branche gauche, ensuite la
branche droite (si elle existe), et on termine par la compilation, la sortie de l’opé-
rateur sur la racine. C’est tout.

Alors, en changant l’évaluation en évaluation « partielle » qui ne va pas jusqu’au


bout, on a fait un compilateur de codes postfixes. Concrètement ce n’est pas encore
le compilateur complet, mais un générateur du code (ou transformateur du code,
si on considère que l’arbre syntaxique en est également une instance). Il nous
manque encore l’analyseur syntaxique. . . 55

(D’ailleurs, on peut aller jusqu’au bout aussi, évaluer toutes les expressions constantes,
mais différer tout ce qui a besoin de l’environnement. Cet environnement peut
rester, et d’habitude reste inconnu pendant la compilation. Ceci est une des tech-
niques fondamentales d’optimisation.)

Le modèle d’exécution (la machine à pile) est si simple, qu’elle constitue la


base des vraies machines virtuelles dans le monde professionnel : Java, Python,
FORTH, PostScript, etc. Nous allons à présent consacrer tout notre effort à la
construction d’une machine relativement complète, capable d’effectuer des cal-
culs non-triviaux. La linéarisation du code arborescent sera abordée plus tard,
mais souvent les parseurs génèrent le code postfixe sans passer par l’étape inter-
médiaire. 56
Machine virtuelle à pile
Dans ce modèle tous les opérateurs primitifs (et par conséquence les autres aussi)
prennent les arguments sur une pile de données qui sera implémentée comme
une liste, même si l’usage des tableaux dans un langage impératif peut être plus
commode.

Le « programme » est une suite (peut être également une liste) contenant des
instructions. Ici le problème de typage est importants aussi : comment distinguer
une donnée d’un opérateur? Nous allons résoudre le dilemme de manière militaire.
Il n’y a que des instructions (commandes) ; pas de données dans le programme.
S’il faut coder l’usage d’une constante, elle sera « cachée » dans une instruction
d’empilement, et en fera partie.

Pas besoin de distinguer entre les opérateurs unaires, binaires, etc. Chaque instruc-
tion exécutable connaît le nombre de ses arguments. (Et c’est la tâche du généra-
teur du code de ne pas permettre que la pile de données contienne une séquence
illégale d’arguments. . . ) 57

Le/les resultat(s) sont toujours déposés sur la pile de données. Un programme qui
attend sur la pile un nombre et qui calcule le sinus hyperbolique pourrait avoir la
forme :

prg = [exp,dup,dld 1.0,exch,dvd,sub,dld 0.5,mul]

où exp calcule la fonction exponentielle de la valeur sur la pile, dup duplique le


dernier élément sur la pile, passe de [x,y,...] à [x,x,y,...], exch change
l’ordre des deux derniers éléménts : [x,y,...] → [y,x,...], sub c’est la
soustraction, mul – multiplication, etc.

Notez que l’exemple ci-dessus est « manuel » au sens d’avoir effectué manuelle-
ment l’optimisation : sh(x) = (exp(x)−exp(−x))/2 appelle l’exponentielle
une seule fois, et sauvegarde le résultat intermédiaire. Plusieurs autres exemples
à présent seront écrits dans ce style, comme si nous voulions faire de la program-
mation en FORTH ou en PostScript. 58
Si on « oublie » le typage polymorphe, on peut envisager les définitions sui-
vantes, où chaque fonction est de type DStack -> DStack, et type DStack
= [Double] :

dup s@(x:_) = x:s


sub (x:y:s) = (y-x):s

etc. Notez aussi la présence des instructions parametrées : dld 0.5 (double-load)
est une forme fonctionnelle en Haskell – une expression dont la valeur est un objet
fonctionnnel (une fermeture).

(dld c) s = c:s -- Parenthèses redondantes

Est-ce réalisable en C++? (En Scheme facilement, nous y avons des fermetures.)
Oui, mais il faut combiner. Une instruction sans paramètres, comme sub est (peut
être) un pointeur vers la fonction qui manipule la pile, qui peut être considérée
comme un objet global, partagé. 59

L’instruction parametrée est une paire (ou un pointeur vers une paire) : pointeur
vers une fonction load générique, partagée, et la constante elle même ; tout est
préparé par le générateur du code.

L’interprète appelle load et lui passe la constante, c’est tout. Le loader load
empile la constante. Bien sûr, il faut utiliser le balisage, la conversion (casting)
des types, etc., mais tout est (relativement) facilement faisable.

En Haskell, tous les éléménts de la liste-programme possèdent le même type. La


machine peut être codée comme suit :

eval dstack (instr:crest) =


let nstack = instr dstack
in eval nstack crest
eval st [] = st

C’est une simple boucle, aucune « intelligence ». Le traitement des données est
distribué, et pris en charge par les instructions. 60
Ce n’est qu’une calculatrice. Pas de conditionnelles, pas de fonctions utilisateur
(et pas de variables/environnements non plus, mais ceci sera traité un peu plus
tard). On peut imaginer qu’une fonction utilisateur soit réalisée de manière sui-
vante :

cube st = eval st [dup,dup,mul,mul]

ce qui permet l’écriture des programmes comme [dld 8.0,dld 2.5,cube,


dld 1.0, sub] etc., mais la solution souffre de la même défaillance qu’aupara-
vant : la lourdeur. Si toute fonction utilisateur a besoin de l’appel récursif de eval,
l’engagement du run-time de Haskell reste trop important. L’interprète n’est pas
une simple boucle, mais une fonction universelle, onéreuse à coder p. ex. en as-
sembleur. 61

Nous pouvons y rémedier en introduisant une pile des retours explicite. Cette pile
servira à stocker le code restant à exécuter, quand le flot séquentiel est interrompu
par un appel de la procédure utilisateur.

Un appel empile le reste du code, remplace le paramètre code de eval par le code
de la procédure, et boucle normalement. le retour – oublie le reste du code, et
dépile le reste à exécuter depuis la pile des retours.

On passe aux détails, mais avec une modification très profonde de l’interprète :
s’il redevient une simple boucle, peut-être nous pouvons l’éliminer complètement?
Le modèle suivant est distribuée : chaque instruction gère le séquencement à sa
manière ! Elle reçoit en paramètres la pile des données, la pile des retours, et le
code restant. Ceci est plus proche d’une réalisation matérielle d’un noyau exécu-
table que le modèle d’interprète classique. Peut-être très légèrement plus gour-
mand en mémoire, car les procédures deviennent un peu plus complexes, mais
plus rapide aussi. 62
Voici le modèle. Nous profitons de cette occasion pour introduire le typage sur-
chargé, et pour le faire de manière professionnelle. Si nos valeurs sont des entiers,
flottants, chaînes, paires, listes, etc. balisés, il serait bien de pouvoir définir des
opérations « standard » pour ces types. Quelques unes peuvent résulter de l’usage
de la clause deriving, mais nous exploiterons – de plus en plus fréquemment – les
classes de Haskell. Voici le début du paquetage :

data Value = Fail String | I Integer | F Double | S String


| B Bool | C Code | L [Value] | P (Value,Value)
| R Rstack -- deriving Eq

instance Eq Value where


F x == F y = x==y
B x == B y = x==y
I x == I y = x==y
63

S x == S y = x==y
L x == L y = x==y
P x == P y = x==y
_ == _ = False

instance Ord Value where


F x < F y = x<y
B x < B y = x<y
I x < I y = x<y
S x < S y = x<y
P x < P y = x<y
_ < _ = False
F x > F y = x>y
-- etc. 64
où, accessoirement nous avons ajouté une valeur : « échec ». Ceci sera utile plus
tard. Nous pouvons également préciser quelques fonctions d’affichage, et l’arith-
métique standard :

instance Show Value where


showsPrec _ (I n) = shows n
showsPrec _ (F n) = shows n
-- etc.
showsPrec _ (C cod) = showString "<<CODE>>"
showsPrec _ (Fail s)= shows ("Fail: " ++ s)

instance Num Value where


F x + F y = F (x+y)
I x + I y = I (x+y)
F x + I y = F (x + fromInteger y)
-- etc. 65

instance Fractional Value where


F x / F y = if y==0.0 then Fail "/0" else F (x/y)
F x / I y = if y==0 then Fail "/0" else F (x/fromInteger y)
recip (F x) = if x==0.0 then Fail "/0" else F (recip x)

fromRational x = F (fromRational x)

Attention. Si pour quelques uns les classes de types en Haskell restent inconnues
– posez des questions ! nous aurons besoin de classes et des instances jusqu’à la
fin de ce cours ! Il vous faudra apprivoiser la classe Show relativement bien.

Pour varier un peu, le code ne sera pas une liste, mais une séquence définie (et
construite) par nous.

type Dstack = [Value] -- Pile des args


type Rstack = [Code] -- Pile des retours 66
infixr 5 :>, +>
data Code = Empty | CodeItem :> Code
type CodeItem = Dstack -> Code -> Rstack -> Dstack
Empty +> q = q -- Concaténation spéciale
(a:>aq) +> q = a :> (aq+>q)
lcode = foldr (:>) Empty -- liste → code
endprog = stop :> Empty

La dernière ligne est un garde-fous : le programme s’arrêtera correctement même


si on essaie de sortir par ret du programme principal. En général, on ne vérifie
pas si le code est vide, mais on exécute une isntruction spéciale stop, qui arrête
la chaîne d’exécution. Voici quelques instructions de la machine.

start (instr:>prog) = instr [] prog ([endprog])


stop stk _ _ = stk
ret stk _ ((instr:>code):rstk) = instr stk code rstk 67

call (instr:>proc) stk code rstk = instr stk proc (code:rstk)


jmp (instr:>proc) stk _ = instr stk proc

Ce sont des instructions de contrôle spécifique. La plupart des autres manipule


seulement les valeurs sur la pile des données, et la gestion de la pile des retours
est simple, standardisée. Commençons par une procédure universelle stkop qui
fait quelque chose (paramètrable) avec la pile et qui gère le flot de contrôle de
manière standard.

stkop op stk (instr:>code) rstack


= case stk of
(Fail : ) -> instr stk code rstack
_ -> instr (op stk) code rstack
op2 op = stkop (\(x:y:stk) -> y ‘op‘ x : stk)
68
et, à présent, une multitude d’opérateurs primitifs concrets

pass = stkop id -- ne rien faire. . .


dup = stkop (\s@(x:stk) -> x:s)
pop = stkop (\(_:stk) -> stk)
exch = stkop (\(x:y:stk) -> y:x:stk)
param n = stkop (\stk -> stk!!n : stk)

add = op2 (+)


dvd = op2 (/) -- etc.
relop op = stkop (\(x:y:stk) -> B (y ‘op‘ x) : stk)
eq = relop (==); ne = relop (/=); gt = relop (>)
bnot = stkop (\(B b : stk) -> B (not b) : stk)
ld c = stkop (c :)
tld t c = ld (t c)
fld = tld F -- etc. 69

Notez que nous avons éliminé (temporairement) l’environnement, donc, la ma-


chine opère seulement avec des constantes. Mais son réinsertion est assez triviale,
il faut ajouter encore un paramètre aux instructions. Sa présence n’apporte rien de
nouveau (mais peut être utile lors de l’examen. . . ).

Une autre imperfection est critique : nous avons encore besoin de conditionnelles.
Les fonctions utilisateur sont là, il suffit d’écrire

cube = lcode [dup,dup,mul,mul,ret]

et de l’appeler par call, mais les mécanismes décisionnels demandent une « ges-
tion paresseuse ». Faudra-t-il de nouveau lancer l’interprète récursivement de l’in-
térieur des instructions? Nous voulons éviter cela. Il existe plusieurs modèles de
conditionnelles, FORTH et PostScript gèrent cela différemment. 70
On passera tout à l’heure aux nouveaux mécanismes de contrôle (décisionnels),
mais avant il faut traiter un peu plus sérieusement (mais toujours superficielle-
ment) les appels des fonctions utilisateur, ainsi que les retours.

La définition du cube, la définition « manuelle » de la factorielle, l’usage des


fonctions primitives dup, exch, pop, etc., c’est du « bidouillage » : le codage
peut être fait par un être humain, mais il est trop irrégulier pour être construit
automatiquement par une machine de compilation. Si nous voulons garder et la
machine à pile et le compilateur relativement simples, il faut enrichir un peu la
structure du système.

On peut introduire une fonction utile qui accède à un objet quelconque sur la pile
des données, un objet dont la profondeur est arbitraire. Appelons cette fonction
index n, où n est l’indice de l’objet qui sera récupéré et placé sur le sommet de
la pile. index 0 est équivalent à dup. 71

Une fonction utilisateur « régulière » sans aucune optimisation doit accéder de


cette manière à tous les éléments sur la pile. Rien n’est détruit. Par exemple, la
fonction cube x = (x*x)*x en postfixe devient [x,x,*,x,*], ou :

cube = lcode [index 0,index 1,mul,index 1,mul,ret]

Mais – une petite catastrophe. . . on n’a jamais dépilé l’argument original, et la


pile contient x ainsi que son cube !

Il faut compiler un mécanisme simple et automatique de nettoyage de la pile. Si on


charge ou on calcule un argument, et on le place sur la pile, donc, si le compilateur
sait que la fonction appelée prend un argument, après le retour il faut mettre le
résultat dans un registre protégé, et éliminer tout ce qui a été empilé. 72
Cette élimination peut être faite juste après le retour du code appelé, ou avant. En
Pascal la fonction appelante gère le nettoyage de la pile. En C, c’est la fonction
appelée. Les appels en Pascal sont plus rapides, en C – plus souples, facilitant
l’écriture des procédures qui prennent un nombre arbitraire de paramètres. . . Dans
notre modèle, nous implémentons une primitive d’appel étendue, parametrée en
plus par le nombre d’arguments passés à la procédure.

callx n (instr:>proc) stk code rstk =


instr stk proc ( (dep n):code) : rstk)

où dep n est l’instruction qui dépile n arguments de la pile, mais en préservant le


dernier, le résultat. Elle peut être définie comme

dep n (res:stk) (instr:>code) = -- rstk omis


let nstk = res : drop n stk
in instr nstk code 73

Conseil. Si vous ne connaissez toujours pas les fonctions standard take et drop
de Haskell (on l’a constaté en TD. . . ), vous risquez avoir des problèmes. Lisez la
doc de Haskell ! En tout cas :

take 0 l = []
take _ [] = []
take n (x:xs) = x : take (n-1) xs

drop 0 l = l
drop _ [] = []
drop n (_:xs) = drop (n-1) xs

La fonction take prend n premiers éléments d’une liste, et drop rejète le préfixe
de longueur n. 74
Nouveaux mécanismes décisionnels

Modèle PostScript

. . . celui vu en TD. Le type Value possède le variant C Code, permettant de


mettre sur la pile des données des morceaux de code, ou procédures – ces objets
correspondent aussi aux étiquettes dans d’autres langages. La séquence

... :> cond :> cld <proc_T> :> cld <proc_E> ifelse ...

où cld pr = ld (C pr), évalue la condition, laissant sur la pile le résultat, en


ensuite empile les deux procédures. L’instruction ifelse dépile les trois objets,
et appelle une de deux procédures selon la valeur de la condition. On peut donc
coder ifelse comme suit : 75

exec stk (instr : code) = instr stk code

ifelse (C els : C the : B cnd : stk) code rstk =


let nrstk = code : rstk
if cnd then (exec st

k the nrstk) else (exec stk els nrstk)


-- ou : exec stk (if cnd then the else els) nrstk

L’avantage de ce modèle est la simplicité extrême du codage. Le handicap : effi-


cacité. Un appel procédural avec la sauvegarde sur la pile de l’adresse du retour,
etc., n’est pas gratuit. Nous allons proposer le modèle plus direct (similaire au
FORTH), typique dans le monde de machines à pile impératives. Haskell ici ser-
vira seulement pour simplifier le codage. 76
Branchements conditionnels

Introduisons alors le saut conditionnel qui généralise l’instruction jmp. Rappelons


l’original, et ajoutons la composante décisionnelle :

jmp (instr:>ccode) stk _ = instr stk ccode

ifjmp (instr:>ccode) (B cnd : stk) (nxt:>ncode)


| cnd = instr stk ccode
| otherwise = nxt stk ncode

On peut aussi définir ifNjmp qui prend la décision contraire. Le codage manuel
des procédures faites de cette façon risque d’être très pénible, les branchements
ne sont pas des instructions très structurées. Mais si le code est généré de manière
automatique (ou sémi-automatique) à partir des instructions structurées, comme
if-then-else, alors la vie est plus douce. 77

Considérons donc que quelqu’un nous a déjà compilé (comme en cas de Post-
Script) les codes « then »: the et « else »: els. Ce sont des fragments du code
« purs », et non pas des procédures, avec un obligatoire ret à la fin !!

Voici le codage de la fonction ifelse dans ce contexte.

ifelse cnd the els continue =


let othwise = els +> continue
in cnd +> (ifNjmp othwise :> the)
+> (jmp continue :> othwise)

Ne nous trompons pas : la fonction ifelse n’est pas une instruction de la ma-
chine, mais un générateur de code qui correspond au dessin suivant :

cond ifNjmp bl.then jmp bl.else


78
Nous pouvons aussi assembler une boucle while à partir de la condition, et le
code, qui doit changer l’état de la pile:

while_gen cond block continue =


let wchunk = cond +> (ifNjmp continue :> block)
+> (jmp wchunk :> continue)
in wchunk

ce qui correspond à

cond ifNjmp block jmp ···

Notez l’autoréférence dans la définition du wchunk ! Une telle construction n’est


possible que dans le cadre de la programmation paresseuse. Ceci est si simple,
qu’il est difficile d’imaginer une solution encore plus compacte. Mais ceci est
possible. . . 79

Les éléments indispensables pour générer un tel code sont :

• Le fragment de code qui calcule la condition.


• Le bloc qui sera conditionnellement exécuté.
• Le reste du code, le fragment continue

Ce dernier élément est absolument cardinal, on ne pourra pas assembler ce code


sans lui, car on ne saura pas où aller si la condition est fausse. Le même problème
touche la conditionnelle.

On peut se poser la question : mais en compilant la conditionnelle, même si on


ramasse les pièces de manière efficace, le reste du programme n’est pas encore
compilé. Qu’est-ce que l’on fait? La réponse est simple : la totalité du code est as-
semblée de façon paresseuse. On génère la boucle qui se termine par le paramètre
continue, et on assemble le reste, et ensuite on assigne ce reste à continue. 80
Cette stratégie peut être très gourmande en mémoire si on compile des programmes
de plusieurs centaines de milliers de lignes de code, mais ceci n’est pas notre ob-
jectif principal.

Le modèle purement fonctionnel, sans des effets de bord n’est pas très bien adapté
– en apparence – à la gestion des opérations où les opérations de bord semblent
nécessaires, par exemple le traçage, ou la gestion des erreurs d’exécution.

Cependant, nous allons traiter les deux, en accord avec la philosophie qu’un cours
de compilation ne peut cacher les situations exceptionnelles, et doit faciliter le
débogage. le traçage utilisera le I/O monadique de Haskell, les exceptions sont
plus « traditionnelles ». Mais d’abord quelques simples exemples. 81

Voici deux exemples de la fonction factorielle (pour ne pas oublier les TD), récur-
sive en profondeur :

fact = ifelse initf tproc eproc (ret:>Empty)


where
initf = lcode [dup, ild 0, eq]
tproc = lcode [pop, ild 1]
eproc = lcode [dup, ild 1, sub, call fact, mul]
ftest = lcode [ild 6, call fact,ret]

où il faut observer comment la continuation de la conditionnelle force le retour.


La solution itérative est : 82
itfact = init +> while_gen cond loop fini
where
init = lcode [dup, ild 1, exch]
cond = lcode [ild 0, gt]
loop = lcode [param 1, mul, exch, ild 1, sub, exch, param 1]
fini = lcode [exch, pop, ret]
itftest = lcode [ild 7, jmp itfact]

(où param, appelé index en TD permet d’accéder aux éléments de profondeur


quelconque sur la pile de données). Nous pouvons aussi avoir besoin de la fonction
roll qui change l’ordre des éléments sur la pile (dans quel sens? Quel rapport
avec rot?)

Et voici deux procédures de traitement des listes, la concaténation, et l’inverse,


qui utilisent roll : 83

roll n = stkop rollp where


rollp (x:l) = let (t,d)=splitAt n l
in t ++ (x:d)
cons = stkop (\(x: L l :stk) -> L (x:l) : stk)
hdtl = stkop (\(L (h:t):stk) -> h : L t : stk)
hd = stkop (\(L (h:_):stk) -> h : stk)
hd_ = stkop (\s@(L (h:_):_) -> h : s)
nil = L[]
append = lcode [dup, lld [], ne, ifjmp rcur, pop, ret] +> rcur
where
rcur = lcode [hdtl, roll 2, call append, exch, cons, ret]

lreverse = lcode [lld [], exch, jmp rev]


rev = lcode [dup, lld [], ne, ifjmp rec, pop, ret] +> rec
where
rec = lcode [hdtl, exch, roll 2, cons, exch, jmp rev]
84
Gestion des erreurs ; exceptions

La division par zéro, la tentative de récupérer la tête d’une liste vide, etc., sont des
situations courantes. Il nous faudra répondre à deux questions :

• Comment rendre notre machine sensible à une telle situation, pour ne pas
perdre le contrôle.
• Comment de telles situations sont gérées par le hardware et le code de bas
niveau sur des « vraies » machines, et comment établir l’interface entre la
gestion des erreurs et ce code de bas niveau.

La réponse à la première question peut être : en cas de trouble on échoue. On rend


une valeur spéciale Fail message, et toute opération primitive qui trouve cette
valeur, la transmet telle quelle. Ceci est sémantiquement correct, mais incomplet
et inefficace. 85

Dans le cas des vraies erreurs le flot des instructions est interrompu, et on fait
« d’autre chose » (on lance la procédure de gestion des erreurs). Il faut donc dans
notre cas prévoir la possibilité d’une continuation exceptionnelle.

La réalisation de ce dispositif dans des langages de bas niveau consiste à sauvegar-


der dans un buffer spécial l’état actuel (p. ex. la pile système) avant d’entrer dans
une zone critique, dont l’exécution peut se terminer mal. En C on lance setjump.
Ensuite, quand le bloc surveillé se termine sans problèmes, on supprime le buffer
de sauvegarde. L’erreur déclenche une action « standard » (interruption) qui exé-
cute longjump parametré par le buffer de sauvegarde, ce qui restaure le système
précédent, mais permet de diagnostiquer l’erreur.

Une technique de plus haut niveau linguistique consiste à assembler des blocs de
genre (les formes syntaxiques sont très variées. . . ) 86
try
<instructions>
except
except_1 -> solution_1
except_2 -> solution_2
...

où le résultat est soit fourni par le bloc try, soit par une des « solutions ». Les
exceptions sont des types de données abstraits, nommés, p.ex Div by Zero, etc.

Une autre variante utilise la combinaison throw – catch, populaire dans les lan-
gages de la famille Lisp. GHC en profite aussi, mais l’usage des exceptions n’est
pas facile, possible seulement dans le cadre de la monade IO. Exemple :

catch (openFile f ReadMode)


(\e -> hPutStr stderr ("Fichier? "++f++": " ++ show e)) 87

L’instruction throw :: Exception -> a génère une exception utilisateur. Le


type du résultat est arbitraire, car il n’est jamais utilisé.

En créant notre machine virtuelle en Haskell qui est déjà un langage de haut ni-
veau, nous pouvons être confrontés avec plusieurs possibilités. Une erreur décou-
verte par une primitive peut bien sûr bomber le run-time de Haskell, et le OS
reprend la main. Ceci est le pire des cas, on doit protéger une VM professionnelle
par catch ou équivalent. Ainsi notre interprète reprend le contrôle. Mais notre
programme exécuté par la VM – non.

C’est le rôle du générateur du code d’établir la connexion entre les procédures


qui gèrent les exceptions au niveau Haskell (généralement : langage d’implémen-
tation) et le programme.

Occupons nous donc d’une solution « manuelle », comment coder une séquence
d’instructions en présence d’une « porte de cuisine » permettant de sortir en cas
de malheur. 88
L’instruction qui réalisera le dispositif est

trap block escblock

Elle exécute le block block et continue comme le programme le prévoit en cas


de succès. En cas d’échec le escblock est exécuté.

Il y a un problème : le bloc principal exécute instruction par instruction de manière


distribuée, rappelons-nous qu’aucune boucle principale n’existe plus ! Ceci n’est
pas une « invention académique » Quand le hardware exécute des instructions,
il n’y a pas dans des processeurs modernes de boucle principale, le transfert de
contrôle, le pre-fetching, etc., tout est distribué.

Alors, comment ce bloc peut « savoir » où passer le contrôle en cas d’exception?


La réponse est : le block n’est pas un simple fragment de code, mais un bloc para-
metré, une procédure Haskell qui génère le code. Le paramètre sera l’« étiquette »
du branchement exceptionnel. Voici le code du générateur trap : 89

trap block (escinstr:>escode) stk code rstk =


let throw _ _ _ = escinstr stk (escode+>endprog) rstk
in jmp (block throw +> code) stk code rstk

Voici un exemple d’usage (pas très utile. . . ). Supposons que l’on calcule le produit
d’un certain nombre d’items, par le code suivant :

prod = lcode [dup, lld [], eq, ifNjmp nonil,


pop, fld 1, ret] +> nonil
where nonil = lcode [hdtl, exch, call prod, mul, ret]

Or, quand la liste-argument contient un zéro, il est inutile de continuer les mul-
tiplications. Le code ci-dessous déclenche une exception. Mais attention, notre
fonction trap est trop brutale, à cause du endprog le code de gestion des excep-
tions termine le programme. 90
xprod = lcode [trap blk escape, ret] where
blk exit = lcode [jmp prod] where
prod = lcode [dup, lld [], eq, ifNjmp nonil,
pop, fld 1, ret] +> nonil
nonil = lcode [hdtl, dup, fld 0, eq, ifjmp bang,
exch, call prod, mul, ret] +> bang
bang = lcode [exit]
escape = lcode [sld "Zéro. Vous avez perdu !"]

Ceci n’épuise pas le problème, mais montre comment organiser la génération du


code « sécurisé » dans des conditions simples.

L’approche suggérée ci-dessus pour déclencher des exceptions et pouvoir passer


à des continuations alternatives ne se limite pas à la gestion des erreurs ! 91

Break et continue

Question : comment compiler while(...){ ...; ... break; ...}, ou une


boucle avec continue? Rappelons que break force la terminaison de la boucle,
on la quitte de façon irreversible. L’instruction continue fait quitter le bloc, on
revient à la condition.

continue break

cond ifNjmp block jmp ···

Ces branchements sont non-locaux. On ne peux compiler le bloc de manière au-


tonôme, car on ne sait pas où est la cible de ces sauts exceptionnels. Alors il faut
utiliser le même dispositif que dans le cas de throw : compiler le bloc comme
un objet fonctioonnel, parametré par deux (au moins) « étiquettes spéciales » ;
bloc gen brk cont. 92
Le générateur de la boucle prendra la forme

while_gen cond blgen continue =


let block = blgen continue wchunk
wchunk = cond +> (ifNjmp continue :> block)
+> (jmp wchunk :> continue)
in wchunk

et – il va de soi – toute occurrence du break dans le code du bloc sera rem-


placée par le compilateur par jmp brk, et continue par jmp cont. En géné-
ral cette technique de paramétrisation peut aider à compiler un bloc avec des
instructions de branchement non-structurées (goto). Cependant, en présence de
plusieurs branchements de ce type la « maladie » de mauvaise structuration du
programme-source se répercute sur la structure du compilateur : il faut stocker
toutes les étiquettes-cibles dans une liste, et l’administration de la compilation
devient pénible. 93

Passons à l’autre extension du notre interprète – l’ajout du dispositif de traçage.


Nous voulons simplement que les instructions exécutées confirment ce fait en
affichant quelque chose sur le port de sortie. Ce sont ces mêmes instructions qui
doivent s’en charger, car il n’y a pas de boucle de dispatching central. Comment
alors sérialiser de telles instructions?

Quand on ajoute manuellement les instructions de traçage dans un programme,


disons en Scheme, il cesse d’être fonctionnel (s’il l’était auparavant. . . ). Tracer
un programme en Haskell semble impossible. Mais on pouvait déjà soupçonner
que les actions d’entrée/sortie étaient également impossibles à réaliser de manière
purement fonctionnelle, et pourtant le modèle sémantique proposé n’a rien de non-
fonctionnel, au sens de séparer complètement les « effets de bord » (l’influence
sur le monde extérieur), et le comportement du programme.

On peut soupçonner que nous nous éloignons de la compilation?. . . 94


Mais non. Nous cherchons un modèle du code, statique et typé, comme toujours,
permettant de réaliser de telles opérations « impératives ». Un compilateur fonc-
tionnel doit traiter le code-cible du programme compilé d’une manière très propre,
et surtout : de manière statique. Une bonne sémantique permet au compilateur
d’ajouter du code pour diagnostiquer les défaillances, corriger les erreurs, etc.

Nous savons déjà que le système I/O de Haskell est assez particulier. Il faut avoir
une raisonnable connaissance de ses particularités, même si de manière superfi-
cielle. Lisez la documentation ! Posez des questions. 95

Le traçage
La modification principale consiste à passer aux instructions du type :

type CodeItem = Dstack -> Code -> Rstack -> IO Dstack

Toute instruction qui veut ajouter un message diagnostique doit contenir le code
approprié, p. ex.

start (instr:>prog) =
putStrLn "START TRACING" >>
instr [] prog ([stop:>Empty]) >>=
print >> -- the stack
putStrLn "END TRACING"

et ensuite 96
stkop msg op stk (instr:>code) rstack
= putStr (msg++"; Stack: ") >>
putStrLn (show stk) >>
case stk of
(Fail _ :_) -> instr stk code rstack
_ -> instr (op stk) code rstack

add = op2 "ADD" (+) -- op2 inherits msg from stkop


sub = op2 "SUB" (-)
mul = op2 "MUL" (*)

stop stk _ _ =
putStrLn "STOPPING" >> return stk
97

ret stk _ ((instr:>code):rstk) =


putStrLn "RETURNING" >>
instr stk code rstk

Les générateurs du code (p. ex. conditionnel) doivent aussi savoir enchaîner cor-
rectement les instructions avec les « effets de bord »

ifjmp (instr:>ccode) (B cond : stk) (nxt:>ncode) r


| cond = putStrLn "C.BRANCHING" >> instr stk ccode r
| otherwise = putStrLn "SKIPPING" >> nxt stk ncode r

ifelse_gen cond thcode elcode continue =


let othw = elcode +> continue
in cond +> (ifNjmp othw :> thcode) +>
(jmp "CONTINUE" continue :> othw) 98
et le code typique, la séquence des instructions reste le même. Plusieurs questions
restent sans réponse : comment brancher ou débrancher sélectivement quelques
instructions de traçage, comment rapporter l’état des piles, etc. Si le temps nous
permet, nous allons discuter encore ces problèmes.

Nous avons constaté que la sérialisation « ensuite » : (>>) typique des instructions
d’entrée et sortie, peut être utile aussi dans un contexte plus riche. Cependant le
modèle devient structurellement hétérogène, on a l’enchaînement des instructions
par « ensuite », et aussi leur liaison par la structure de données transportable, le
code.

Le raisonnement sur la génération du code exécutable devient plus simple si on


change de modèle. 99

Modèle CPS

Nous avons promis de traiter encore et encore les continuations. L’heure est venue.
Rappelons que ce concept concerne le « futur » d’un calcul, il donne la réponse à
la question : « et ensuite? ». Il permet de préciser de manière fonctionnelle le sens
du branchement (goto), et il est très utile pour comprendre les techniques de com-
pilation du backtracking en Prolog, etc. Avec les continuations nous avons déjà
transformé une expression en « code assembleur » pour une machine à registres.

Jusqu’à présent, le code était une structure équivalent à 100% à une liste. On pas-
sait ce code à toute instruction, qui y trouvait son successeur, et lui passait le reste.
Dans la plupart des cas la sérialisation est simple, les instructions sont exécutées
une après l’autre, et toute instruction sait quel est son successeur directement. 100
On pourra donc prévoir l’assemblage du code un peu différemment. Aucune struc-
ture de données ne sera « traînée » avec les instructions, les instructions pren-
dront chacune un paramètre supplémentaire – la continuation proche, l’instruc-
tion suivante. Nous proposeront un opérateur de séquencement (un peu similaire
– conceptuellement – à (>>)) pour faciliter l’assemblage.

Soulignons : le modèle devient un peu plus élaboré, mais

• sa sémantique est plus propre, plus semblable à un code « physique », inter-


prété par une machine physique ; il sera un objet fonctionnel et non pas une
liste d’opérations. L’instruction suivante représentera en fait la totalité du pro-
gramme successeur, car cette instruction référence son successeur direct, qui
référence le sien, qui. . .
• L’assemblage de morceaux qui se suivent reste très facile, même plus facile
qu’auparavant. 101

Il y aura des modifications de typage assez poussées. Dans le modèle précédent


l’« opération générique » qui travaillait sur la pile de données, prenait une fonction
pure, et la transformait en CodeItem, p. ex.:

type Opstack = Dstack->Dstack

stkop :: Opstack -> CodeItem


...
add = stkop (\(x:y:stk) -> (x+y) : stk)

Tout ceci reste valable, mais maintenant le CodeItem ne contiendra plus le Code
(la liste d’instructions) comme son paramètre.

Rappelons l’idée fondamentale de continuations. Au lieu d’avoir une simple don-


née x, nous aurons une fonction, disons x cont d’un paramètre. Ce paramètre
est une autre fonction qui récupère la valeur et qui en fait quelque chose, (par
exemple, la transforme et passe à sa continuation). 102
On peut définir la fonction lift qui agissant sur x transforme cette valeur en objet
continué x cont = lift x :

(lift s) cnt = cnt s


= flip id s cnt
lift = flip id

Trivial, non?

Rappelons aussi qu’une fonction du genre donnée -> donnée peut être liftée
comme suit :

(lift1 f) x cnt = cnt (f x)


(lift1 f) x = (lift . f) x
lift1 = (lift .)

ce qui est assez bizarre, mais montre la puissance du raisonnement combinatoire. 103

Dans le style CPS (Continuation Passing Style) conséquent, toute expression est
continuée, demande un paramètre fonctionnel « de transmission ». Durant cette
chaîne de continuations, on ne récupère jamais le résultat « final », « nu ». (Comme
on ne sort pas de la chaîne IO. Comme on ne sort pas d’un programme principal
en C++ !!). Ce résultat est donc en principe polymorphe.

Notre nouvelle variante d’instructions aura le type suivant :

type CodeItem a = Dstack -> (Dstack->a) -> a

Une instruction prend la pile, prend aussi une autre instruction comme paramètres,
et retourne un résultat. Notez que (Dstack->a) est compatible avec Dstack ->
(Dstack->a) -> a, il y a une ambiguïté dans l’air. . . 104
Voici le lifting de nos opérations. Supposons que f dénote une fonction qui fait
quelque chose avec Dstack, duplique le sommet ou ajoute deux nombres, etc.
L’instruction de la VM qui réalise cette opération est

stkop f dstk cnt = cnt (f dstk) -- ou


stkop = (lift .) -- et à présent
add = stkop (\(x:y:s) -> (y+x):s) -- etc.

Pour combiner les instructions nous proposons l’opérateur (.>) défini ci-dessous,
qui permet la construction des codes comme

cube = dup .> dup .> mul .> mul -- et


test = fld 5.0 .> cube .> fld 2.0 .> mul
res = eval test

etc. Nous n’avons plus de pile des retours, et l’instruction ret manque aussi. 105

L’essentiel est la définition de l’opérateur d’enchaînement. Si vous faites un com-


pilateur « classique », il construit le code par le stockage séquentiel des com-
mandes (p. ex. bytecodes) dans un vecteur ou une liste. Pourquoi toute cette intri-
cation?

La réponse est : car le stockage aveugle n’est pas suffisant pour générer le code
avec des branchements, des appels procéduraux, des boucles, etc. Cela demande
beaucoup plus, on a déjà vu les problèmes dans le modèle précédent.

Le méta-opérateur (.>) est défini comme suit :

infixr 0 .>
instr1 .> instr2 = \stk -> nxt
instr1 stk (\ns -> instr2 ns nxt)

c’est-à-dire : le résultat de l’enchaînement est une instruction, une fonction de


deux paramètres, la pile et l’instruction qui suivra les deux, nxt. 106
On lance la première instruction avec la pile d’origine, et on passe le résultat (la
nouvelle pile ns) à instr2, qui évidement se termine par l’appel du nxt. Le
reste de l’interprète ne change presque pas, définissons encore la conditionnelle et
la boucle while.

ifelse cond thcode elcode = cond .>


\(B v : s) -> (if v then thcode else elcode) s
ifthen cond thcode = ifelse cond thcode lift

while cond code = chunk where


chunk = ifthen cond (code .> chunk)

Un exemple? Le voilà :

fact = ifelse nezero


(dup .> ild 1 .> sub .> fact .> mul)
(pop .> ild 1) 107

Mais nous avons « oublié » l’essentiel ! Comment sortir de ce cercle vicieux de


continuations, comment tester nos programmes, quelle est la définition de la fonc-
tion eval? Il faut que ce soit une fonction qui lance le code, et qui lui attache une
continuation particulière : instruction qui « oublie » d’appeler son successeur, et
qui retourne le résultat (la pile). Voici deux instructions spéciales de ce genre, la
première démarre (et arrête) le programme, la seconde le tue de l’intérieur :

eval code = code [] const undefined


abort v s nxt = v

(Bien sûr, le undefined, défini dans Haskell standard comme undefined=undefined


n’est jamais appelé, sinon c’est un puits sans fond ; l’usage de undefined et non
pas d’une autre fonction bidon est une protection. . . )

(Entre nous : on peut forcer la sortie de la monade (chaîne) I/O, mais c’est un jeu
de massacre, jamais enseigné aux débutants. Vérifiez unsafePerformIO dans la
documentation). 108
Analyse

On passe doucement à l’analyse des programmes sources : l’analyse lexicale (scan-


ning) et l’analyse syntaxique (parsing). Les résultats de l’analyse peuvent – si
on le veut – être stockés dans un arbre syntaxique (un code intermédiaire non-
exécutable), mais ceci n’est pas important, on peut générer le code de plus bas
niveau plus directement.

Ce qui compte c’est la sémantique du programme, la signification de toutes les


constructions, la cohérence des « connaissances » du compilateur concernant le
programme-source.

Donc, nous n’allons jamais séparer l’analyse purement formelle, théorique, le


traitement des chaînes et des arbres, de notre vision plus concrète, de la nécessité
de générer un code exécutable par une machine physique ou virtuelle ! 109

Ceci dit, un peu de théorie sera nécessaire. Dans le champ de compilation les
arbres poussent partout : structures syntaxiques, environnements hiérarchiques (lexi-
caux et dynamiques), intégration du programme à partir des modules, etc. Il est
donc souhaitable de rappeler quelques techniques modernes de traitement des
arbres, ainsi que leur réalisation dans le style fonctionnel.

Arbres de recherche

Les structures

data Arbre a = Nil | Nd a (Arbre a) (Arbre a)

permettent (dans le cas optimal) insérer et accéder à un élément en temps logarith-


mique. Rappelons que la technique d’insertion d’un élément consiste à descendre
jusqu’au niveau des feuilles, en allant à gauche si l’élément inséré est plus petit
que la racine (courante), sinon – à droite. 110
On remplace le Nil par le nouveau élément. Si on insère dans un arbre initiale-
ment vide les éléments de la liste [4,5,2,7,1,8,6,9,3], on obtient la structure

4
2 5
1 3 7
6 8
9
ce qui est facilement implémentable par le programme ci-dessous. 111

Ce programme définit l’insertion d’un élément, l’itérateur convertissant une liste


en arbre, ainsi qu’une fonction inverse, qui « aplâtit la liste » selon le parcours
inorder.

ins x Nil = Nd x Nil Nil


ins x (Nd y g d) | x < y = Nd y (ins x g) d
| otherwise = Nd y g (ins x d)

listree l = ls l Nil where


ls [] t = t
ls (x:xq) t = ls xq (ins x t)

flat tr = flt tr [] where


flt Nil l = l
flt (Nd x g d) l = flt g (x : (flt d l)) 112
L’opération un peu plus délicate est la suppression d’un élément. Si l’objet à sup-
primer possède au maximum 1 fils, la solution est triviale, on fusionne le descen-
dant unique avec l’encêtre.

Si le noeud possède deux fils, il faut effectuer les opérations suivantes :

– Localiser le successeur du noeud, l’élément suivant dans inorder. Il ne peut


avoir aucun fils gauche, car c’est le minimum de la première branche à droite
de ce noeud.
– Effacer ce successeur, et remplacer le noeud supprimé par le successeur.

Dans le cas général la recherche du successeur n’est pas triviale (il faut descendre
à droite d’un ancêtre), mais dans le contexte ci-dessus, le successeur est le mi-
nimum de la branche droite tout court. Construisons une procédure (deltmin)
qui récupère et qui efface le minimum d’un arbre (elle n’est pas protegée contre
l’échec ; veuillez la compléter vous-mêmes) : 113

deltmin (Nd x Nil d) = (x,d)


deltmin (Nd x g d) =
let (y,p) = deltmin g
in (y,Nd x p d)

Pour effacer un noeud quelconque il suffit de lancer la procédure delt

delt x (Nd y g d) | x==y =


if g==Nil then d else
if d==Nil then g else
let (s,p) = deltmin d
in Nd s g p
| x<y = Nd y (delt x g) d
| otherwise = Nd y g (delt x d) 114
Arbres équilibrés

Les arbres de recherche dans la compilation peuvent jouer le rôle d’environne-


ments. Nous avons déjà utilisée une liste linéaire, pour les opérateurs, et pour les
variables. Dans la discussion d’un interprète de la forme lambda, nous avons vu
que l’environnement agit comme une pile, et de nouvelles instances recouvrent
les précédentes.

Même avec des listes linéaires on peut optimiser la recherche : garder tout atome
(identificateur, nom) en un seul exemplaire, mais attacher à son emplacement
une pile (liste) de ses instances hiérarchiques). Ou, éventuellement, pour chaque
module et chaque procédure compilée (ou interprétée) prévoir un environnement
local, et chercher d’abord là. Si la recherche échoue, on cherche dans l’environ-
nement englobant, etc. Les environnements peuvent alors constituer un arbre hié-
rarchique aussi. 115

En tout cas, recherche linéaire est lente, et des techniques d’optimisation, l’usage
du hash-coding ou des arbres devient inéluctable. Mais pour assurer un bon com-
portement des algorithmes de recherche et de la mise à jour, il faut que les arbres
soient équilibrés.

Nous allons donc rappeler quelques outils de traitement des arbres balancés (équi-
librés ; dont les branches possèdent à-peu-près la même hauteur, ce qui est bon
pour la complexité des algorithmes qui les digèrent, on obtient le coût logarith-
mique dans le pire des cas).

Les arbres AVL (Adelson, Velskiy & Landis) se caractérisent par la propriété que
la différence de hauteur des deus sous-arbres ne dépasse√ jamais 1. On prouve
que la hauteur maximale d’un arbre AVL est limitée par 2 fois la taille optimale
(équilibre parfait), donc, toujours logarithmique en nombre de noeuds. L’insertion
dans un arbre AVL commence comme d’habitude, on descend jusqu’aux feuilles.
L’insertion peut produire un arbre déséquilibré, qui sera alors immédiatemment
reconstruit. 116
Souvent on stocke dans des noeuds un drapeau qui dit laquelle de deux branches
est plus longue (ou aucune). Si l’insertion a lieu dans la branche courte, c’est tout.
Dans le cas contraire on effectue une rotation. Il existe deux variantes de rotation
des arbres AVL : simple et double. L’arbre à droite est le résultat d’une rotation
simple de l’arbre à gauche, visiblement déséquilibré.

x y

y x z
A
z
B A BC D
C D
Une autre configuration mauvaise peut également être traitée. 117

x z

y x y
A
z
D A BC D
B C

Donc, pour équilibrer l’arbre AVL il faut chercher un noeud dont le « grand-père »
est devenu déséquilibré, et effectuer la rotation. Le code Haskell a une dizaine de
lignes, et son implantation se trouve sur l’Internet. Trouvez-le. 118
Digression : lambda-lifting

Si une procédure accède uniquement à ses variables locales (c’est une fonction
« pure »), on peut stocker toutes les données sur la pile. Si de plus on peut accé-
der aux variables globales, on prévoit un environnement global. Cela suffit pour C
ou les premières version du Python. Mais Scheme, Haskell, et même Pascal per-
mettent la définition des procédures dans des procédures. Ceci signifie l’existence
des variables non-locales qui ne soient pas globales. Comment y accéder?

Les techniques générales d’allocation de mémoire seront discutées plus tard. Il


faudra distinguer soigneusement entre les liaisons dynamiques et lexicales ! Men-
tionnons ici une astuce (utilisée d’ailleurs en Python au niveau utilisateur pour
simuler les fermetures. . . ). Étant donné une hiérarchie de deux fonctions imbri-
quées, avec le passage d’une variable non-locale : 119

f x = ...
let ...
g y = ... h x y
...
... g(x+1) ...

on voit que la variable x liée par f est utilisée à l’intérieur de g. Cette fonction
constitue une fermeture. On peut la compiler en dehors de f, et transformer le
code ci-dessus en

g_ y x_ = ... h x_ y
...
f x = ...
... g_ (x+1) x ...

Donc, on ajoute un paramètre supplémentaire pour chaque variable non-locale,


on compile la fonction comme pure, et on l’applique sur les variables extérieures,
traitées comme arguments. 120
Techniques de précédence
On revient à l’analyse proprement dite.

La première classe d’algorithmes de structuration de données très simplifiés est


basée sur le concept dit de « grammaires d’opérateurs », mais nous éviterons toute
référence au concept de grammaire, cela viendra plus tard. Supposons que les
entités primitives appartiennent à une de deux classes : les données, ou atomes, et
les opérateurs. (à cela il faudra ajouter des méta-objets : les parenthèses, même si
en principe on pourrait les traiter comme opérateurs. . . ).

Commençons paradoxalement non pas par l’analyse d’un texte (séquence linéaire
d’entités), mais par un « anti-parseur », ou un pretty-printer, un module qui trans-
forme une arborescence syntaxique en texte. On sait que Haskell dispose de la
classe Show qui contient quelques fonctions (notamment show) qui convertissent
un objet Haskell quelconque en chaîne. 121

Ces fonctions sont définies pour des structures de données standard, nombres,
listes, tuples, etc. On peut demander – grâce à la clause deriving l’affichage
par défaut des structures algébriques ayant quelques balises et variantes, mais si
on veut afficher quelque chose de plus conséquent, il faut définir soi-même la
fonction show correspondante dans l’instance de Show.

Cette classe possède encore une autre fonction : showsPrec qui prend un pa-
ramètre entier supplémentaire. Dans le prélude standard de Hugs ceci n’est pas
exemplifié.

Rappelons que la fonction shows (définie globalement, et non pas comme un


méthode) possède la définition

shows = showsPrec 0

mais, indépendamment de cette définition nous l’avons utilisé pour optimiser la


conversion des séquences. Au lieu d’écrire 122
f = show a ++ show a ++ show a + show d ++ ...

ce qui provoque une recopie massive des données (la concaténation (++) recopie
toujours son premier argument), nous avons utilisé la fonction shows, dont la
sémantique était : convertir le premier argument, et concaténer le résultat avec le
second (une chaîne). Sémantiquement shows x str = show x ++ str.

Ceci nous permet d’écrire

f = shows a (shows b (shows c ... "")) - ou :


f = (shows a . shows b . shows c . ...) ""

Mais, le showsPrec? Ceci nous permettra de jouer avec les précédences des opé-
rateurs, et prendre en considération le parenthésage. 123

Regardons ces deux arbres :

− −

× c a ×

a b b c

qui correspondent aux expressions a × b − c et a − b × c. 124


Cependant, l’arbre

− c

a b

représentant (a − b) × c, nécessite les parenthèses pour affichage ! Parfois cela


arrive avec les opérateurs de même famille, avec l’associativité particulière. Les
expressions a − b − c et a − (b − c) ne sont pas équivalentes. 125

Dans quelles circonstances faut-il parenthéser une sous-expression? (Considérons


que l’idée générale de l’affichage textuel d’un arbre est complètement triviale, il
s’agit d’un parcours in-order le plus classique. . . )

On voit que le parenthésage est nécessaire si la précédence de l’opérateur interne


est plus faible que celle de l’opérateur-ancêtre.

Commençons par affecter à chaque opérateur binaire deux attributs.

– La précédence, un nombre entier positif ; valeur plus grande signifie l’opéra-


teur « plus fort » (p. ex. la multiplication est plus forte que l’addition).
– L’associativité : gauche (comme les 4 opérations arithmétiques), droite (comme
la puissance, ou l’affectation en C), ou
– la non-associativité.

Ceci sera représenté par la structure de données 126


data Assop = Lft | Rgt | Noa

(Un objet de ce type, purement symbolique et mnémonique, sera converti en -1,


+1 ou zéro). Les opérateurs seront mis dans une liste associative de recherche,
disons :

infops = [(’+’,60,Lft),(’-’,60,Lft),(’/’,70,Lft),(’^’,90,Rgt),
(’<’,40,Noa),(’:’,50,Rgt),...]

qui peut, naturellement, être élargie comme on veut. Il est souhaitable que les
précédences ne soient pas contiguës, qu’il y ait un espace entre ces entiers.

Ensuite nous définissons la fonction findop qui cherche les propriétés d’un opé-
rateur dans la liste infops, et nous pouvons passer au pretty-printer infixe des
structures arborescentes : 127

data Expr = Dat String | Eop String Expr Expr

où Dat balise une feuille, une donnée atomique, et Eop représente un noeud bi-
naire, identifié par son opérateur infixe. La fonction findop ne présente aucune
difficulté, c’est une fonction de recherche la plus classique, qui retourne les attri-
buts de l’opérateur trouvé :

findop st ((o,n,as):rst) | st==o = (n,asconv as)


| otherwise = findop st rst
findop st [] = error ("missing operator: " ++ show st)

(s’il existe, bien sûr ; déclencher une exception dans le cas contraire est une pos-
sibilité, mais nous pouvons également avoir un opérateur « bidon ». 128
Accessoirement nous aurons

asconv Lft = -1
asconv Rgt = 1
asconv Noa = 0

où asconv est une fonction auxiliaire qui transforme l’associativité symbolique


en numérique. On va ajouter le résultat : ±1 ou 0 à la précédence, et obtenir ainsi
la précédence gauche ou droite.

Voici le Pretty-Printer. Si l’expression est une feuille (donnée atomique), on l’af-


fiche.

instance Show Expr where


showsPrec _ (Dat x) = showString x 129

showsPrec p (Eop op g d) =
let (np,as) = findop op infops
dl = asconv as
txt=showsPrec(np+dl)g . showString op . showsPrec(np-dl)d
in if np<p then showString "(" . txt . showString ")" else
if np>p then txt else error "non-assoc..."

Sinon, on descend récursivement et on affiche la branche gauche, l’opérateur et


la branche droite, comme prévu, mais en descendant on passe la précédence de l’
opérateur actuel, modifié par l’ajout ou la soustraction de 1, -1 ou zéro selon la
branche et l’associativité déclarée.

Enfin, on compare la précédence de l’opérateur actuel avec celle de son ancêtre,


et si le père est plus fort, on ajoute des parenthèses. À présent on voit comment
l’associativité est-elle gérée. Pour un opérateur associatif à gauche, dont la pré-
cédence est égale à 60, on passe 59 à gauche, et 61 à droite. Si la branche droite
contient le même opérateur, les parenthèses sont nécessaires. 130
Si l’opérateur n’est pas associatif, les précédences corrigées entrent en collision,
et l’afficheur déclenche une exception.

Et la lecture?

L’exemple précédant nous montre que la librairie standard de Haskell possède


quelques utilitaires de sortie des données structurées. Pour l’entrée des données
de types variés nous avons la classe Read, avec des fonctions comme read, ainsi
que readsPrec, qui peut être exploité pour le parsing – assez primitif – des ex-
pressions infixes, selon le modèle du pretty-printer « à l’envers ».

Nous allons établir un modèle plus général un peu plus tard, mais la classe stan-
dard Read va nous inspirer. La méthode de cette classe, read possède le type
String -> a, où a est le type du résultat, l’instance de la classe Read. 131

Il existe également la fonction reads, du type String -> [(a,String)]. Concrè-


tement : read "1.23" déclaré comme Double transforme l’argument en 1.23,
mais que fait-on si la chaîne-source contient "1.23abc"?

L’idée est : on lit et on convertit l’argument, mais si la chaîne-source n’est pas


épuisée, on retourne également le reste. Ici (1.23,"abc"). Mais c’est faux, n’ou-
bliez pas les crochets !

Parfois le parsing est non-déterministe, il peut rendre plusieurs résultats. Ima-


ginons un parseur (stupide. . . ) défini sur les entiers, qui transforme une chaîne de
chiffres en entier. Si nous ne précisons plus rien, l’analyser "1079 marquise"
peut resulter en : [(1,"079 marquise"), (10,"79 marquise"), (107,"9
marquise"), (1079," marquise)]. Ce non-déterminisme parfois est plus
difficile à enlever que dans ce cas artificiel où il suffit de dire : accepte la chaîne
numérique la plus longue. Parsing ambigu d’habitude est une faute. Comme au-
paravant avec shows, la fonction reads est définie comme readsPrec 0. 132
N’oublions pas que read etc. sont des fonctions polymorphes, qui retournent les
résultats ambigus au niveau typage, il faut préciser ce que nous voulons (l’instance
concrète de la classe read !). Cependant, indépendamment du typage du résultat,
on peut séparer un flot (une chaîne) de caractères quelconques en items selon
notre intuition, p. ex. "12.54abc /=+>bbb(p+12.e)" après quelques itérations
peut nous fournir la liste ["12.54", "abc", "/=+>", "bbb", "(", "p",
"+","12", ".e", ")"].

Un tel séparateur de « tokens » est prédéfini dans la librairie standard de Haskell,


et s’appelle lex ; profitez-en. Nous allons reconstruire un scanneur lexical plus
sélectif que celui-là (adapté à une grammaire concrète), mais plus tard. En tout cas,
à cause du typage, la construction des procédures de lecture est assez fatigante.

La proposition ci-dessous est un « jouet » qui contient les ingrédients d’un parseur
sérieux, mais qui est extrêmement simplifié. Nous allons parser les expressions du
genre "a*(b+c-a/f)-g^a", composées d’atomes. 133

Ce sont des simples caractères alphabétiques, les 5 opérations arithmétiques, et


les parenthèses. Pas d’espaces, et l’expression en principe doit être correcte. Ces
éléments primitifs seront appelés tokens (jetons).

On peut catégoriser les tokens en définissant un type de données spécial, symbo-


lique, et construire une fonction de recherche.

data Toks = Atom | Lpar | Rpar | Add | Sub | Mul


| Div | Pow | Err | Fin
toklist = [(’(’,Lpar),(’)’,Rpar),(’+’,Add),(’-’,Sub),
(’*’,Mul),(’/’,Div),(’^’,Pow)]
findtok x ((c,t):es) | c==x = t
| isAlpha x = Atom
| otherwise = findtok x es
findtok _ [] = Err
token (c:str) = let t = findtok c toklist
in (t,[c],str) 134
Notez que la fonction de recherche findtok n’échoue pas avec une erreur de
Haskell, mais rend un « token illégal » Err. Ceci constitue un ingrédient du mo-
dule d’analyse qui ne se laisse pas tuer par un programme erroné (rappelons que
la fonction de recherche dans la liste des opérateurs n’avait pas cette protection ;
essayez de l’ajouter. Ceci est un bon sujet d’examen).

Nous avons également opté pour que la fin de la chaîne-source soit considérée
comme un token spécial Fin. Le caractère retourné par token est emballé dans
une chaîne en accord avec notre définition précédante du type Expr.

Nous allons lire à présent les expressions complètes, et construire les arbores-
cences correspondantes. La solution n’est pas optimale (en particulier, quelques
tokens sont lus deux fois). 135

Voici le code :

instance Read Expr where


readsPrec p str =
let (t,ch,rst) = token str
in case t of
Atom -> readsOp (Dat ch) p rst
Lpar -> let [(e,rst1)] = readsPrec 0 rst
(r,_,rst2) = token rst1
in if r==Rpar then readsOp e p rst2
else error "Parens."
_ -> error (str)

Commentaires. On commence par la lecture du premier token qui doit être un


atome, ou la parenthèse ouvrante ; dans le second cas on appelle readsPrec ré-
cursivement, et ensuite on force la lecture de la parenthèse fermante. 136
readsOp ctx p str =
let (t,op,rst) = token str
in if any (t==) [Rpar,Fin] then [(ctx,str)] else
let (np,assc) = findop op infops
(nl,nr) = (np+assc,np-assc)
in
if nl<p then [(ctx,str)]
else let [(rgt,rst1)] = readsPrec nr rst
nctx = Eop op ctx rgt
in readsOp nctx p rst1

La suite est assurée par la fonction readsOp qui prend trois paramètres : la pré-
cédence d’entrée, et la chaîne, comme dans readsPrec, ainsi que le contexte
gauche, le paramètre ctx. Cette fonction démarre avec la « tête de lecture » po-
sitionnée devant un opérateur infixe. Il va de soi qu’il est précédé par une ou plu-
sieurs données. La partie de données déjà analysée constitue ce contexte gauche.
La fonction s’arrête net si au lieu d’un opérateur elle trouve la fin de données ou
la parenthèse fermante, en retournant le contexte gauche. 137

La fonction retourne aussi si le nouveau opérateur possède la précédence infé-


rieure par rapport à l’argument p. Notez que p est comparé avec la précédence
gauche de l’opérateur.

C’est ici que l’inefficacité se manifeste ; un opérateur plus faible, ou la paren-


thèse fermante seront lus deux fois.

Sinon, après avoir consommé l’opérateur, readsOp relance readsPrec, en lui


passant comme p la précédence droite de l’opérateur. Après avoir récupéré le ré-
sultat le parseur forme un noeud de l’arbre-résultat, et le passe comme le nouveau
contexte gauche à l’appel récursif terminal.

Quelques techniques d’optimisation, notamment l’usage des piles (privées) d’opé-


rateurs, seront discutées plus tard. Mais le squelette conceptuel est là, et il doit être
assimilé. 138
Grammaires et parseurs
Passons à la théorie générale (appliquée !) du parsing. Rappelons qu’un langage
est un ensemble de phrases valides, et que la validité est établie par une gram-
maire. Nous n’envisageons pas répéter le cours Langages et Automates !, son
contenu est considéré acquis. Une grammaire formelle (sans sémantique) est un
ensemble qui contient

1. Un certain nombre de symboles terminaux ou constantes syntaxiques. Ce


sont des « mots » concrets qui appartiennent au langage ; des littéraux.
2. Les non-terminaux ou variables syntaxiques. Ce sont des méta-mots, des
descriptifs des catégories, comme « expression », « phrase verbale », « opé-
rateur », « boucle », etc.
3. Un non-terminal spécifique est considéré le symbole initial du langage, par
exemple la variable « programme ».
4. Un ensemble de règles ou productions qui définissent récursivement les non-
terminaux. 139

On appliquera cette caractérisation également à analyse lexicale, où une phrase


formelle est un mot normal, et les éléments, les mots formels sont des lettres
(chiffres, caractères spéciaux, etc.) La grammaire peut donc posséder 2 ou plus ni-
veaux. Mots composés de lettres. Phrases (sentences) composées de mots. Poèmes
composés de phrases, etc.

Dans le domaine de compilation de langages de programmation, les grammaires


utilisées sont d’habitude non-contextuelles, ce qu’implique que toute production
aura la forme :

non-terminal → Séquence de terminaux et non-terminaux

où la séquence peut contenir des symboles juxtaposés, ce qui signifie la conca-


ténation, la barre verticale significant l’alternative, ou les parenthèses (comme
méta-symboles, dont le rôle est évident). On ajoute à cet ensemble le symbole ∅
qui dénote la chaîne vide. 140
Attention. La grammaire peut être non-contextuelle, mais le processus du parsing,
et en particulier plusieurs éléments d’analyse sémantique sont contextuels, et il
faudra durant l’analyse maintenir des traces des choses faites, et passer « en aval »
quelques paramètres qui détermineront les choix des futures stratégies.

Quelques grammaires

Voici une séquence itérative, une chaîne de caractères entre guillemets, ou – après
quelques modifications de notation, sans changer la structure : les listes (Scheme/Lisp)
composées d’atomes (considérés irréductibles) et entourées de parenthèses (les
listes complètes seront traitées plus tard).

Liste → Lpar Sequence Rpar


Lpar → ’(’
Rpar → ’)’
Sequence → ∅ | Atom Sequence

ou, peut-être : Sequence → ∅| Sequence Atom. 141

La concaténation est évidemment associative, et syntaxiquement les deux va-


riantes sont identiques. Une chaîne :

Chaine → ’"’ SeqChar ’"’


SeqChar → ∅ | Char SeqChar

ou la variante récursive à gauche. Notons la similitude de ces deux grammaires.


En construisant les parseurs nous allons exploiter les fonctions d’ordre supérieur
pour mettre sous le même chapeau – différemment paramétré – de telles et d’autres
structures linéaires, itératives.

Nous avons vu également la grammaire qui décrivait les listes en Prolog. Les
éléments pouvaient être des atomes, mais aussi d’autres listes (en fait, des termes
Prolog quelconques, mais c’est un bon exercice personnel). La liste pouvait être
« irrégulière », et se terminer par un item plutôt que par la liste vide. Ces listes
sont légales : [], [a,b,c], [a,[b,c],d | e], [[],a,b | [c,d]]. 142
Les complications sont assez nombreuses :

– Si les listes peuvent être imbriquées, la structure de la grammaire est récursive


et non pas itérative.
– La virgule entre les éléments constitue un élément non-trivial, elle se trouve
toujours entre deux items, jamais au début ni à la fin.
– L’irrégularité terminale doit être traitée soigneusement. La barre ne peut se
trouver devant rien.

Exercice. Modifiez la grammaire ci-dessous afin de rendre légal la construction


[| x] (ceci était valide en micro-Prolog). On y va :

Trm → Atom | Liste


Liste → ’[’ Seq ’]’
Seq → ∅ | Trm Seq_
Seq_ → Fin | ’,’ Seq_
Fin → ∅ | ’|’ Trm 143

Deuxième classe d’exemples concerne le langage des expressions algébriques


(arithmétiques), avec des atomes, les 4 (ou 5) opérations infixes de base, ainsi
que les parenthèses. La grammaire est rigide. Au lieu de parler de précédence des
opérateurs on divise les expressions parsées en

– primaires : ce sont des atomes, ou des expressions parenthésées ; on les appe-


lera également des facteurs ;
– secondaires, ou termes (ne pas confondre avec aucun autre usage de ce terme. . . ) :
ce sont des séquences de facteurs connectés par des opérateurs multiplicatifs
(* ou /) ;
– enfin, des expressions générales, des sommes de termes – séquences de termes
liés par les opérateurs additifs (+ ou -, plus faibles que les multiplicatifs).

Les atomes sont considérés comme primitifs. Voici la grammaire : 144


Expr → Terme | Expr OpAdd Terme
Terme → Facteur | Terme OpMul Facteur
Facteur → Atom | ’(’ Expr ’)’
OpAdd → ’+’ | ’-’
OpMul → ’*’ | ’/’

Notez la propriété essentielle des constructions ci-dessus. L’associativité gauche


des opérateurs implique la récursivité à gauche des productions syntaxiques ; la
« première chose faite » par Expr est de référencer Expr si le premier variant
échoue. La récursivité à gauche est une affaire délicate, et elle sera discuté dans
les détails.

On peut inventer de dizaines d’autres exemples de grammaires utiles, disons : les


commentaires, simples et imbriqués ; la structure de nombres flottants avec l’ex-
posant (et la base variable). 145

Et encore : les grammaires qui décrivent les grammaires (pour la construction des
méta-parseurs) ; les grammaires décrivant les expressions régulières (pour les gé-
nérateurs des scanneurs lexicaux) ; la grammaire des structures en Haskell, avec
des opérateurs « sectionnés » : (x ==) ou (* y) ; les blocs en Smalltalk qui mé-
langent la structure d’une liste avec une séquence d’instructions ; les déclarations
en C++ (avec les pointeurs, tableaux, références procédurales, avec les modifica-
teurs du genre const, etc.)

Parfois on peut s’étonner que les langages déclaratifs (descriptifs : VRML, SVG ;
les déclarations de données en Haskell, Java ou C++, ou . . . ) sont sur le plan
syntaxique beaucoup plus riches que les langages « de programmation », mais
c’est normal.

Quelques exemples seront discutés en TD, et mentionnés ad hoc pendant notre


travail de construction des parseurs.

Essayons de préparer un cahier des charges conséquent, pour la construction des


parseurs et les utilitaires qui vont avec. 146
1. L’analyse c’est la reconnaissance des structures, la validation de la source.
Pour nous la compréhension sera active, et on doit prouver que le texte ana-
lysé est sain. Les parseurs seront donc équipés de procédures sémantiques qui
assembleront les propriétés des objets analysés, et permettront de générer, par
une séquence de transformations, un code potentiellement exécutable.
2. La construction des parseurs doit suivre de manière la plus fidèle possible
la grammaire du langage source. On fait la publicité des générateurs de par-
seurs (p. ex. Yacc) en disant : vous définissez la grammaire, le générateur
vous produit un programme de parsing. Nous voulons avoir le même comfort
intellectuel.
3. Avec les générateurs la vie n’est pas tellement douce, car les procédures sé-
mantiques doivent être écrites par l’utilisateur quand même. Ajouter des pro-
cédures sémantiques à un parseur écrit en Yacc n’est pas facile. Nous voulons
intégrer – en accord avec la philosophie du point (1) – les procédures séman-
tiques de manière naturelle et universelle. Les fonctions d’ordre supérieur
seront inestimables ! 147

4. Nous n’allons pas séparer – comme dans quelques autres approches à la com-
pilation – l’analyse lexicale de l’analyse syntaxique. Notre approche, fonc-
tionnelle et combinatoire exploitera les grammaires et les combinateurs à ces
deux niveaux : lexical et phrasal, de manière homogène. Ceci n’implique pas
que le parseur doit construire le résultat à partir des caractères individuels :
l’analyse procédera en quelques étapes, la construction des tokens d’abord,
l’analyse d’un flot de tokens ensuite etc.
5. L’integration de modules du compilateur passera par le traitement séquentiel
des flots de données ; ici la sémantique paresseuse encore une fois s’avère
très utile.
6. Nous allons traiter sérieusement les problèmes d’efficacité et les problèmes
de débogage, même si les solutions proposées seront simplifiées par rapport
aux compilateurs professionnels. Nos maquettes pourront servir comme le
point de départ d’un projet plus ambitieux. 148
7. Finalement, si le temps nous le permet, nous allons aborder quelques élé-
ments du parsing différent de notre approche, notamment les techniques as-
cendantes LR. Si possible, nous discuterons aussi les expressions régulières,
mais sans y trop insister.

Les techniques de construction s’appuyeront sur les monades en Haskell : les en-
tités connectées ensemble par des opérateurs (>>=) (bind) et (>>), et la fonction
return. Les monades dans ce contexte se ressemblent beaucoup à des continua-
tions, et c’est normal : l’analyse consomme le flot de données, et à chaque moment
il faut préciser ce que l’on fait ensuite, on continue la lecture, ou réduit les tokens
à un sous-arbre, etc.

Mais nous profiterons de cette occasion pour parler de monades dans un contexte
plus général, comme d’une réalisation fonctionnelle des concepts sématiques
liés au « calcul » général, évaluation des expressions, mais également des concepts
impératifs et du non-déterminisme logique (utile pour savoir comment on compile
Prolog). 149

Parseurs monadiques

Les techniques fonctionnelles, combinatoires du parsing appartiennent à la caté-


gorie des parseurs descendants, où on construit l’arbre syntaxique (physique ou
conceptuel) à partir de la racine. Les non-terminaux de la grammaire seront trans-
formés en fonctions d’analyse, et les paramètres de ces fonctions (ainsi que leurs
résultats) permettront de transmettre l’information sémantique. Pour le contraste :
le parseur construit auparavant, qui utilisait les opérateurs et leur précédences était
un parseur ascendant, on voit clairement comment on récupère les feuilles et com-
ment on les assemble en expressions.

Nous allons suivre quelques règles générales répertoriées ci-dessous. Ceci doit
permettre de construire des parseurs universels, de réagir aux situations exception-
nelles, et de réutiliser le code si on trouve des similarités entre plusieurs parseurs.
150
– Les parseurs consomment un flot de données d’entrée. Ceci peut être une
chaîne (liste de caractères), mais aussi une liste de tokens, ou un autre flot,
par exemple les tokens enrichis par des méta-informations comme la position
(ligne et colonne) d’un token dans le texte. Il faudra donc paramétrer les
parseurs par le type de son flot d’entrée.
– Le parseur doit retourner un résultat ainsi que le flot restant. Mais on ac-
cepte aussi la possibilité d’échec, ou le parsing ambigu : plusieurs résultats
possibles. Donc, on retourne une liste de paires (résultat,flot restant), comme
dans notre parseur avec précédences. L’échec c’est la liste vide.
– Sur le plan opérationnel le parseur est une fonction. Mais pour des raisons
techniques nous préférons de le considérer comme une donnée, un objet typé
spécifique. Pour qu’il agisse sur son flot d’entrée on utilisera un opérateur
d’application spécial, disons (-*>).
151

Définissons donc :

infix 0 -*>
newtype Parser c a = Pa ([c] -> [(a,[c])])

Pa pfun -*> inpt = pfun inpt

(Rappelons que newtype en Haskell peut être assimilé à data, mais son implan-
tation ressemble plutôt à type ; la balise joue uniquement le rôle d’identification,
la représentation interne du parseur est une fonction (et l’opérateur (-*>) tout
simplement l’applique, comme le dollar).

Le type a dénote le résultat ; les parseurs seront paramétrables et polymorphes.


Le type c décrit les items élémentaires dans le flot d’entrée : les caractères pour
le scanneur, les lexèmes pour le parseur, ou, p. ex. chunks de code intermédiaire
pour un « parseur » qui en fait est un générateur de code. 152
Les lexèmes classiques sont – par exemple –

data Lexem = I Integer | F Double | Lpar | Rpar | Comma | Semic


| Colon | Idn String | Lbrack | Rbrack | Op String
| Apo | Quot | Period | Str String

Ils doivent être distincts et reconnaissables. Le scanneur lexical est un parseur


universel restreint :

type Scanner = Parser Char Lexem

Ce scanneur doit s’appliquer à une chaîne, et fournir un lexème. Si nous vou-


lons intégrer notre compilateur comme une séquence de « boîtiers » formant une
« pipeline », il faudra placer les lexèmes dans une liste (paresseuse). 153

On devra donc construire une procédure qui lit un fichier textuel et produit une
liste de lexèmes. Ceci viendra un peu plus tard. Désormais les espaces seront
considérés comme séparateurs, coupant p. ex. les identificateurs, mais ils ne feront
jamais partie d’un lexème, sauf dans les chaînes littérales "Belle marquise
etc."

La philosophie générale qui va nous guider tout le temps est : un parseur qui
retourne un résultat de type a (avec tout le « bazar » : le input restant, et tout
emballé dans une liste) est analogique à IO a, c’est une action qui engendre un
objet de type a. On utilisera le terme token pour désigner un item primitif dans le
flot d’entrée.

Parseurs primitifs

Commençons par quelques parseurs très simples. Voici un parseur qui découpe et
retourne un token : 154
item = Pa (\(x:xs) -> [(x,xs)])

Attention. Cette définition est incomplète. Et si le flot d’entrée est vide? Le parseur
doit échouer (au lieu de déclencher une exception Haskell. . . )

Si on est là, nous pouvons imaginer des parseurs encore plus simples que ça !
Voici deux parseurs primitifs, l’un échoue toujours, et l’autre retourne un résultat
constant, sans lire quoi que ce soit.

failure = Pa (\_ -> [])


succeed x = Pa (\s -> [(x,s)]

Les deux seront très utils comme éléments des compositions. Parlant de composi-
tion : comme il a été suggéré, la chaîne de parseurs qui mastique et digère le flot
d’entrée, ressemble un peu les opérations d’entrée/sortie formulées dans le style
monadique. 155

Donc, nous voudrons réutiliser les mécanismes de composition définies pour la


monade IO. Concrètement, nous déclarons les parseurs comme des instances de
la classe Monad. Attention : le constructeur, Parser c sans « a » peut être mo-
nadique. IO est une monade, non pas IO Char etc.

instance Monad (Parser c) where


fail s = failure
return = succeed

Nous connaissons déjà return, l’échec existe aussi dans IO, car la lecture ou écri-
ture peuvent échouer. La définition est incomplète, il nous manque le compositeur
(>>=), mais construisons d’abord notre item

item = Pa itm where


itm (x:xs) = [(x,xs)]
itm [] = [] 156
La forme bind est intuitivement triviale. Un parseur produit un résultat. Une fonc-
tion récupère ce résultat, et en produit un autre parseur qui agira sur le flot d’entrée
restant. Mais – une seconde. . . et si le premier parseur échoue, ou s’il retourne
plusieurs résultats (et plusieurs flots restants possibles)?

Alors il faudra appliquer la fonction à droite du bind à tous résultats possibles,


et rendre la liste aplâtie. Sachant que la fonction produit un parseur générant une
liste d’un résultat primaire, individuel, ensemble nous aurons une liste de listes
qu’il faut mettre à plat.

instance Monad (Parser c) where


fail s = failure
return = succeed
(Pa prs) >>= fun = Pa
(\inp -> concat [fun v -*> inp1|(v,inp1) <- prs inp]) 157

La fonction concat est prédéfinie : concat = foldr (++) [], elle transforrme
[[a],[b,c],[d,e]] en [a,b,c,d].

Combinateurs élémentaires

Tout langage est structuré. Les chiffres forment des nombres, les lettres – identi-
ficateurs, etc. Il faut équiper notre boite de quelques utilitaires de reconnaissance.
Voici le parseur sat p où p est un prédicat Booléen, qui rend un item à condition
qu’il satisfait ce prédicat, sinon le parseur échoue.

sat p = item >>= \x -> if p x then return x else failure

Notez que ce parseur n’est plus élémentaire, mais une combinaison. cette combi-
naison est construite en toute indépendance du flot de données. Ce parseur est
la base de plusieurs autres outils de reconnaissance : lettres, chiffres, etc. 158
Voici un parseur (lit) qui vérifie l’identité d’un item (littéral), et quelques scan-
neurs lexicaux primitifs :

lit x = sat (\y -> x==y) -- {\em ou } sat (x==)

digit = sat (\x -> ’0’ <= x && x <= ’9’)

lower = sat (\x -> ’a’ <= x && x<=’z’)


upper = sat (\x -> ’A’ <= x && x<=’Z’)

Mais comment définir une lettre tout court, majuscule ou minuscule? Nous aurons
besoin d’un autre combinateur : l’alternative (comme dans le domaine de gram-
maires, n’est-ce pas?) 159

L’alternative dans le monde des parseurs existe en deux variantes :

– Logique, inclusive. On retourne tous les résultats produits par l’un ou par
l’autre parseur.
– Opérationnelle, ordonnée. On lance le premier parseur. S’il retourne un ré-
sultat, on l’accepte. Sinon on lance le second, et on prend son résultat. Ceci
est évidemment la solution privilégiée, plus efficace, à condition que les deux
parseurs s’excluent réciproquement.

Appelons le premier parseur, inclusif alt, et l’autre, séquentiel et plus efficace –


(#), c’est un opérateur infixe de précédence assez basse (mais supérieure à celle
du (-*>)). Voici leurs définitions. 160
infixl 1 #

alt (Pa p) (Pa q) = Pa (\inp -> p inp ++ q inp)


(Pa p) # (Pa q) =
Pa (\inp -> let s=p inp
in if s==[] then q inp else s)

Le parseur alt sera utilisé très rarement. Nous pouvons à présent construire

letter = lower # upper


alphanum = letter # digit

etc. On peut passer aux séquences qui permettront, p. ex. la construction des mots,
des nombres, etc. Il faut qu’un parseur suit un autre, ce qui implique des défini-
tions récursives, comme dans la définition syntaxique

Word → Letter | Letter Word 161

Commençons cependant par un parseur concret, plus simple, qui accepte la chaîne
« -*> ». La grammaire

AppOp → ’-’ ’*’ ’>’

ne nous dit rien à propos du résultat retourné. On retourne alors tout simplement
cette chaîne. Voici le parseur, qui accepte une chaîne de longueur quelconque,
mais qui exige que les trois premiers caractères forment le symbole désiré.

appop = lit ’-’ >>= \c1 -> lit ’*’ >>= \c2 ->
lit ’>’ >>= \c3 -> return [c1,c2,c3]

L’appel appop "-*>abc" rend [("-*>","abc")]. Mais – allons !. . . Si on sait


a priori quels sont les caractères qui doivent être lus, on n’a pas besoin de les
stocker. Nous pouvons les lire et oublier, et à la fin retourner le même résultat. 162
La définition ci-dessous serait plus compacte :

appop = lit ’-’ >> lit ’*’ >> lit ’>’ >> return "-*>"

Dans la classe Monad l’opérateur « ensuite » (>>) est définit de manière univer-
selle

m1 >> m2 = m1 >>= \_ -> m2

mais il est inutile de passer à la fonction à droite un argument jamais lu. Donc,
nous augmentons notre instance monadique par la définition plus explicite, un peu
plus efficace.

(Pa pr1) >> (Pa pr2) = Pa


(\inp -> concat [pr2 inp1 | (_,inp1) <- pr1 inp]) 163

Les mots composés de lettres seront assemblés par le parseur qui contient une
séquence (récursive), et une alternative. Voici une solution

word = (letter >>= \c -> word >>= \s -> return (c:s))


# (letter >>= \c -> return [c])

où on note immédiatemment une légère inefficacité. La dernière lettre sera lue


deux fois, car la première clause alternative finalement échouera. Il serait bien
d’accepter une règle d’or : l’optimisation d’un parseur commence souvent par
l’optimisation de la grammaire :

Word = Letter (Word | ∅)

Notez d’ailleurs que l’ordre inverse dans la clause récursive produirait un résultat
correcte, mais indésirable : le parseur lirait la première lettre et terminerait l’ana-
lyse, car la première alternative (vide) se termine bien et le parseur n’a aucune
raison de continuer. 164
Par contre, la construction avec alt fournirait toutes les solutions : une lettre,
deux, trois,. . . . Voici le parseur optimisé :

nullp = return ""


word = letter >>= \c ->
(word # nullp) >>= \s -> return (c:s)

Son application à "Belle marquise" retourne [("Belle"," marquise")],


l’espace arrête l’analyse, car letter échoue. On pourra utiliser la technique dé-
crite ici pour assembler toute simple séquence, par exemple les entiers composés
des chiffres. L’itération suit exactement le même modèle. Cependant dans ce cas
on n’est pas tellement intéressé par une chaîne de chiffres, on veut un nombre
entier comme résultat.

Nous allons donc construire un parseur d’ordre supérieur, un itérateur, qui ap-
plique séquentiellement un perseur plus primitif jusqu’à son échec, et qui lance
une fonction spéciale d’assemblage : une concaténation pour les chaînes, l’assem-
blage numérique pour les entiers, etc. 165

Le parseur élémentaire et la fonction d’assemblage seront des paramètres de notre


itérateur. Il manque tout de même un ingrédient : la valeur initiale du tampon
d’assemblage, disons "" pour les chaînes et 0 pour les nombres.

Voici une proposition avec une erreur !!

many p cnstr ini =


let loop = p >>= \x -> (loop # return ini)
>>= \s -> return (cnstr x s)
in loop

word = many letter (:) ""


integ = many digit (\d n -> 10*n+ord d - 48) 0

La fonction many est OK, et le parsing word -*> "Belle marquise" est cor-
rect. Cependant l’exécution de integ -*> "0102360X" produit [(632010,"X")]. . .
166
Notre fascination par la généralité de la solution nous a fait oublier que la règle ré-
cursive à droite n’est pas bonne pour un assemblage direct des nombres. Consultez
vos notes de TD !

Une solution de ce dilemme est évidente. Le parseur assemble toujours une chaîne
(une liste de tokens légaux), et à la fin, à l’extérieur de la boucle on lance une
procédure séparée de conversion. Ceci est plutôt inefficace.

Une autre possibilité, exploiter la récursivité à gauche, selon la grammaire

Word → Letter | Word Letter = (Word | ∅) Letter

etc., engendre un désastre imminent. Construisez le parseur. Le bind exécuté à


l’intérieur du word relance à nouveau word avant de consommer quoi que ce soit.
Le système boucle. . .

Ce problème reviendra lors du parsing des expressions arithmétiques, où l’asso-


ciativité gauche est inéluctable. 167

Rappelons-nous de la fonction construite en TD, qui construisait corretement un


entier à partir d’une chaîne numérique.

intf s = itf s 0 where


itf "" t = t
itf (c:s) t = itf s (10*t + ord(c)-48)

Il suffisait d’ajouter un tampon. . . L’itérateur universel many ne prévoit pas cette


possibilité. L’autre solution n’est pas plus compliquée que many, mais cette fois
le parseur qui boucle sera parametré par le tampon.

lmany p cnstr tmp = loop tmp where


loop t = p >>= \x -> let y = (cnstr x t)
in (loop y # return y)

integ = lmany digit (\d n -> 10*n+ord d-48) 0 168


On peut modulariser un peu plus cette solution, car la séquence de deux ou plu-
sieurs parseurs est un pattern commun, indépendamment de la récursivité (sans
ou avec tampon).

seqr p1 p2 cnstr ini =


p1 >>= \x -> (p2 >>= \s -> return (cnstr x s))
# return (cnstr x ini)
many p cnstr ini = loop where
loop = seqr p loop cnstr ini

seql p1 pp cnstr tmp =


p1 >>= \x -> let y = (cnstr x tmp)
in (pp y # return y)
lmany p cnstr = loop where
loop = seql p loop cnstr

Les parseurs word et integ restent sans modification. 169

D’habitude il est utile de considérer les espaces comme des caractères qui n’ap-
portent rien, et qui peuvent se trouver optionnellement devant un token sérieux.
Construisons le parseur optspaces p qui « avale » un nombre quelconque d’es-
paces qui précèdent l’item reconnu par le parseur p (qui retourne le résultat).

space = lit ’ ’ # lit ’\n’


optspaces p = optsp where
optsp = (space >> optsp) # p

Quelle est le résultat de cette expérience :

wordlist = many (optspaces word) (:) []

res = wordlist -*> "Belle marquise vos 876 yeux d’amour" 170
Analyse d’un fichier

Avant de passer aux parseur plus complexes, discutons l’usage de nos parseurs
non pas pour analyser une chaîne donnée, mais pour lire et digérer un fichier
complet. Rappelons-nous : quand on commence à lire un fichier, ou – en général –
à s’engager dans les activités I/O, on passe à un protocole « impératif », séquentiel
de Haskell, on « entre » dans la monade IO, et on ne peut pas en sortir « comme
ça ». Si on commence par l’ouverture d’un fichier, cette manipulation produit un
descripteur du fichier, de type IO Handle, Ensuite nous pouvons construire un
flot paresseux de caractères qui correspond au contenu de ce fichier, mais toutes
les opérations auront lieu dans ce « programme principal » IO. Essayons donc de
découper en mots un fichier declar.txt, qui contient :

Tous les etres humains naissent libres et égaux en dignité


et en droits. Ils sont doués de raison et de conscience et
doivent agir les uns envers les autres dans un esprit de
fraternité. 171

L’affichage du résultat est obtenu en lançant la fonction filewords définie ci-


dessous.

filewords = do
hndl <- openFile "declar.txt" ReadMode
str <- hGetContents hndl
let lst = wordlist -*> str
print lst
hClose hndl

où nous avons ajouté à l’ensemble de lettres auusi le « é » accentué, et le point.


Cette fonction ne retourne rien. Nous pouvons ajouter quelque chose, comme
return lst, mais ceci ne changera rien, on ne pourra utiliser directement ce
résultat, car il restera emballé dans la monade IO. 172
Vraiment, toutes les opérations sur le texte lu doivent rester dans le bloc do. Le
résultat imprimé est

[(["Tous","les","etres","humains","naissent","libres","et",
"\233gaux","en","dignit\233","et","en","droits.","Ils","sont",
"dou\233s","de","raison","et","de","conscience","et","doivent",
"agir","les","uns","envers","les","autres","dans","un","esprit",
"de","fraternit\233."]," ")]

Notez que le système d’affichage standard de Haskell n’aime pas tellement les
caractères hors ASCII. Comment y rémédier?

En fait, en disant que tout doit se trouver alors dans le bloc do ci-dessus nous
avons menti. . . 173

Dans un autre variante, la fonction filewords n’imprime rien, mais on lui ajoute
comme sa dernière instruction est return lst. C’est un résultat monadique,
donc une action, invisible. Ensuite on définit

printwords = filewords >>= print

et notre liste peut être affichée. Mais elle ne le sera pas pour des raisons tech-
niques pertinentes à Haskell !

Le fait que Haskell est un langage paresseux doit être toujours présent à l’es-
prit ! Un langage paresseux ressemble un peu un langage logique, au sens d’être
goal-oriented, orienté vers le but, vers le résultat final, qui déclenche les activités
nécessaire à sa génération. Uniquement quand on exécute printwords, la fonc-
tion filewords est lancée. Toutes les instructions qui engendrent lst (p. ex. sa
lecture) restent latentes jusqu’à la demande d’imprimer lst. 174
Mais si avant cette impression on ferme le fichier par hclose hndl, la lecture
échoue. On doit le laisser ouvert, et fermer plus tard, donc dans un programme
professionnel la fonction qui imprime, ou fait quelque chose avec des données
lues, doit recevoir comme paramètres toutes les handles des fichiers ouverts, pour
pouvoir dûment nettoyer le contexte du travail.

Nous avons terminé – pour l’instant – la discussion des parseurs « lexicaux »,


relativement simples, itératives. Les parseurs syntaxiques peuvent être beaucoup
plus élaborés, et nous verrons d’autres combinateurs universels. Cependant nous
reviendrons encore une fois à la couche de base, la lecture du fichier et l’assem-
blage des tokens lexicaux, car il faudra répondre au moins à la question : comment
diagnostiquer une faute dans le programme source? Comment tranmettre à l’utili-
sateur l’information où l’erreur a été découverte? 175

Parsing phrasal général


Cette fois notre volonté d’avoir toujours une relation immédiate entre la gram-
maire et le parseur correspondant est encore plus forte. Commençons par un sous-
ensemble très réduit d’expressions algébriques, avec l’addition, la multiplication
et les parenthèses. Les opérateurs impliqués sont associatifs et symétriques, donc
on pourra prétendre qu’ils soient associatifs à droite.

On essaira de construire des arbres syntaxiques comme déjà vus :

− c

a b

à partir de la forme textuelle. 176


Cependant on passera d’abord par la couche lexicale, qui construit un flot de
lexèmes définis auparavant. Il faut alors que le scanner soit capable de discerner
et de retourner un Lexem quelconque :

Lexem → Integer | Double | Ident | Comma | Lpar | ...

D’abord, rappelons la structure de données qui représentera le résultat sortant :

data Lexem = I Integer | F Double | Lpar | Rpar | Comma | Semic


| Idn String | Lbrack | Rbrack | Op String | Apo
| Quot | Period | Str String deriving(Eq,Show)

où, naturellement, les symboles purs comme la parenthèse ouvrante se réduisent à


la balise, mais les nombres ou les identificateurs doivent être transmis comme ob-
jets possédant un type et un contenu. (On devrait, d’ailleurs, déjà ici introduire un
sous-ensemble très important des symboles parametrés : les mots-clefs, différents
des identificateurs). 177

L’instance Show pour les lexèmes est redondante, uniquement pour des tests vi-
suels. L’instance Eq peut être essentielle !

Le parseur des expressions ne lira pas directement des chaînes de caractères, mais
des listes lexicales. Construisons le module correspondant.

Scanneur lexical relativement complet

Quelques caractères spéciaux se transforment en simples balises :

tagch c t = sat (c==) >> return t

lpar = tagch ’(’ Lpar


rpar = tagch ’)’ Rpar
comma = tagch ’,’ Comma
apostr = tagch ’\’’ Apo
quote = tagch ’"’ Quot 178
etc. On considère que les opérateurs sont formés uniquement des caractères spé-
ciaux spécifiques. On introduit donc une séquence de caractères discriminés par
l’appartenance à un ensemble précis (lettres, etc.), de manière plus générale qu’au-
paravant, et on ajoute un parseur-constructeur, qui retourne une chaîne balisée :

charin l = sat (\y -> any (y==) l)


chain p = many p (:) []
tagit = (return .)

opchar = charin "+-*/=<>#%:!?"


oper = chain opchar >>= tagit Op

ident = seqr letter (chain alphanum) (:) "" >>= tagit Idn
intnum = integ >>= tagit I 179

La notation est très compacte, et si quelqu’un pense à apprendre tout cela durant
l’examen, je lui souhaite bon courage. . .

Mais tout est simple. Rappelons que any est le « ou » logique itéré sur une liste ; on
termine l’itération si on trouve le premier élément « vrai » par rapport au prédicat.

Le parseur chain est un cas trivial de many, où la construction du résultat se


réduit à la concaténation.

Le parseur-baliseur tagit mérite quelques mots. Sa définition expansée est :

tagit balise objet = return (balise objet)

Ensuite, rappelons encore une fois le parseur integ. Il est un peu différent de la
version précedente à cause du fait que ord retourne un Int, et nous avons besoin
d’un Integer. 180
integ = lmany digit (\d n -> 10*n+toInteger(ord d)-48) 0

Finalement, nous construisons un lexème générique comme l’alternative des cas


particuliers, en ajoutant le consommateur d’espaces. La dernière étape est l’itéra-
tion de ce parseur, ce qui génère une liste de lexèmes.

lexem = foldr1 ((#) . optspaces)


[lpar,rpar,ident,intnum,oper,comma]
lexlist = chain lexem

et le test

res0 = lexlist -*>


"alph0 + (x+17/12)>=(x*6++d1) - beta"

donne 181

[([Idn "alph0",Op "+",Lpar,Idn "x",Op "+",I 17,Op "/",


I 12,Rpar,Op ">=",Lpar,Idn "x",Op "*",I 6,Op "++",
Idn "d1",Rpar,Op "-",Idn "beta"],"")]

La couche lexicale présentée ici n’est pas – évidemment – complète. Il faut faire
quelque chose en TD, et réserver un peu du mystère pour l’examen. Cependant,
tous les ingrédients de reconaissance et d’assemblage sont là. On n’a pas discuté la
gestion des erreurs ni l’intéraction entre le scanneur et la table des symboles (pour
ne garder les identificateurs qu’en un seul exemplaire). Tout ceci est à découvrir
aussi à travers votre travail personnel.

On passe aux structures syntaxiques générales. 182


Structures algébriques simplifiées

Notre grammaire aura la forme suivante :

Expr → Term | Term ’+’ Expr


Term → Factor | Factor ’*’ Term
Factor → Ident | ’(’ Expr ’)’

et elle sera la base d’un parseur qui construit des expressions du type

data Expr = Dat String | Eop Ops Expr Expr


deriving (Eq,Show)

data Ops = Add | Sub | Mul | Div | Pow | Lth | Gth | Equ | Leq
deriving (Eq,Show) 183

presque comme auparavant. Cette fois le noeud d’une expression au lieu de conte-
nir la chaîne "+", etc, contient un opérateur symbolique, la balise Add, etc. On
prolifère les balises, mais ainsi le processus de reconnaissance devient plus simple.

Comme il a été dit, le parseur lira une liste de lexèmes balisés, générée par l’ité-
rateur lexlist, p. ex.

lexpr = lexlist -*> "a+b0*(a1+b2*psi)*(b*(c+x))"

La seconde couche du parsing demandera quelques utilitaires comme la procédure


de recherche des opérateurs dans un environnement :

listop = [("+",Add),("*",Mul),("/",Div),("^",Pow),
("<",Lth),(">",Gth),("==",Equ),("<=",Leq)] 184
opfind st ((x,opt):rst) | st==x = opt
| otherwise = opfind st rst
opfind st [] = error ("operator: " ++ show st)

ainsi que quelques abbréviations, facilitant l’identification des lexèmes.

addop = lit (Op "+") >> return Add


mulop = lit (Op "*") >> return Mul

lparen = lit Lpar >> return (Dat "")


rparen = lit Rpar >> return (Dat "")

Les deux dernières définitions sont naives, on retourne une expression bidon uni-
quement pour satisfaire le système de types, et assurer le compilateur que lparen
et rparen sont des parseurs de type : Parser Lexem Expr. 185

Le parseur principal est une traduction presque littérale de la grammaire ; la seule


chose intéressante ce sont les éléments sémantiques qui assemblent le résultat
final. La seule chose gênante c’est la lisibilité, toujours médiocre si un fragment
de programme contient trop de lambdas. . . :

atom = item >>= \x-> case x of


(Idn y) -> return (Dat y)
_ -> failure

expr = term >>= \t ->


((addop >>= \o ->
expr >>= \s -> return (Eop o t s))
# return t) 186
term = factor >>= \t ->
((mulop >>= \o ->
term >>= \s -> return (Eop o t s))
# return t)

factor = atom # (lparen >> expr >>= \e -> rparen >> return e)

cependant, grâce aux combinateurs déjà connus comme seqr, on peut simplifier
considérablement ces expressions.

Le résultat du parsing est :

[(Eop Add (Dat "a") (Eop Mul (Dat "b0") (Eop Mul
(Eop Add (Dat "a1") (Eop Mul (Dat "b2") (Dat "psi")))
(Eop Mul (Dat "b") (Eop Add (Dat "c") (Dat "x"))))),[])]

et il suffit d’appliquer notre pretty-printer pour voir si le parsing est correct. 187

Récursivité à gauche

Avec les règles récursives à droite, ou avec les éléments imbriqués, comme les
sous-expressions parenthésées ou des listes hiérarchiques, il n’y a plus de pro-
blèmes. Nous pouvons p. ex. analyser les listes en Lisp/Prolog, les séquences
d’instructions avec des blocs, les segments balisés en HTML/XML, etc.

Cependant pour la « vraie » arithmétique, la grammaire possède des productions


récursives à gauche, à cause de l’associativité dans :

Expr → Term | Expr Addop Term

Comme nous avons déjà mentionné, la clause à droite (la plus longue) doit être
essayée en premier, et l’opérateur (>>=) relance Expr avant de lire l’item suivant.
La parseur ne progresse pas, il boucle. 188
La solution est simple, mais demande une très bonne connaissance de la séman-
tique du résultat final, ainsi qu’une connaissance des astuces formelles dans le
domaine du parsing. Commençons par la syntaxe.

Normalisation de Greibach

Il a été démontré formellement que toute grammaire avec des productions récur-
sives à gauche peut être transformée en grammaire récursive à droite équivalente
(qui reconnaît le même langage ; nous parlons ici uniquement de la syntaxe, non
pas de l’interprétation, qui demandera un peu de réflexion).

Supposons que la grammaire analysée possède la règle suivante :

N → a1 | a2 | ... | ak | N b1 | N b2 | ... | N bl

Le raisonnement de Greibach est le suivant : 189

Toute instance du flot d’entrée reconnue par cette grammaire doit commencer par
un symbole de la famille a (il peut être composite). Toutes les formes b doivent
être non-nulles, sinon la règle est pathologique (N → N). On affirme, que la gram-
maire suivante est équivalente :

N → a1 M | a2 M | ... | ak M
M → b1 M | b2 M | ... | bl M | ∅

Par exemple :

Expr → Term TermSeq


TermSeq → Addop Term TermSeq | ∅

ce qui devrait nous rappeler des formes déjà vues. De même manière on trans-
forme Term → Factor | Term OpMul Factor en 190
Term → Factor FactSeq
FactSeq → OpMul Factor FactSeq | ∅

Le problème syntaxique a été résolu. Cependant un problème conceptuel se pose :


dans la version précédente nous avions une correspondance entre les non-terminaux
de la grammaire, et les noeuds de l’arborescence syntaxique résultante du parsing.
Ici on a introduit un symbole artificiel, les variables TermSeq et FactSeq, même
si intuitivement leur signification est claire, ne correspondent à aucun noeud.

On voit cela un peu mieux si on re rend compte que la forme Addop Term
TermSeq ... commence par un opérateur, alors c’est une expression incom-
plète !

Nous allons utiliser exactement le même astuce que dans notre modèle de parseur
basé sur les précédences. La variable TermSeq aura besoin d’être complétée, alors
le parseur termesq aura besoin d’un paramètre, de son contexte gauche. Voici le
parseur résultant. : 191

expr = term >>= termseq


termseq c = (addop >>= \o ->
term >>= \t -> termseq (Eop o c t))
# return c

term = factor >>= factseq


factseq c = (mulop >>= \o ->
factor >>= \t -> factseq (Eop o c t))
# return c
factor = atom # (lparen >> expr >>= \e -> rparen >> return e)

Il transforme l’expression "a+b0*(a1-b2*psi-c)*(b/(c+x+y)/f)" en

[(Eop Add (Dat "a") (Eop Mul (Eop Mul (Dat "b0") (Eop Sub
(Eop Sub (Dat "a1") (Eop Mul (Dat "b2") (Dat "psi")))
(Dat "c"))) (Eop Div (Eop Div (Dat "b") (Eop Add
(Eop Add (Dat "c") (Dat "x")) (Dat "y"))) (Dat "f"))),[])] 192
Il serait peut-être utile de visualiser graphiquement un parseur-séquence résultant
de la normalisation de Greibach. La partie encerclée du graphe ci-dessous est
obligée de couper un arc, qui représente le paramètre, le contexte gauche..

− ×

a b d f

Nous avons terminé la description des parseurs combinatoires essentiels. On


sait comment parser les structures itératives et récursives à gauche, et comment
assembler le résultat. Nous avons exploité souvent le bind, mais il ne faut pas
hésiter à utiliser les blocs do : 193

termseq c = (do
o <- addop
t <- term
termseq (Eop o c t) ) # return c

Ils sont utiles aussi en dehors de la monade IO.

Parseurs positionnels
Supposons que le parseur lexical passe à sa continuation, le parseur phrasal pas
seulement les tokens, mais aussi – si telle est la volonté de l’utilisateur – la posi-
tion : (ligne,colonne) de chaque token.

Tout parseur primitif comme item, ou lit qui consomme un caractère doit per-
mettre au mécanisme de lecture d’incrémenter les compteurs correspondants. Ce-
pendant, nous ne voulons pas que cette information soit « pompée » inutilement
sur le flot de sortie. Nous voulons qu’elle soit accessible en cas de besoin. 194
Il faudra rédéfinir l’état de la machine qui consomme le flot d’entrée (la chaîne
de caractères). Par exemple, nous pourrons définir un item dédié aux chaînes de
caractères

data Cstate c = Cs Integer Integer [c]


newtype Parser c a = Pa (Cstate c -> [(a,Cstate c)])

itchar = Pa itm where


itm (Cs col row (x:xs)) = [(x,Cs c1 r1,xs)] where
(c1,r1) = if x==’\n’ then (0,row+1) else (col+1,row)
itm (Is _ _ []) = []

mais un problème d’universalité se pose : pour les parseurs qui agissent sur un flot
de lexèmes déjà construit, ou sur encore une autre structure, peut-être il n’est pas
souhaitable de garder ces informations. Les modifications toucheront le reste du
paquetage. 195

Nous ne pouvons discuter en détails toutes les modifications possibles. Quelques


détails (sans commentaires) se trouvent dans le fichier newparse.hs, stocké dans
le même endroit que ces notes. Voici le parseur qui récupère la position courante
(sans lire quoi que ce soit, alors on peut le lancer même après avoir épuisé le flot
d’entrée) :

pos = Pa ppos where


ppos ss@(Cs col row s) = [((col,row),ss)]

Nous pouvons précéder le parser word etc. par pos, et ensuite utiliser cette infor-
mation à notre guise.

Nous reviendrons encore sur la problématique du parsing, en particulier dans le


contexte de gestion des attributs qui constituent l’information sémantique du flot
traité. Cependant, vu le flou autour du bind et autres concepts monadiques, il nous
semble utile de parler un peu de la sémantique calculatoire en général 196
Intermezzo monadique
Le « protocole » de la programmation fonctionnelle pure est très simple. Nous
avons des expressions composées des données (primitives ou pas), auxquelles on
applique des fonctions, en construisant d’autres expressions : x → f (x). La seule
opération magique est l’application fonctionnelle (indépendamment de la possibi-
lité de réaliser un système calculatoire fonctionnel dans le cadre du calcul lambda
de Church. . . ).

Dans la pratique on a vu déjà d’autres entités/catégories plus complexes :

– Évaluation d’une expression peut échouer. Ceci peut se dérouler de manière


« douce », on retourne un objet qui constate l’échec, et on interdit aux autres
fonctions de s’y apopliquer. Ceci peut se dérouler de manière « forte », en
déclenchant une exception, la rupture du flot de contrôle existant. Ceci peut
être généralisé : 197

– Dans le cadre de programmation nondéterministe (Prolog ou parsing géné-


ral), une expression peut résulter en plusieurs résultats possibles (ou aucun).
– Une expression peut être « incomplète » ; elle ne sera évaluée que si on pré-
voit un mécanisme de récupération de la valeur – la continuation. Une donnée
cesse d’être statique, mais devient un objet fonctionnel qui prend comme ar-
gument cette continuation.
– Le concept de continuation peut être beaucoup plus élaboré que cela. Scheme
et quelques autres langages permettent la construction des continuations de
première classe ; on peut, au milieu d’évaluation d’une expression deman-
der la création d’un objet qui représente le « futur » de ce calcul. Cet ob-
jet peut être transmis ailleurs, et exécuté, ce qui provoque la restauration du
contexte de sa création. Le programme « redémarre » en complétant l’éva-
luation de l’expression d’origine. Ce mécanisme peut être utilisé pour créer
des co-procédures, et organiser un système de simulation des processus pa-
rallèles. 198
– On peut imaginer qu’une expression ou une donnée possède un « état in-
terne » qui est modifié par le programme. Ceci est le trait caractéristique le
plus important de la programmation impérative, explicitement non-fonctionnelle.
– Une version très concrète de ce contexte est spécifiée par les entrées/sorties.
Quand on lit un fichier, quand on écrit quelque chose, on modifie « l’état
du monde ». Sur le plan strictement fonctionnel c’est comme si on créait un
nouveau monde avec les modifications, en « oubliant » le monde précedant.
Bien sûr, ceci n’est pas réalisable dans la pratique, il faut savoir modifier le
monde donné, qui n’existe qu’en un seul exemplaire.
– (On peut aussi vouloir avoir des variables internes modifiables, pour des rai-
sons d’efficacité.)
– On peut vouloir ajouter à un programme fonctionnel des effets de bord, par
exemple tracer le programme en processus du débogage. Encore une fois, il
s’agit d’ajouter une couche impérative à un programme fonctionnel. 199

On peut continuer cette liste, mais on voit déjà que le monde de programmation
est beaucoup plus riche que la vision de la programmation fonctionnelle enseignée
en Licence. Pourtant, nous affirmons que la programmation fonctionnelle est
suffisamment riche pour spécifier et implémenter ces catégories de calculs,
et le mot-clé concerné est : la Monade, un concept qui appartient à la théorie des
catégories, et qui a été introduite – relativement récemment – dans la théorie de
programmation par Eugenio Moggi.

Les monades dans le monde de compilation sont vraiment irremplaçables, on les


voit partout !

Une Monade (au sens spécifique, exploité ici) est un constructeur de données qui
« emballe » une donnée en quelque chose qui peut être appelée le « calcul » (ang. :
computation). La Monade triviale correspond à la programmation fonctionnelle
pure. Une expression « emballée » ou « liftée » au domaine des monades reste
intacte. Les transformations des objets monadiques sont des simples fonctions. 200
Pour définir une monade m sur un type quelconque a il faut définir une fonc-
tion polymorphe return du type a -> m a qui effectue ce lifting. Le nom m est
icipurement symbolique, générique. On peut avoir la monade IO ou la monade
Parser ..., ou tout autre chose. Pour la monade triviale return ≡ id, et le
constructeur-lifteur est inexistant.

Deuxième ingrédient indispensable pour la définition d’une monade est l’opéra-


teur bind : >>= qui sait comment appliquer une fonction à un objet monadique. Le
résultat de l’pplication doit également être un objet monadique. Pour la monade
triviale x >>= f ≡ f x. En général le type de cet opérateur est :

(>>=) :: Monad m => m a -> (a -> m b) -> m b

Troisième ingrédient qui n’a pas de nom propre unique, et qui parfois n’est pas
utilisé, est la fonction d’« aplâtissement » qui transforme un objet de type m(m a)
en m a, une sorte de concat généralisé. 201

On ajoute à cette panoplie un opérateur de séquencement (>>) qui peut combiner


deux objets monadiques en un seul, et finalement une fonction fail :: String
-> m a qui n’appartient aux monades mathématiques, mais qui peut être très utile
sur le plan pratique. Très souvent, quand un type de constructeur est monadique,
il est également l’instance de la classe Functor qui définit une application géné-
ralisée, la fonctionnelle fmap, similaire conceptuellement au map sur les listes.

On peut passer aux exemples non-triviaux. L’exemple classique d’une monade


« échec » et le type Maybe. Rappelons le, et ses propriétés monadiques :

data Maybe a = Nothing | Just a


deriving (Eq, Ord, Read, Show)

instance Functor Maybe where


fmap f Nothing = Nothing
fmap f (Just x) = Just (f x)
202
instance Monad Maybe where
Just x >>= k = k x
Nothing >>= k = Nothing
return = Just
fail s = Nothing

En général dans la classe Monad (comme dans toute autre) on peut définir des
méthodes par défaut qui sont valables sauf si on les redéfinit dans une ou plusieurs
instances. Ainsi, nous pouvons préciser que

m1 >> m2 = m1 >>= \_ -> m2

La « suite » c’est un bind qui ignore le résultat passé. Mais parfois on peut opti-
miser cette définition. 203

Dans Maybe l’aplâtissement (la concaténation) est simplement Just (Just x)


→ Just x. Le bind du Nothing avec une fonction quelconque donne toujours
Nothing, les fonctions refusent de s’appliquer à « l’échec ».

Alors si dans un programme fonctionnel typique on remplace toutes les appli-


cations f x par (lift x) >>= (lift f), ou « lift » est un convertisseur
(générique, symbolique, il doit être concrétisé selon la sémantique voulue !) des
objets « normaux » en monadiques, on obtient un programme enrichi. La monade
Maybe permet de neutraliser les erreurs, en propageant l’échec. Avant de passer
aux autres monades, un rappel important :

Les monades ne vous donnent directement aucun outil pour résoudre vos
problèmes. Elles permettent de les formuler de manière universelle et sou-
vent intuitive. Ce qui est réellement important ce sont les fonctions concrètes qui
opèrent sur les objets monadiques. Chaque monade a sa spécificité, et personne
ne remplacera l’utilisateur, le seul qui sait ce qu’il veut. 204
La monade nondéterministe

Cerci est une généralisation de la monade Maybe, et son constructeur n’est rien
d’autre que le constructeur des listes []. Sa sémantique est la suivante : au lieu
de rendre un résultat, la fonction peut être nondéterministe et en rendre plusieurs,
éventuellement un seul, ou aucun. Dans ce dernier cas nous aurons l’échec. La mo-
nade est partiellement basée sur Maybe au sens que bind propage l’échec. Comme
auparavant, la paresse du langage sera ici très importante.

La liste qui représente l’objet monadique non-déterministe contient toutes les so-
lutions d’un problème. En parcourant cette liste nous pouvons traiter une par une
ces solutions. La liste peut même être infinie (potentialement), si telle est la sé-
mantique du problème. Nous savons déjà que les listes constituent un instance du
Functor, où fmap ≡ map. Voici l’instance de la classe Monad : 205

instance Monad [ ] where


(x:xs) >>= f = f x ++ (xs >>= f)
[] >>= f = []
return x = [x]
fail s = []

Notez bien que bind peut être exprimé de manière plus générique. D’abord on
applique f à tous les éléments de la liste-source, mais ensuite, à l’instar de map,
au lieu de cons’er les résultats, on les concatène avec (++). Donc :

l >>= f = concat (map f l)

à titre d’exemple, transformons un problème nondéterministe classique, la géné-


ration de toutes les permutations d’une liste, en un programme monadique, de
Prolog vers Haskell. Le prédicat original en Prolog est présenté ci-dessous. 206
Commençons par un prédicat nondéterministe ndinsert qui peut insérer un objet
X dans une liste n’importe où.

ndinsert(X,L,[X|L]).
ndinsert(X,[Y|Q],[Y|R]):-ndinsert(X,Q,R)

permut([ ],[ ]).


permut([X|Q],R):-permut(Q,R1),ndinsert(X,R1,R).

(Si la liste est vide, ndinsert ne peut que mettre X à la tête ; la seconde clause
du ndinsert est inactive). Les permutations sont générées de manière intuitive.
On produit une permutation de la queue, et on insère la tête dans une position
quelconque. 207

La solution Haskell doit être soigneuse pour être optimale, mais nous proposons
une translation presque directe, moins efficace, mais plus lisible sur le plan mo-
nadique. La présence de deux ou plusieurs clauses compatibles en Prolog ne se
traduit pas en plusieurs clauses en Haskell ; celles-là sont des réelles alternatives
contradictoires. Par contre, on concatène les résultats partiels en un résultat non-
déterministe complet. Le résultat est très court.

ndinsert x [] = return [x]


ndinsert x l@(y:yq) =
let l1 = return (x:l)
l2 = ndinsert x yq >>= \p -> return (y:p)
in (l1 ++ l2)

permut [] = return []
permut (x:xs) =
permut xs >>= ndinsert x 208
La monade des continuations (CPS)

Le principe nous est connu. Au lieu d’avoir une donnée, nous traiterons un objet
fonctionnel dont le paramètre est le « futur » du calcul. Toute fonction monadique
appliquée par bind reconstruit une expression continuée. La construction explicite
et complètement générique n’est pas possible, car le « vrai » résultat final est
différé jusqu’à la sortie de la chaîne monadique. On peut lifter une simple donnée
par

lift0 x = \c -> c x

mais on n’a aucune chance de savoir quel est le type retourné par lift0. La
construction peut être donc la suivante (ce n’est pas la seule possibilité). D’abord
nous allons spécifier le type du résultat, p. ex type Ans = Double. Définissons
un type synonymique restreint qui représente un objet continué : 209

type Ans = Double


newtype Cnt a = Cnt ((a->Ans) -> Ans)

Ensuite introduirons les fonctions du lifting, p. ex. lift0 qui monadise les va-
leurs, et lift1 qui monadise les fonctions à un paramètre.

lift0 x = Cnt (\c -> c x)


lift1 f x = Cnt (\c -> c (f x))

(Bien sûr, lift0 = lift1 id). On peut définir quelques fonctions et valeurs
liftées

cq = lift0 5.0
csqrt = lift1 sqrt
ccos = lift1 cos 210
L’instance monadique de notre type se réduit à

app f a = g where Cnt g = f a

instance Monad Cnt


where
return = lift0
Cnt m >>= f =
Cnt (\c -> m (\a -> app f a c))

La balise Cnt et la fonction app sont strictement accessoires et redondantes,


présentes uniquement pour satisfaire le typage. À présent la composition fonc-
tionnelle cos(sqrt(5.0)) et le résultat numérique final seront exprimés par

xpr = cq >>= csqrt >>= ccos


res = r id where Cnt r = xpr 211

Pour les opérateurs binaires et les fonctions (opérateurs) binaires, les constructions
sont un peu plus compliquées, mais seulement techniquement.

Si on change le type du résultat final Ans, il faudra re-coder la totalité. Haskell


ne permettra pas de définir les types ci-dessus en remplaçant Ans par un type
générique b.

La monade des états

Cette monade est assez complexe. À chaque valeur dans le programme nous as-
socions un « état interne », une entité invisible, mais qui peut changer, et qui
normalement change lors de chaque opération. Une donnée est liftée au domaine
des actions, un peu comme dans la monade IO, mais spécifiées différemment.

Une valeur x de type a sera liftéé vers un type fonctionnel : st -> (a,st).
Ceci signifie que l’objet monadique agira sur un état, en produisant une valeur
concrète, et un nouvel état. 212
Une fonction monadifiée agissant sur un objet m (à travers bind) construit un nou-
vel objet monadique : une fonction d’état initial. L’objet m agit sur cet état initial,
et produit une valeur concrète appariée avec un état intermédiaire. La fonction
récupère la valeur et produit un objet monadique censé de s’appliquer à l’état
intermédiaire et produire l’état final.

Voici la définition essentielle de la monade de transformateurs d’états :

newtype Stc st a = Stc (st -> (a,st))

instance Monad (Stc st) where


return x = Stc (\s -> (x,s))
Stc m >>= f = Stc (\ini -> let (x,mid) = m ini
Stc k = f x
in k mid) 213

Souvent, pour la documentation et l’enseignement, on laisse tomber la balise arti-


ficielle qui caractérise le newtype. Alors le type monadique est tout simplement
une fonction \s -> (x,s). Le bind est très simplifié :

(m >>= f) ini = case m ini of (x,mid) -> f x mid

Exemple? Mais on la connaît, cette monade, un parseur en est un exemple. Les


parseurs combinent le non-déterminisme et l’état. Si on déclare que tout parseur
rend un et un seul résultat, les définitions monadiques subissent les simplifications
suivantes :

newtype Parser state a = Pa (state -> (a,state))


Pa pfun -*> inpt = pfun inpt
-- pas de failure !
succeed x = Pa (\s -> (x,s)) 214
instance Monad (Parser c) where
return = succeed
(Pa prs) >>= fun = Pa
(\inp -> fun v -*> inp1 where (v,inp1) <- prs inp)

où nous avons à peine éliminé quelques crochets et concat.

La monade du tracing

Le modèle est un peu différent du tracing ajouté à notre machine virtuelle (le
modèle précédent permet aussi une formulation monadique, mais c’est un bon
sujet pour votre travail individuel). Ici la paresse du langage Haskell peut jouer
un rôle important. Le lifting monadique d’une valeur x sera une paire (x,s), où s
est une chaîne qui est crée quand x est formé. Cette chaîne contient un message
informatif. 215

Le return peut être très simple, p. ex. return x = (x,""), ou, si on le veut :
return x = (x,show x).

Rappelons encore une fois : les monades ne nous disent rien concernant l’im-
plantation concrète des instances concernées. Elles permettent de structurer le
programme. Cependant, c’est à l’utilisateur de définir return, bind, etc., cas par
cas, selon la sémantique voulue.

Donc, nous pouvons définir un bind générique :

(x,s) >>= f = let (y,s’) = f x


in (y,s++s’)

qui ajoute un nouveau texte au précédant quand la fonction f est appliquée. Ce-
pendant, quel est ce texte (par exemple, il peut contenir la signature de la fonction,
ainsi que la forme textuelle de l’argument et du résultat) dépend de la fonction f. 216
Analyse sémantique générale
Attributs

Les techniques descendantes, tels que l’approche monadique, parfois causent des
problèmes, comme la nécessité d’introduire la normalisation de Greibach, et des
non-terminaux accessoires, mais leurs avantages sont également visibles. En par-
ticulier, il est facile de passer l’information en deux sens, de la racine vers les
feuilles, à travers des paramètres des parseurs, et des feuilles vers la racine, en
construisant le résultat. Nous avons déjà vu les deux catégories d’attributs.

En général, le résultat constitue l’attribut sémantique principal d’un symbole


syntaxique. La génération du code est la composition des attributs sémantiques.
Le résultat est un attribut synthétisé, l’information qui passe de droite vers la
gauche, si le parseur correspondant à une production genre

N → A1 A2 ... An

est appliquée. 217

Pour les parseurs descendants cette information est assemblée lors du retour de la
procédure du parsing. Les parseurs ascendants (discutés brièvement un peu plus
tard) combinent les informations de manière itérative, et non pas récursive, mais
le résultat est le même.

Voici quelques mots sur l’analyse syntaxique augmentée par l’analyse séman-
tique : par le jeu des attributs. Rappelons encore une fois une grammaire qui
construit des entiers. Il nous faudra ajouter des indices identifiant les non-terminaux
sémantiquement différents, appartenant à la même catégorie syntaxique.
N0 → Da
N0 → DbN1
Notez que notre grammaire est récursive à droite, pour varier. . . L’attribut qui nous
intéresse est la valeur de N0, disons N0.v . (On peut penser que sémantiquement
chaque non-terminal correspond à un record avec plusieurs champs, qui stockent
les attributs). 218
Il est évident que la valeur finale dépend des valeurs à droite des productions, mais
ces valeurs sont liées entre elles ; par exemple la « valeur concrète » de Da dépend
de sa position dans la chaîne (unités, dizaines, centaines . . . ).

Introduisons donc l’attribut p comme position, ainsi que l’attribut longueur l,


puisque la position d’un chiffre dépend de la longueur de son voisin à droite.
Ajoutons un attribut fondamental de chaque chiffre : son code, sa valeur absolue
D.c (d’habitude son code ASCII - 48). Bien sûr, la longueur d’un chiffre est 1. Il
est facile de constater que les équations suivantes ont lieu :

1. Pour la première alternative :

N0.l = 1
N0.v = Da.c
219

2. Pour la seconde :
Db.p = N1.l
Db.v = Db.c · 10Db.p
N0.l = 1 + N1.l
N0.v = Db.v + N1.v

et en principe il est facile de construire le parseur correspondant, qui retourne une


valeur composite (n,l) pour un entier. Pour un chiffre il est facile de retourner le
code, mais attention : le parseur chiffre défini ci-dessous ne pourra en aucun cas
retourner la valeur positionnelle (relative) d’un chifre, car ceci est une information
contextuelle. Supposons que le résultat est un nombre entre 0 et 9. Voici le parseur
déduit de notre raisonnement :

entier = chiffre >>= \d ->


(entier >>= \(n1,l1) -> return(n1+d*10^l1,l1+1))
# return(d,1) 220
Et pour la grammaire récursive à gauche? Analysons les attributs avant de norma-
liser la forme
N0 → Da
N0 → N1Db
Il est clair que la seconde règle produit : N0.v = 10 · N1.v + Db.c. La longueur
de la chaîne n’est pas nécessaire. Le seul problème est l’impossibilité d’implé-
menter cette grammaire telle quelle par la stratégie descendante. La grammaire
normalisée selon Greibach est
N0 → Da S
Se → ∅
S0 → Db S1
et, attention à présent ! Nous n’allons pas combiner la valeur de Da avec la valeur
de S pour obtenir N0.v , mais nous écrivons brutalement N0.v = S.v . Qu’est-
ce passe-t-il avec le chiffre? Il n’est pas oublié, mais il fournit un attribut hérité,
contextuel, à S . Appelons-le le contexte gauche, S.g . La règle donnant N0 précise
donc que S.g = Da.c. 221

Passons aux productions définissant S . La première résulte en Se.v = Se.g .


Ceci peut être construit automatiquement par un générateur capable d’effectuer la
normalisation. La seconde production donne S1.g = 10 · S0.g + Db.c. L’oc-
currence de S à droite récupère l’information de gauche. C’est ça, le mystère des
attributs hérités. La production récursive ne retourne jamais rien, elle boucle, en
construisant des nouvelles instances des attributs hérités, jusqu’à l’échec de la
clause récursive (plus de chiffres). Le parseur correspondant a déjà été construit.

Quels sons d’autres attributs intéressants dans notre modèle du compilateur?

– Être une constante (explicite ou symbolique). Un nombre explicit, p. ex.


3.14159 peut être retourné par le parseur comme un record qui contient sa
valeur, mais également son statut, disons, CONST. En générant le code résul-
tant de l’application d’un opérateur à deux arguments, le parseur/générateur
peut vérifier ces attributs. Si les deux arguments sont constants, au lieu de
générer le code le compilateur exécute l’opération (si applicable), et stocke
le résultat. Ceci est une des techniques fondamentales d’optimisation. 222
– « Adresse » d’une variable. Quand le parseur trouve un identificateur, il doit
immédiatemment vérifier dans la table des symboles (l’environnement) si
cette variable possède déjà quelques attributs, notamment l’indice qui per-
mettra localiser sa valeur. Sinon, il faut l’insérer et retourner un nouvel envi-
ronnement, enrichi par cette variable.
– Type d’une variable/constante. Les types peuvent se propager. L’expression
x+1 (dans un langage monomorphe) déclenche une procédure d’unification,
ou de solution de contraintes, permettant de constater que « + » possède
un argument entier, et donc c’est une opération entière. Donc x doit être
également entier !
Ceci est, bien sûr, loin de la vérité pratique.
1. Dans des langages comme C les expressions mixtes sont parfaitement
légitimes. Mais x doit être déclaré, le compilateur connaît son type. S’il
n’est pas entier, il doit appartenir à un sur-ensemble des entiers (flottant,
complexe, etc.), ce qui permet la conversion appropriée de nombre 1.
D’autres cas sont aussi possibles, p. ex. x + 1.0 force la conversion du
x si celui-ci est entier. Tout est basé sur l’information passée sous forme
d’attributs entre les éléments du parseur. 223

2. En Haskell la situation est un peu différente, car il n’y a pas de décla-


rations. . . . Comment compiler : f x = x+1? La réponse est délicate. La
fonction f est polymorphe, équivalente à f x = x + fromInteger 1.
L’opérateur (+) ne peut être compilé directement, puisque le compila-
teur ne sait pas quelle variante sera effective lors de l’appel de la fonction
f (tout dépend du type de l’argument actuel). Il existe plusieurs modèles
de compilation polymorphe, le plus simple (pas forcément le plus effi-
cace) exploite des arguments « cachés » : des dictionnaires de « fonctions
virtuelles ».
Si on applique f y où y est connu, p. ex. complexe, alors la fonction
f reçoit un argument caché – un dictionnaire attachée à la classe Num ;
les classes servent justement à déclarer des dictionnaires, et les instances
remplissent ces dictionnaires avec les associations concrètes entre les
« messages », p. ex l’opérateur (+), et les « méthodes » – p. ex. la procé-
dure d’addition. 224
Parsing, quelques compléments
La couche lexicale n’est pas triviale

Syntaxiquement les langages comme Scheme, ou Haskell ne sont pas très com-
pliqués. . . mais même là, il faut reconnaître les complications suivantes :

– En Lisp/Scheme le mot 7up est un identificateur, tandis que dans la plupart


d’autres langages – non. En C, Pascal, Haskell, etc., quand le scanneur re-
connaît qu’un lexème commence par un chiffre, il sait que le résultat sera un
nombre (entier ou réel ; en Python aussi complexe, p. ex. : 2.3j). Si la suite
de chiffres se termine par une lettre, le scanneur déclenche une exception. En
Scheme il peut ne pas être souhaitable de reconstruire le nombre immédia-
temment à partir des chiffres. On accumule les caractères, et seulement si le
parsing réussit en « mode numérique », le résultat est un nombre, et on le
peut assembler. 226

Il y a d’autres conventions. En MetaPost 7up est équivalent à 7*up, le scan-


neur sépare les fragments lexicaux. Ensuite le parseur montre une grande
intelligence : si un nombre est suivi par un identificateur, on insère un opéra-
teur de multiplication.
Dans tous ces cas le parsing est non-déterministe, on ne peut savoir immé-
diatemment quel sera le résultat. Ceci diminue l’efficacité de l’analyse. Les
langages/parseurs plus rigides enlèvent toute ambiguïté après avoir vu un
caractère (one character look-ahead ; plus tard nous verrons qu’une optimi-
sation fondamentale est possible si le parseur phrasal possède également la
faculté de jeter un coup d’oeil sur le lexème avant de le consommer. . . ).
– D’habitude la forme up7 est un identificateur simple, mais pas toujours. En-
core une fois, MetaPost (et MetaFont) la considèrent comme quelque chose
de genre up[7] – une variable indexée.
– Quelques langages (notamment C, C++, mais aussi notre MetaPost préféré)
possèdent des macros lexicaux, un mot peut être reconnu comme une entité
active, et remplacé par sa définition. D’habitude le scanneur n’a plus rien à
faire ; le parseur phrasal reçoit la suite de lexèmes qui résulte de la substitu-
tion, c’est le cas typique en C ou en Scheme. 227
Toutefois, parfois la substitution est faite au niveau lexical, et le scanneur
répète l’analyse, ce qui peut générer de nouveaux identificateurs, qui n’étaient
pas présents en code source, par la concaténation des fragments.
– Nous avons introduit le parseur « positionnel », permettant de localiser un
lexème (ou autre entité) dans le flot-source. Ceci pouvait être utile pour le
débogage. Mais la connaissance de la position peut jouer un role beaucoup
plus important ! En Haskell, Python, partiellement en Fortran, d’habitude en
assembleur, la structure de la ligne de code-source est importante syntaxique-
ment et sémantiquement.
En Haskell les règles du layout ont déjà été discutées. Rappelons que l’inden-
tation plus grande que sur la ligne précédante signifie la continuation de cette
ligne. L’indentation identique ouvre une définition colatérale. Le scanneur
garde trace des indentations, et il ajoute aux lexèmes réels les accolades et les
points-virgules qui sont interprétés par le module suivant de manière ration-
nelle. En Python il existent des lexèmes spécifiques : « indent » et « dedent »,
qui jouent des rôles similaires. 228

– Il y a des langages très spécifiques, comme Smalltalk où on peut écrire une


expression comme add: x to: list, où le message, le nom de la méthode
appelée est add:to:. C’est sous cette dernière forme elle figure dans la table
des symboles, mais dans le texte du programme le nom est coupé en mor-
ceaux par les deux points.
– Il ne faut pas oublier des langages très spécialisés, comme le description d’un
jeu d’échecs, ou une partition musicale. Ici la couche lexicale peut être assez
non-standard, p. ex. avec des caractères graphiques. (Il faut avouer que de
tels langages sont rarement parsés. . . )

Ce qui normalement est respecté, c’est la simplicité structurelle des lexèmes. Il


est difficilement imaginable de representer les mots par une grammaire récursive
complète, même si les règles lexicales de la construction des mots corrects en
quelques langues naturelles, comme Japonais ou Polonais, sont loin d’être tri-
viales. Mais c’est un problème pour un correcteur lexical intelligent, non pas pour
un compilateur. . . 229
Macros

La construction et l’usage des lexèmes ou des « formes fonctionnelles » qui seront


remplacées par leurs définitions, est délicate, mais utile. Ainsi Scheme, qui utilise
toujours la récursivité terminale pour les itérations, peut accepter des structures de
contrôle typiquement impératives. La construction

(while condition
instr1 instr2 ... instrN)

sera traduite en

(let loop1765 ()
(if condition (begin instr1 instr2 ... instrN (loop1765)))

et cette manipulation a lieu pendant la compilation. En Scheme l’utilisateur peut


définir ses propres fonctions du développement, qui sont exécutées pendant la
compilation des autres et peuvent être très complexes. 230

Ceci est faisable si l’implémentation du langage permet la communication bi-


latérale entre le noyau exécutif (le runtime, la machine virtuelle), et le compilateur.
Ceci est possible en FORTH, Snobol et partiellement en Python (ou Perl), mais
totalement impossible dans le cas de langages compilés standard, comme C. Les
macros en C ou C++ sont statiques, contiennent uniquement les substitutions. Bien
sûr, quelques mécanismes décisionnels (#if ...) sont aussi nécessaires, mais en
général les macros en C ne présentent rien d’épatant.

Il existent des langages descriptifs, statiques, comme le langage de description


des scènes graphiques POVRay. Dans ce langage les macros jouent le rôle des
fonctions. Leur exécution (p. ex des boucles) physiquement génère le code qui
correspond à l’insertion d’autres objets dans la scène : si on met on objet, disons
un cylindre, dans une boucle, qui tourne 7 fois, ceci est équivalent au placement
de 7 cylindres directement.

(Alors attention, les boucles font augmenter la taille du code !) 231


Grammaires d’opérateurs encore une fois

La grammaire qui décrit les expressions comme sommes de termes, qui sont com-
posés de facteurs, etc., est rigide. Nous ne pouvons ajouter aucun autre opérateur
dans une expression arithmétique.

On peut, bien sûr, considérer encore quelques niveaux, p. ex. des expressions re-
lationnelles, p = x+y<=7*x, expressions logiques a = p & q, etc., et cette ap-
proche peut être utilisée dans quelques langages, p. ex. Fortran, Algol60, Pascal. . .
Cependant, nous n’avons toujours aucune possibilité d’y ajouter de nouveaux opé-
rateurs, et donc, des grammaires de ce type sont trop pauvres pour décrire Haskell
ou Prolog (ou ML. . . ).

Donc, il faut

– Prévoir un environnement lexical, une table d’opérateurs (de précédences)


dynamique. Toute déclaration genre op en Prolog ou infix (ou :-l, -r) en
Haskell ajoute un opérateur à cette table. 232

– Contrairement à ce que l’on a fait auparavant, il faut obligatoirement séparer


cette table de l’assignation plus sémantique (quelle fonction ou symbole est
attaché à l’opérateur). Ceci doit être cherché dans une autre table, l’environ-
nement lexical standard.
– Il y a un petit problème lié aux opérateurs préfixes. En Haskell ils n’existent
pas en tant qu’entités autonômes, toute fonction est un opérateur préfixe,
grâce au style « curryifié », sans parenthèses. Dans d’autres langages ceci est
plus délicat, une fonction peut exiger les parenthèses autour des arguments,
tandis que not x serait légal. Il faut distinguer entre les opérateurs préfixes
et infixes, et éventuellement introduire aussi les opérateurs postfixes.
– On peut en principe traiter les parenthèses généralisées : crochets, paren-
thèses, accolades, ou les paires begin–end comme paires d’opérateurs : pré-
fixe/postfixe, mais nous n’allons pas définir de telle stratégie, car elle est un
peu dangereuse. De préférence les parenthèses, crochets, etc. appartiennent à
la couche rigide du langage. 233
– Ce dernier point implique la nécessité de définir le langage très concrètement
sur le plan syntaxique et sémantique avant même de penser à sa compilation.
En C, Pascal, Python etc. f(x,y) la parenthèse ouvrante déclenche la com-
pilation de l’opération de l’application fonctionnelle de f à deux arguments
empilés, x et y. Mais en Haskell ceci est l’application de f à un tuple x,y, et
le rôle des parenthèses est complètement différent !
En Python le passage d’un tuple s’écrit f((x,y)). Tout peut être parfaite-
ment rationnel, et compilé efficacement, mais – répétons – rien ne remplace
une définition très précise du langage-source.

Nous ne pouvons traiter ici tous les problèmes liés aux précédences. Si on veut
rester simple, on peut envisager le découpage du langage en deux couches :

– une avec des mots-clefs let, where, if, begin, etc., des structures de don-
nées de type listes, tuples, et autres éléments rigides ;
– l’autre – le sous-langage d’expressions opérationnelles, avec des opérateurs
de précédence et associativité quelconques. 234

D’habitude cette couche se réduit aux expressions artihmétiques, relationnelles et


logiques, mais rien n’empêche de l’élargir.

Construisons encore une fois le parseur précédenciel. Il doit à présent être mieux
compris qu’auparavant. La présentation typique de l’algorithme du parsing avec
des opérateurs est ascendante, itérative :

– On prévoit deux piles, la pile de données, et la pile d’opérateurs. Initialement


la pile de données est vide, et la pile d’opérateurs contient un marqueur, un
opérateur artificiel de très basse précédence.
– Si, en consommant le flot de données on trouve une donnée (tout objet qui
n’est pas opérateur), on l’empile aussitôt.
– Si on trouve un opérateur, sa précédence gauche est comparée avec la pré-
cédence droite de l’opérateur qui se trouve sur le sommet de la pile. Si le
nouveau opérateur est plus « fort », on l’empile, et on continue la lecture. 235
– Sinon, le dernier opérateur empilé réduit ses arguments, et on répète la com-
paraison de l’opérateur avec la pile. Ceci peut déclencher une réduction en
cascade.
– Quand la fin de l’expression est forcée (p. ex. par un terminateur ou la paren-
thèse fermante) on dépile, et on réduit le reste.

Cette stratégie gére aussi des opérateurs préfixes d’arité quelconque.

Pour varier un peu, construisons un parseur monadique descendant, construit dans


le style courant (rappelons que la version précédante était une instance du read-
sPrec), mais qui réalise cette stratégie. La récursivité remplace les piles.

La solution proposée est imparfaite, elle ne réagit pas correctement aux erreurs
de précédence conflictuelle (p. ex. a<b>c ; les opérations relationnelles d’habitude
ne sont pas associatifs, avec – peut-être – l’exception d’égalité). Veuillez apporter
des corrections vous-mêmes, en ajoutant une réaction violente si deux opérateurs
voisins entrent en conflit (la même précédence l–r). 236

La grammaire de base, un peu sommaire est :

E → E Op E | Prim

où Prim est une expression primaire : atôme, appel fonctionnel, ou expression


parenthésée.

Prim → Id (’(’ Largs ’)’ | ∅) | ’(’ E ’)’


Largs → ∅ | E ’,’ Largs

Le parseur lit un flot de lexèmes, et construit une expression :

data Expression = Va String | Co Integer | Pr String Expression


| In Syntoks Expression Expression
| Appl String [Expression]
deriving (Eq,Show) 237
où Pr dénote une expression précédée par un opérateur préfixe, qui n’est pas inclus
dans la solution présentée. Bien sûr, Va symbolise une variable, Co une constante
entière, etc. La balise type Syntoks est un des objets spéciaux :

data Syntoks = Add | Sub | Mul | Div | Pow | Lth | Gth | Equ
| Leq | Geq | Neq | Var String | Cst Integer
| Assgn | Cons | Conc | And | Or | Not
deriving (Eq,Show)

(Var et Cst ici sont redondants, nous voulons les opérateurs.) Les objets de ce
genre sont construits par une procédure de recherche associative des opérateurs
dans une liste. Un opérateur infixe est représenté par son symbole, et par deux
précédences : gauche et droite. 238

inflist = [("+",(Add,60,61)),("*",(Mul,70,71)),("/",(Div,70,71)),
("^",(Pow,80,79)),("-",(Sub,60,61)),("<",(Lth,40,40)),
(">",(Gth,40,40)),("==",(Equ,40,40)),("<=",(Leq,40,40)),
(">=",(Geq,40,40)),("/=",(Neq,40,40)),("=",(Assgn,10,9)),
(":",(Cons,50,49)),("++",(Conc,50,49)),("&&",(And,30,29)),
("||",(Or,20,19))]

etc. Tout ceci est rudimentaire, uniquement pour l’illustration. Notons que l’ex-
pression E est défini par une règle récursive à gauche. La vraie grammaire sera
donc

E → Prim S
S → Op E S | ∅ 239
ce qui a déjà été vu, mais cette fois le jeu d’attributs est plus complexe. Le contexte
gauche reste le même. Nous ajoutons à la séquence S un attribut hérité : la précé-
dence limite Sp. Si dans la production S0 → Op E S1 la précédence gauche
de l’opérateur courant Op.l < S0.p, l’itération s’arrête, on retourne le contexte
actuel.

On commence par la précédence minimale, zéro, qui joue le rôle du marqueur.

expression = exs 0
exs p = primary >>= opseq p

primary = (ident >>= \i -> (leftpar >> argseq >>=


\a -> rightpar >> return (Appl i a)) # return (Va i))
# (leftpar >> expression >>= \e -> rightpar >> return e)
argseq = (expression >>= \e -> argseq >>= \s -> return (e:s))
# return [] 240

Le vrai cheval de bataille est le parseur S, nommé opseq :

opseq p c = (highinf p >>= \(op,l,r) -> exs r >>= \e ->


opseq p (In op c e))
# return c
highinf p = infx >>=
\s@(op,l,r) -> if l>p then return s else failure

où highinf est le parseur qui échoue si l’opérateur suivant ne possède pas la


précédence plus grande que la préc. limite actuelle. Il échoue aussi si infx échoue
(p. ex. si on trouve une parenthèse fermante). C’est ici qu’il faudrait ajouter un peu
plus, et réagir convenablement aux conflits de précédences, et des incongruités
d’autre type.

Malheureusement, des détails de ce genre peuvent nous occuper encore pendant 7


mois. . . . 241
Ce qui resterait à faire

– D’abord, compléter la couche parsing par des dispositifs sérieux de recon-


naissance d’erreurs. L’échec ([]) n’en est pas un. Ceci demande la généra-
lisation de la notion d’échec, l’introduction des « productions-fautes », etc.,
avec des messages informatifs. Bien sûr, le diagnostic doit rapporter la posi-
tion, et le caractère de la faute.
– Prévoir une procédure de restauration d’un contexte « sain » dans le cas d’er-
reurs, ce qui permettrait de continuer le parsing après avoir découvert une
erreur. Ceci doit normalement désactiver le générateur du code.
– Traiter sérieusement la construction et l’usage d’une table de symboles pour
le stockage des attributs. Sans cela tout le travail reste un jouet pédagogique,
sans utilité pratique.
242

Génération du code, continuation


Le sujet traité récemment – la génération du code linéaire par les procédures du
parseur fait partie de la stratégie compilation pilotée par la syntaxe. On n’est pas
obligé de construire physiquement l’arbre syntaxique. Pour compiler une expres-
sion spécifiée par la syntaxe

Expr → Terme TermSeq


TermSeq → ∅ | OpAdd Terme TermSeq

en code postfixe, on aura à peu-près cela :

expr = term >>= \t -> termseq t -- ou term >>= termseq


termseq c = (opadd >>= \op -> term >>\t ->
termseq (c ++ t ++ [op]))
# return c 243
etc. Cette définition est OK, sauf un détail technique : le contexte gauche du term-
seq est composé par concaténations itérées, ce qui n’est pas très efficace.

De la même manière on peut définir un bloc d’instructions, dont la grammaire est


réalisée par l’associativité gauche, et la normalisation de Greibach classique

Block → ’{’ Inseq ’}’


Inseq → Instr ’;’ Insuite
Insuite → Instr ’;’ Insuite | ∅

ce qui correspond à

ins = instr >>= \i -> semicol >> return i


block = lbrace >> inseq >>= \s -> rbrace >> return s
inseq = ins >>= insuite
insuite c = (ins >>= \i -> insuite (c++i)) # return c 244

Le même problème. Mais l’associativité peut être à gauche ou à droite ; la conca-


ténation est associative tout court, donc on peut essayer

Inseq → Instr ’;’ Inseq | ∅

Le parseur peut être

inseq=(instr >>= \i -> semicol>>inseq >>= \s -> return(i++s))


# return []

exactement comme nous l’avons fait avec la liste d’arguments fonctionnels dans
notre parseur de précédences. Cette fois il n’y a pas de concaténations en cascade,
mais l’empilement de toutes les instructions lors des appels récursifs de inseq.
Ceci n’est pas très bon si le bloc risque d’être long. 245
On voit ici les limites de la technique purement fonctionnelle avec les listes et
sans la possibilité de modifier les structures (les concaténer physiquement).

Or, on sait qu’en Prolog on peut y arriver sans avoir aucune procédure de mo-
dification réelle d’une structure complètement assemblée, grâce à la notion de la
variable logique et l’usage des « structures incomplètes », p. ex. des listes diffé-
rentielles. Est-ce que l’on peut faire la même chose en Haskell?

On peut faire au moins une chose pour éviter l’encombrement de la pile, ainsi que
la complexité quadratique du parcours à cause des concaténations répétés. On uti-
lise la règle récursive à gauche, normalisée. Cependant, au lieu de concatener par
(++) : insuite (c++i), on attache la nouvelle instruction avant le contexte :
insuite (i++c). En fin de comptes ceci produit la liste inversée, mais la clause
terminale de insuite peut être ... # return (reverse c). La complexité
de l’algorithme augmente, mais reste linéaire. Le gaspillage de mémoire est limité. 246

Un autre moyen de procéder, pas très rapide non plus, mais assez sophistiqué
a déjà été mentionné lors de notre discussion des machines virtuelles dans un
contexte un peu différent ; il s’agit de construire non pas le code, mais une pro-
cédure génératrice du code, un « bloc parametré » par le code successeur. Si la
version standard de parseur ins fournissait un fragment du code x, le nouveau
variant retourne \buf->x++buf.

Le parseur inseq devient

inseq = ins >>= insuite


insuite c = (ins >>= \i -> insuite (c . i))
# return c

et à la fin il faudra appliquer l’objet fonctionnel résultant à la liste vide, ou gé-


néralement, à la continuation du code. Si on a besoin de chaque miliseconde, la
solution Haskell existe toujours, mais elle utilise une monade impérative spéciale
– les variables mutables qui ne seront pas expliquées ici. 247
Encore sur les parseurs paramétriques et les branchements

L’exemple précédent (avec les continuations) exploite la règle récursive à gauche,


mais normalisée, ce qui implique l’usage d’une variable-tampon – le contexte
gauche, qui réalise l’attribut hérité introduit par na normalisation, mais sur le plan
d’organisation du code la procédure est récursive à droite. La d¿ifférence par rap-
port au parseur construit à partir d’une production associative à droite est que rien
n’est empilé.

Ceci ne signifie pas que nous avons économisé la mémoire. Nous avons éliminé la
surcharge de la pile. Mais une procédure d’« empilement » a lieu, sauf que l’objet
qui est formé est un thunk fonctionnel, stocké sur le tas système, et non pas sur
la pile. En général c’est ce qui se passe quand on utilise les continuations dans
un contexte récursif. Le tas est encombré, mais puisqu’il est beaucoup plus grand
que la pile, la procédure est moins dangereuse. Ceci dit, l’évaluation ultérieure de
ces thunks n’est pas gratuite ! Les générateurs du code basés sur cette stratégie ne
sont pas très rapides. 248

Cependant, quand on lit un livre classique sur la compilation on voit la « galère »


liée au déploiement du code, l’allocation de mémoire, et la gestion des branche-
ments, des boucles, etc. Construction des objets fonctionnels simplifie le proces-
sus par un ordre de magnitude même si on paie avec le temps de compilation.

Si le code de petits modules est stocké dans des listes qui sont ensuite concate-
nées, la création des résultat fonctionnels par les parseurs devient inéluctable en
présence des instructions de branchement, car la concaténation risque d’invalider
la cible d’une telle instruction. Si un bloc possède la structure

code = [... , jmp cible, ... autres instr] ++ cible


where cible = [... instructions]

et si ensuite on construit plusieurs modules = code1 ++ code ++ code2


++ ..., on peut espérer que l’instruction jmp cible passe à l’exécution des
instructions, et ensuite le bloc code2 devient actif. 249
Or, ceci est faux. On exécute les instructions et on se casse le nez. . . Le code
est recopié, mais la cible – non. Cette question a déjà été signalée lors de la
discussion de la machine virtuelle de bas niveau, à pile, notre modèle d’exécution
privilégié. Analysons la génération du code correspondant à la boucle while en C.

while(expr)bloc

qui peut, en principe, être parsé par le suivant, où le parseur key et le symbole
WHILE sont des simples outils permettant de distinguer le plus rapidement possible
les mots-clefs des identificateurs. Les mots réservés peuvent être cherchés dans
un environnement spécial par le scanneur lexical. L’instruction pass ne fait rien.
Une optimisation poussée doit l’éliminer. La forme lbrace est une abréviation
de l’accolade ouvrante, etc.

bloc = (instr >>= \i -> bloc >>= \b -> return(i++b))


# return [] 250

wloop = key WHILE >> lpar >> expr >>= \e -> rpar >>
lbrace >> bloc >>= \b ->
let cont = [pass]
cod = e ++ [ifnjmp cont] ++ b ++ [jmp cod] ++ cont
in cod

Ce parseur-générateur est imparfait. La concaténation du code généré par wloop


avec la suite, détruira la liaison entre la cible (cont) et le reste du code. Si on al-
louait un vecteur contigu aux code-items, et si toute concaténation était physique,
le problème ne se poserait pas. D’autre part, le parseur-générateur ci-dessus ignore
l’existence des structures break et continue.

Nous allons donc construire un parseur-générateur qui fournit une fonction, dont
l’évaluation génère le code final. Ces « codes fonctionnels » sont composés, et
non pas concaténés. La création de ces thunks est rapide et économique, mais le
vrai travail est effectué quand on les évalue. . . 251
Encore un commentaire important : faut-il toujours générer (quand l’instanciation
finale a lieu) un code continu, un vecteur avec la totalité du programme compilé?
Ceci est évidemment utile (même si ce n’est pas indispensable) quand il faut créer
un fichier « stand-alone », stocké, et ensuite exécuté indépendamment de son en-
vironnement de création. Cependant, si on génère le code seulement dans la mé-
moire, pour la machine virtuelle integrée avec le compilateur, on peut laisser les
segments séparés (ces segments qui constituent des unités adressables). Ceci évi-
tera plusieurs concaténations inutiles. En particulier, si un fragment se termine par
un branchement inconditionnel, alors tout ce qui suit, est soit une unité adressable
(et donc accessible depuis un autre endroit), soit constitue un « code mort » qui
doit être éliminé par un bon optimiseur, et la mémoire en peut être récupérée par
le garbage-collector. (Ceci, d’ailleurs, est la stratégie exploitée par quelques im-
plémentations du langage Snobol4, dialecte/implémentation Spitbol). Par contre,
laisser le programme sous la forme de plusieurs segments indépendants implique
une gestion de tout ce bazar assez élaborée, donc n’essayez pas d’exagérer. . . 252

Il faut prévoir la possibilité de terminer la boucle while par break, ou omettre


le reste du bloc sans quitter la boucle par continue. Ceci servira à paramétrer
le parseur bloc qui accumule les instructions. Rappelons que le parseur reçoit un
flot de lexèmes déjà pré-traités.

Attention : il ne s’agit pas de construire une fonction-parseur parametrée, car cela


ne suffit pas, il est difficile de « boucler » la boucle, la séquentialisation mona-
dique empêche l’usage de la paresse, comme nous l’avons fait dans le cadre de
construction du code manuel pour la machine virtuelle. Ici c’est le résultat du
parsing, qui sera parametré, comme dans le cas du parsing avec continuations,
où le parseur instr au lieu de retourner – disons – un instr code, fournissait
\nxt -> instr code++nxt. Voici le parseur. Commençons par la définition de
l’instruction qui peut avoir break etc. à l’intérieur.

xinstr = key BREAK >> return (\brk cnd -> [jmp brk]) #
key CONTINUE >> return (\brk cnd -> [jmp cnd]) #
other_instr >>= \i -> return (\brk cnd -> i) 253
iseq = (xinstr >>= \i -> iseq >>= \s ->
return (\brk cnd -> i brk cnd ++ s brk cnd) #
return (\brk cnd -> jmp cnd : brk)

wloop = key WHILE >> lpar expr >>= \e -> rpar >> lbrace >>
iseq >>= \bl -> rbrace >>
return (\nxt -> let d = e++[ifnjmp nxt]
++ bl nxt d
in d)

Le résultat doit être appliqué au code restant. Ceci est loin de réalisation la plus ef-
ficace et universelle (quid de blocs imbriqués?), mais l’idée générale est là. Notez
l’associativité à droite de l’iseq ; nous aurions pu faire ça autrement. 254

Transferts de contrôle aléatoires

Comment gérer le calamiteux goto n’importe où dans le programme? Dans le


style « classique », la notion d’étiquette est très importante, pas seulement dans
le contexte du code source, mais comme une entité reconnue par le compilateur.
Quand on voit une instruction étiquettée :

label51: instruction;

on met dans l’environnement l’objet label51 balisé comme une étiquette, et at-
tachée à l’instruction qu’elle précède. Quand l’instruction est compilée, quand le
code est généré, alors – comme nous l’avons dit – un des attributs fondamentaux
de l’instruction en tant qu’entité syntaxique, est son adresse. L’étiquette reçoit
cette adresse comme son attribut primaire. Ensuite, l’instruction goto label51
est compilé comme un branchement primitif, avec la cible récupérée des attributs
de l’étiquette. 255
Une instruction conditionnelle doit subir un traitement poussé : if A then goto
label43 si compilé à l’aveugle, risque d’engendrer des sauts cascadés. Un com-
pilateur sérieux doit en faire un saut conditionnel. La même optimisation attend
les breaks et les continues conditionnels.

Ceci est assez trivial, sauf si l’instruction goto arrive avant l’étiquette. Cette ins-
truction ne peut être compilée. Mais ceci justement est le cas du break !

La stratégie orthodoxe du déploiement du code consiste à générer une instruc-


tion « bidon ». L’étiquette est générée normalement dans l’environnement, avec
son attribut : nom, mais sans l’adresse réelle. Elle peut être accessoirement mar-
quée comme forward label, l’« étiquette future ». L’adresse de l’instruction jmp
vers l’avant est déposée dans un endroit connu, comme un « attribut latent » de
l’étiquette concernée, par exemple dans une liste. Si d’autres instructions forward
jump se trouvent dans le code, cette liste grandit. Finalement l’étiquette est retrou-
vée, et le compilateur modifie toutes les instructions mémorisées. 256

Bien sûr, nous pouvons faire la même chose dans le cadre de nos outils. Cepen-
dant, une solution plus fonctionnelle a déjà été proposée : on construit un « code
incomplet », un objet fonctionnel, parametré par les étiquettes manquantes. On
l’instancie quand les étiquettes deviennent connues.

En général, la stratégie est toujours un peu tordue. L’élimination des branche-


ments aléatoires dans les langages modernes n’est pas qu’une amélioration de la
structure des programmes, mais constitue aussi une simplification du processus
de compilation. La plupart des gotos peuvent être réalisés par les boucles, condi-
tionnelles, instructions switch, etc. Par contre, dans le code de bas niveau, le
transfert de contrôle doit rester . . . de bas niveau. 257
Machine avec registres
Tout processeur moderne est équipé de plusieurs registres rapides qui économisent
le transfert d’information entre l’unité de calcul et la mémoire. Leur nombre peut
aller au delà de 64. Notre machine à pile stocke tout dans une liste ou vecteur dans
la mémoire. Dans un langage évolué d’habitude on n’a pas d’accès aux registres
(l’option register en C++ n’est pas fiable). Nous pouvons économiser un peu de
temps, si une partie d’information est sauvegardée dans des variables statiques (les
plus statiques possibles. . . ), au lieu de mobiliser à chaque accès toute la gestion
de la pile.

Pour des calculs arithmétiques (ou autres) typiques, la dernière valeur obtenue, le
sommet de la pile, est presque toujours très active. On la dépile, utilise, empile le
résultat, le dépile. . . – il serait souhaitable d’éviter ce gaspillage.

Ceci, évidemment, se trouve loin du parsing proprement dit, c’est une affaire du
générateur de code (et de l’exécuteur de ce code. . . ). 258

L’usage des registres ouvre un nouveau volet d’optimisation. Il est évident que si
nous disposons d’un registre, l’expression (a+b)*(b+d) force son stockage sur
la pile après avoir calculé le premier facteur, a+b pour pouvoir calculer le second.

Le générateur doit donc garder la trace des résultats partiels et d’allocation des
registres, et sauvegarder ces registres sur la pile au dernier moment, avant de les
pouvoir réutiliser. D’autres attributs entrent en scène, comme la « vie » d’une
expression stockée dans un registre. Supposons que la machine virtuelle (toujours
à pile) possède accessoirement deux registres universels, R0 et R1, dont R0 joue
le rôle habituel du sommet de la pile ; c’est ici que les instructions primitives add
etc. déposent le résultat, et ce registre fournit également un des arguments. Au lieu
d’exécuter un tel code

load a
load b accès aux variables
add 259
load b
load d
add
mul

sans registres, on pourra compiler

load_R0 a
load_R1 b
add_R1
stack_R0 la sauvegarde
load_R0 d le compilateur connaît le contenu du R1
add_R1
mul std., l’autre arg. sur la pile 260

Moins d’accès à la mémoire des variables (typiquement : indexée), et moins d’opé-


rations avec la pile. Plusieurs modifications de ce schéma sont possibles, et l’adres-
sage est plus riche que pour une machine à pile.

Parfois il est souhaitable d’ajouter une étape intermédiaire entre une machine à
pile et une machine à registres, en construisant un code abstrait dit le code à
3 adresses, où toute instruction a la forme var1:=var2 Op var3 (évent. plus
simple). Le compilateur essaie d’allouer les variables dans des registres, en construi-
sant un graphe d’interférence des variables et en essayant de le colorier avec un
nombre minimal de couleurs. La machinerie parfoit ré-ordonne les instructions
pour limiter les sauvegardes dans la mémoire et les transferts entre les registres.
Ceci appartient à un cours plus avancé que le nôtre, lisez Appel. 261
Optimisation du parsing

Optimisation des parseurs descendants ; stratégie LL

Toute l’idée repose sur un objectif : sachant que les langages formels typiques
sont globalement déterministes, comment enlever le non-déterminisme du par-
sing? Le parseur peut échouer, mais il doit retourner au maximum une valeur
finale. D’ailleurs, dans des cas typiques la possibilité de backtracking est « vir-
tuelle », presque jamais exploitée. Nous voulons donc économiser le temps et
l’espace en supprimant les listes des résultats, leur concaténation et aplâtissement
monadique, etc.

On peut démontrer qu’une large classe de langages permet un parsing absolu-


ment déterministe, sans aucun retour en arrière, si le parseur est capable de voir le
lexème successeur de celui qui est actuellement traité. 262

Nous décrirons ici (brièvement) une telle stratégie, connu comme LL(1). Acces-
soirement elle élimine la récursivité en faveur d’une approche itérative, avec un
tableau de pilotage. Ceci nous éloigne de la grammaire initiale, le parseur devient
plus compliqué, mais il reste toujours réalisable par des techniques combinatoires.
Dans cette version des notes du cours la présentation sera sommaire.

Il faut prévoir la création de deux tableaux spéciaux, FIRST et FOLLOW (PRE-


MIER et SUIVANT si on le veut. . . ). Ils obéissent les propriétés suivantes :

– FIRST est indexé par les symboles de la grammaire, terminaux et non-, et


désigne le premier terminal qui commence une chaîne quelconque qui ré-
sulte du développement de ce symbole. Évidemment, pour tout terminal α,
FIRST(α)=α. Pour un non-terminal X il faut chercher ce premier symbole
terminal à droite de la production X → Y1Y2 . . . Yn. Si X possède une pro-
duction vide, ∅ appartient à FIRST(X ). Mais alors FIRST(XY ) dépend de
FIRST(Y ), et la procédure de construction du FIRST n’est pas triviale. 263
– FOLLOW est indexé par des non-terminaux. FOLLOW(A) contient l’en-
semble de tous les terminaux qui peuvent se trouver à droite de la chaîne
venant du développement de A. Si A est utilisé dans une production, p. ex.
S → αAaβ , où a est un terminal, alors a appartient à FOLLOW(A). Si a
n’est pas terminal, on cherche son FIRST.
– FOLLOW a un caractère « hérité ». Si la production A → αB existe, tout
dans FOLLOW(A) se retrouvera dans FOLLOW(B ).

Ayant construit les tableaux FIRST et FOLLOW, on peut passer à la construction


du tableau de pilotage M [A,a], où A est non-terminal, et a terminal, ou marqueur
du flot de données. Le parseur aura aussi une pile pour stocker les symboles lus.
Il regarde toujours le dernier symbole X sur la pile, et a – le lexème d’entrée.

– Si X = a = $, le marqueur de la fin, le parseur s’arrête.


– Si X = a, on a identifié un symbole terminal. Il est dépilé, traité sémanti-
quement, et la lecture continue. 264

– Si X est non-terminal, le parseur consulte M [X,a]. Le contenu du tableau


est une production. Si on trouve X → AB , X est dépilé, et B et A – empilés.
une procédure sémantique est appelée. La production en question peut être
une signalisation d’erreur ! (Une case vide dans M est également erroné).

La construction de M est facile.

– Pour tout terminal a dans FIRST(α), ajouter A → α à M [A,a]. Si FIRST(α)


contient ∅, la même production est ajoutée à M [A,b] pour tout terminal b
dans FOLLOW(A).
– Si FOLLOW(A) contient $, il faut ajouter la production dans M [A,$]

C’était la présentation « orthodoxe » de la stratégie. Mais on peut construire éga-


lement des parseurs récursifs, combinatoires, qui l’implémentent. 265
Parseurs LR

Les générateurs de parseurs comme Yacc, réputés par leur efficacité du parsing des
programmes très longs repose partiellement sur la stratégie choisie : l’algorithme
du parsing est itératif, ascendant, avec le look-ahead d’un lexème. La théorie du
parsing de ce genre est basée sur les automates à pile. Un des arguments pour
l’usage de cette stratégie est le fait que la classe de langages reconnus par les
techniques LR est plus grande que celle décrite par les grammaires LL et les tech-
niques descendantes, mais sur le plan pratique a priori cette richesse n’est pas
tellement utile, les langages très complexes et atypiques sont très rares.

Les générateurs de parseurs tels que le Yacc (ou Bison) sont basés sur cette stra-
tégie. Bien qu’elle soit implémentable de manière très élégante avec les outils
fonctionnels (peut-être l’année prochaîne. . . ), nous la présentons ici assez formel-
lement, pour votre culture générale. Un parseur généré automatiquement par Yacc,
à partir d’une grammaire (et quelques procédures sémantiques) est un programme-
automate, toujours le même. 266

La diversification vient du tableau de pilotage de cet automate, des « scripts »


statiques, augmentées par les procédures sémantiques de l’utilisateur, et quelques
unes considérées standard.

Ce tableau est composé de deux parties, nommés conventionnellement ACTION


et GOTO. Ce second tableau constitue la fonction de transition de l’automate. Le
tableau GOTO est indexé par l’état actuel (indice), et par un symbole syntaxique –
le dernier sous l’« oeil » de l’automate.

Le tableau des actions est indexé par l’état actuel, et par un symbole terminal –
le dernier sous la « tête de lecture » de l’automate. Les éléments de ce tableau
peuvent contenir une parmi les quatre valeurs suivantes :

– accept – La fin du processus de parsing.


– error – Déclenchement de la procédure de restauration de l’équilibre interne
de l’automate, annulation de la génération du code, etc. 267
– shift s – L’action de décalage. La lecture progresse, et s c’est le nouvel
état de l’automate, qui sera empilé.
– reduce – Action de réduction d’une production, disons A → β .

La pile principale peut stocker les symboles de la grammaire (terminaux ou non-),


ainsi que les états. Si sk dénote un état, et Xi – un symbole syntaxique, la pile
en général contient la séquence s0X1s1X2 · · · Xmsm, où sm se trouve sur le
sommet. Dans la pratique on n’a pas besoin d’empiler les symboles syntaxiques,
mais por la présentation ceci peut être utile.

Le programme du parseur après avoir consommé l’item suivant, ai, consulte l’élé-
ment ACTION[sm,ai] et exécute l’action. Le flot restant contient ai+1ai+2 . . ..

– Si l’élément consulté contient shift s, l’automate passe à la configuration


(s0X1s1X2 · · · Xmsmais) et procède à consommer ai+1 etc. 268

– Si ACTION[sm,ai] est reduce A → β , le programme vérifie la production


et récupère r, la longueur de β . On dépile r états, et r symboles syntaxiques,
sm−r devient le sommet, les états sm,sm−1 etc. disparaissent, et les sym-
boles Xm,Xm−1 etc. se réduisent à A. Le lexème suivant reste ai. La confi-
guration de la pile devient (s0X1 · · · Xm−r sm−r As) où s est le contenu
de l’élément GOTO[sm−r ,A]

Exemple. Reprenons l’ensemble de règles algébriques :

(1) E ::= E + T
(2) E ::= T
(3) T ::= T * F
(4) T ::= F
(5) F ::= ’(’ E ’)’
(6) F ::= id 269
Le tableau de pilotage de cette grammaire aura la structure présentée ci-dessous.
Faites le test du parsing vous-mêmes. . .

State ACTION GOTO


id + * ( ) $ E T F
0 s5 s4 1 2 3
1 s6 acc
2 r2 s7 r2 r2
3 r4 r4 r4 r4
4 s5 s4 8 2 3
5 r6 r6 r6 r6
6 s5 s4 9 3
7 s5 s4 10
8 s6 s11
9 r1 s7 r1 r1
10 r3 r3 r3 r3
11 r5 r5 r5 r5 270

Ceci, bien sûr est seulement la stratégie du parsing, non pas la technologie de
construction des tableaux de pilotage. Elle ne sera pas présentée ici. Dans la litté-
rature courante on présente trois méthodes différentes de construction des tableaux
de pilotage, la méthode SLR (simple LR), la méthode dite canonique, et LALR –
lookahead LR, la technique utilisée en pratique, qui permet de traiter quelques cas
en dehors de la stratégie SLR, et qui partage avec elle une certaine simplicité du
résultat. Pour un langage de complexité de Pascal le nombre d’états générés par
SLR et LALR est de quelques centaines. La méthode canonique engendrera dans
ce cas un automate à plusieurs milliers d’états.

Ceci démontre que dans le cas de petits langages, où la syntaxe est assez simple,
et la totalité des problèmes repose sur les aspects sémantiques, les générateurs de
parseurs sont d’une utilité assez restreinte, les procédures combinatoires sont plus
faciles à manier. 271
Types
En général, le typage est une catégorie sémantique des plus importantes, peut-être
la plus importante dans la compilation. Le type d’un objet est son « contrat »
vis-à-vis son entourage, il définit l’ensemble d’opérations qui peuvent y être ap-
pliquées. Après avoir éliminé les fautes syntaxiques, en Haskell 80% des bugs
restants concernent le typage.

Les langages se divisent habituellement en deux classes : typés dynamiquement


et statiquement. Le typage dynamique implique que les types sont assignés aux
valeurs, non pas aux variables. On peut construire des valeurs universelles, bali-
sées (comme dans le cadre de notre machine virtuelle). Parmi les langages dyna-
miques on trouve Lisp/Scheme, Python, Icon, Smalltalk, etc. Souvent ce sont des
langages interprétés, et leur exécution est plus lente que dans le cas statique, car il
faut décoder les valeurs et les opérateurs pour appliquer la procédure convenable,
et éventuellement effectuer les conversions (cas : entiers/flottants). 272

La discipline statique implique l’attachement des types aux variables. Ainsi le


compilateur sait quel est le type d’une expression qui contient les variables, et
une optimisation très importante peut avoir lieu avant l’exécution. De plus, comme
nous l’avons dit, on évite beaucoup d’erreurs.

Mais la compilation devient plus lente. . . Souvent la souplesse est plus importante
que la vitesse d’exécution, et les langages typés n’ont aucune tendance à dispa-
raître, des nouveaux « petits » langages, surtout orientés-objet, avec plusieurs op-
tions de surcharge – sont d’habitude typés dynamiquement.

L’analyse et la gestion de types dans un cas monomorphe est simple. Le type


est un attribut, et la réduction d’une production doit tout simplement vérifier la
concordance des types : l’égalité, ou la possibilité d’ajouter des procédures de
conversion. Le procédé est presque identique dans le cas statique et dynamique,
seulement le temps de cette opération est différent, et – bien sûr – les réactions
dans le cas d’erreur sont fondamentalement différentes. 273
L’histoire se complique dans le cas de surcharge. Pour un langage dynamique il
faut équiper les opérateurs internes, qui sont tout de même obligés de vérifier les
types des arguments, d’un dispositif d’aiguillage. Ainsi en Python la procédure
interne add qui implémente l’opérateur (+) dans le cas où les arguments sont
des chaînes, appelle simplement la procédure de concaténation.

Pour un langage statique, la surcharge doit être bien structurée, toutes les ambi-
guïtés doivent être résolues par le compilateur, car lors de l’exécution il n’y a plus
de vérification des types. Mais ils peuvent exister en tant que balises d’aiguillage.

On construit une sorte de fonctions virtuelles. Nous savons que la multiplication


est une opération surchargée, applicable au moins aux entiers et aux flottants. Une
technique simple, similaire au système de classes en Haskell, consiste à ajouter à
toute opération surchargée un élément caché : un dictionnaire indexé par les types
des argument dont les valeurs sont des opérateurs concrets, typés. Dans l’appel
x*y les arguments sont connus (et ils ont été convertis dans le même domaine par
l’analyseur sémantique). 274

La fonction reçoit un argument caché – le type des arguments, et décode l’opéra-


tion par l’aiguillage. Mais comment compiler

power x n = pow n where


pow 0 = 1
pow n = x * pow (n-1)

sans savoir quel est le type de x? La fonction power devient elle-même surchar-
gée. Elle reçoit le type de son premier argument et le transmet au (*).

L’approche objet standard est un peu différente. En Haskell – rappelons – une


classe est attachée aux opérations, et ses instances ajoutent des associations dans
les dictionnaires indexés par des types.

Dans un langage à objets une classe est attachée au type de l’objet. Le diction-
naire, ou le tableau de fonctions virtuelles, est indexé par les opérations (leurs
identifiants). Si une opération y est absente, elle doit être présente dans la super-
classe, le compilateur génère le code d’aiguillage correspondant sans problèmes. 275
Polymorphisme

La fonction : hd (x: ) = x récupère la tête de toute liste non-vide, quel que soit
le type d’éléments. Elle est réellement polymorphe ; dans la littérature moderne
on distingue entre le polymorphisme et la surcharge. En C++ le polymorphisme
est simulé par les templates (on compile plusieurs instances typées du même opé-
rateur). En Ada, Modula-3 ou ML il existe d’autres mécanismes.

On peut gérer le polymorphisme de plusieurs manières. Souvent il suffit d’opérer


avec des adresses des objets sans les décoder, l’action interne les traite comme
(pardonnez le mot. . . ) void *. Dans un langage typé statiquement il faut attri-
buer un type à la fonction et aux arguments. On peut l’appeler « a » etc., en
introduisant la notion de variable type. L’usage des objets polymorphes est donc
trivial. La construction et vérification des types – non. Le type [a] et autres dérivés
de types polymorphes, comme a → a, leur cohérence et leurs instanciations plus
concrètes, engagent un processus équivalent à l’unification en Logique. 276

Inférence automatique des types

Une partie de la carrière de Haskell, ML etc., est le typage statique, mais sans
déclarations. Le vérificateur des types est en réalité le constructeur de types. Sur
le plan formel on peut apparier le processus de vérification/construction, à une
opération logique, la validation d’une clause. Quand l’expression peut être assigné
un type, la réponse logique est « vrai ». On ne va pas décrire les mécanismes de
manière formelle, mais plutôt intuitive, avec des exemples.

Pour un système monomorphe (ou presque, avec surcharge) la situation est simple.
Le parseur voit x + y , et si (+) est une opération d’addition monomorphe, les
types de x et y sont figés, ce sont des nombres. En CAML c’est tout, on en déduit
même que x et y sont des entiers, car l’addition flottante est notée (.+). En C
x peut être entier ou flottant, mais ici nous avons des déclarations. L’expression
x < y sera en toute circonstance booléenne. 277
En Haskell on sait à peine que dans x + y x est un nombre, son type appartient à
la classe Num, et dans x < y – à la classe Ord. Comme il a été dit, le compilateur
« branche » à l’évaluation de ces expressions le dictionnaire correspondant, et le
type actuel de l’argument sert d’aiguillage. Dans le cas polymorphe l’essentiel est
le jeu de contraintes/équivalences. Si la fonction f s’applique à x : y = f x, et
le type de f est a->b, ceci fige les types de x,y et vice-versa !

Analysons la définition de la fonction map

map f [] = []
map f (x:xs) = f x : map f xs

La première clause nous dit seulement que map :: a -> [b] -> [c]. La se-
conde clause fige f : elle s’applique aux éléments de le liste-argument, et elle rend
un élément de la liste-résultat. Donc f :: b->c, et map :: (b->c) -> [b] ->
[c]. 278

De même, la définition ad n1 n2 x y = n1 x (n2 x y) permet de trouver


le type principal (le moins restreint) de ad. En général :

ad :: a -> b -> c -> d -> e 4 arguments


x :: c; y :: d
n2 :: c -> d -> f
n1 :: c -> f -> e

Alors : ad :: (c->f->e) -> (c->d->f) -> c -> d -> e.

La définition

fm _ z [] = return z
fm g z (a:aq) = g z a >>= \y -> fm g y aq

est plus compliquée. Mais déjà la première clause nous dit fm :: Monad m =>
a -> b -> [c] -> m a, à cause du return. 279
L’opérateur bind (>>=) doit appartenir à la même (ou compatible) monade : (>>=) ::
m a -> (a->m b) -> m b. Le premier argument de fm est la fonction g :: b
-> c -> d où d n’est pas directement visible, mais ce résultat est le type mo-
nadique. L’argument y piqué par l’argument droit du bind a le même type que le
premier argument de g, alors

fm :: Monad m => (b -> c -> m b) -> b -> [c] -> m b

Pour déboguer les programmes fonctionnels il faut savoir effectuer de telles dé-
ductions. Un sujet d’examen portera sur le typage polymorphe.

Plusieurs éléments de la vérification des types peuvent être basés sur notre intui-
tion, par exemple le fait que l’expression après le then et l’expression après le else
dans une conditionnelle doivent avoir le même type. 280

Mais le côté logique est également intéressant. Le fait qu’un type fonctionnel, di-
sons a → b peut être assimilé à une proposition (implication) logique, a plusieurs
conséquences. Par exemple, a → a est un type, car c’est une proposition logique
valide ; tel est le type du combinateur id. Mais il n’existe aucune fonction poly-
morphe dont le type serait a → b !

Et a → (b → a), ou (a → b) → a? Trouvez des exemples (si existent). . .

L’analyse des constructions polymorphes n’est pas un jouet académique. Elle est
essentielle pour la compilation des fonctions d’ordre supérieur, qui sont presque
toujours polymorphes ! Parfois c’est paradoxal, la fonction

twice f = f . f

peut être appliquée à elle même. Quel est son type? 281
Omissions

Faute de temps, nous ne pouvons traiter ni les subtypes, ni les types existentiels,
ni les types dépendants. . . Cependant c’est ici que l’on voit clairement le vrai
progrès dans le domaine des langages de programmation.

Les systèmes de types des langages à objets ne se réduisent pas à la surcharge.


L’héritage demande des techniques de compilation assez intelligentes, pour éviter
la fouille des dictionnaires lors de chaque appel d’une méthode appartenant à la
super-classe.

Les langages logiques (comme Mercury) grâce au typage deviennent 10 fois plus
rapides que Prolog. Finalement, les langages de Calcul Formel, qui permettent de
manipuler les « x » non-instanciés, symboliques, commencent à dériver dans la
direction où des non-déterminés algébriques sont quand même typés, ce qui évite
pas mal de problèmes. (Voir Axiom, Aldor, Magma, MuPAD, etc.) 282

Allocation de la mémoire pendant l’exécution


Piles

Les arguments et les variables locales des procédures sont d’habitude allouées
comme des records (ou frames) d’activation stockés sur la pile, et accédés comme
des variables indexées ; la base est le sommet de la pile, et toujours le dernier
segment est directement accessible. (Le frame est typiquement utilisé aussi pour
le stockage de l’adresse de retour de la procédure).

Malgré les apparences, la gestion des piles n’est pas triviale. Rappelons-nous que
tous les accès doivent être compilés avant que la pile n’existe. Il faut également
compiler la création des frames et le stockage des variables.

Qui sauvegarde les paramètres etc., le module appelant, ou la procédure appelée?


Déjà ici nous voyons des ambiguïtés, les deux protocoles sont utilisés ! 283
Un problème spécial pose l’existence des variables non-locales, et l’exportation
des fermetures. Rappelons-nous de

power x n = pow n where


pow 0 = 1
pow n = x * pow (n-1)

Comment la fonction pow accède à x? Nous avons mentionné une stratégie typi-
quement fonctionnelle, le lambda-lifting. On compile

power x n = pow y n
pow y 0 = 1
pow y n = y * pow y (n-1)

on « renverse » la simplification introduite par la fonction interne. Ceci est donc


plus simple uniquement au niveau source. 284

Une autre technique exploite les liens statiques. Quand une fonction interne est
exécutée, elle reçoit le pointeur sur le frame d’activation de la fonction englobante
(elle doit être active). Sachant que l’accès aux variables se fait par l’indexation par
rapport au frame pointer (lié à la pile), l’accès aux variables non-locales n’a be-
soin de rien très différent, sauf que la base est différente. On peut aussi, au lieu de
générer les liens statiques particuliers, gérer un tableau global appelé display, qui
sur la position i stocke le pointeur sur le frame de la plus récente procédure dont
la profondeur statique, lexicale, est i.
Toutes ces méthodes sont implémentables sans problèmes. Le lien statique se
comporte comme un paramètre caché ; d’habitude il est passé à la procédure dans
un registre dédié. Un problème beaucoup plus exotique arrive dans le cadre de la
programmation fonctionnelle, où nous pouvons demander à ce qu’une procédure
retourne une fermeture. Définissons

multby x =
let f y = x*y
in f 285
et construisons p = multby 5. Le résultat, p est une fonction qui multiplie son
argument par 5. Elle est exportée de multby. Mais si f est exportée, comment elle
accède à son argument non-local x à qui on a assigné la valeur 5? Le frame qui
contient 5 ne peut être restreint à la pile, qui au moment de l’invocation de f (via
p) a déjà été réduite. On peut envisager qu’une fermeture exportée est compilée
de manière à ce que toute variable non-locale est recopiée dans une petite zone
« privée », sur le tas, accessible uniquement par la fermeture. Un des modèles de
Haskell est basé sur ce principe.

Ce strategème, par ailleurs, évite l’usage des liens statiques. D’autre part, on peut
allouer la pile sur le tas sous forme de liste, comme nous l’avons fait en définissant
notre machine virtuelle. Son seul handicap est son inefficacité par rapport aux
techniques plus brutales (tableaux, accès direct, indexé). 286

Garbage Collection

Le ramassage des miettes est un processus qui coexiste avec le programme, qui a
l’accès à la totalité de la mémoire (tas) exploitée par celui-là, et qui est capable de
restructurer cette mémoire de manière à faciliter l’allocation des nouveaux objets,
en supprimant définitivement les données « oubliées » par le programme.

C’est un processus de bas niveau, en dessous du système de types, car il est ca-
pable de détruire complètement la structure d’un fragment de mémoire.

Supposons que le programme a alloué un certain nombre d’objets composites, qui


contiennent des références aux autres objets. Tout est accessible de l’extérieur par
des variables x et y (ainsi qu’à travers la pile, si quelques fonctions sont actives). 287
La totalité a été allouée par tranches d’un grand morceau de mémoire. On voit
qu’ici l’objet C n’est pas accessible. Il constitue un item de « garbage ».

B
C
y
D

F
G
288

Compteurs des références

Il existe une technique de prévention de création des miettes dans la mémoire, la


technique de compteur des références (parfois enseignée en cours de C++. . . ).

Tout record (tableau, etc.) alloué dans la mémoire possède un champ caché, suf-
fisamment spacieux pour pouvoir y stocker un entier de taille raisonable (d’habi-
tude 2 octets suffisent). Quand le record est créé et attaché à une référence, son
compteur vaut 1. Désormais toute affectation x:=P procède comme suit :

– Si x avait auparavant une valeur, on y accède et on décrémente son compteur.


– on affecte P et on incrémente son compteur.

Quand le compteur devient égal à zéro, on détruit l’objet, et on retourne la mé-


moire au gestionnaire du tas. Avant la destruction finale, si cet objet possède des
références à d’autres objets, on décrémente récursivement leurs compteurs. 289
La technique est simple et facile. Évidemment elle n’est pas capable de gérer les
références cycliques (directes ou indirectes). De plus, dans le cas des langages
C++ il faut être très vigilant à cause des constructeurs de recopie automatique,
lancés quand on passe un objet composite par valeur. Il faut pratiquement toujours
passer seulement les références aux fonctions, et dûment contrôler les compteurs
des arguments.

La vraie technique de garbage collection permet la création des miettes, mais


ensuite elle les identifie et récupère. Comme précédemment, tout objet alloué est
identifiable par le système, qui connaît sa taille et sa structure (les champs qui
mènent à d’autres records).

Il existe deux techniques fondamentales de GC : la stratégie du marquage-balayage


(mark-and-sweep), et la technique de recopie (stop-and-copy). Analysons les deux. 290

Algorithme marquage-balayage

L’algorithme procède en deux étapes. D’abord toutes les structures « vivantes »


sont visitées, et marquées comme telles. Tout record possède un bit extra, caché,
normalement égal à zéro. La procédure est assez simple, récursive. Si la structure
qui est l’argument actuel de la procédure de marquage n’a pas encore été visitée,
son bit GC est égal à zéro, dans le cas contraire la procédure retourne. Le bit GC
de la structure visitée pour la première fois est positionné, et la procédure visite
récursivement tous les champs-références. C’est donc un parcours des graphes en
profondeur.

On peut imaginer des structures qui contiennent des nombres flottants, et rien
d’autre. Il n’y a pas de place pour le bit de marquage. Il faut alors étendre l’objet
par un octet entier, ou prévoir un « bit de marquage extérieur » – un tableau de
bits compressés, où chaque bit correspond à un nombre flottant stocké ailleurs. 291
Quelque soit la stratégie détaillée, après cette phase toutes structures vivantes sont
marquées. On passe à la phase du balayage. C’est une opération beaucoup moins
structurée, le GC parcourt la mémoire séquentiellement, en balayant tous les re-
cords jamais alloués (il connaît leurs longueurs ; il suppose qu’il n’y ait pas de
trous entre les records). Tout record marqué est restauré (son bit GC redevient
zéro). Toute structure morte (bit GC déjà zéro), est annéantié, c’est à dire, on la
chaîne dans une liste des zones mémoires disponible. Les zones contiguës doivent
être fusionnées.

Après la collection la zone d’allocation n’est pas globalement contiguë. L’alloca-


tion d’un grand objet peut échouer même si la totalité de mémoire est importante.
On dit que la mémoire est fragmentée (fragmentation externe). Si la procédure
d’allocation qui a besoin d’une zone de – disons – 46 octets, trouve une zone de
49, allouer 46 et laisser 3 octets « orphélins » n’a aucun sens. On créé donc un
objet un peu plus grand que nécessaire. Ceci est la fragmentation interne. . . 292

La technique mark and sweep est donc accompagnée parfois d’une troisième
phase, le compactage de mémoire. C’est une procédure assez délicate, qui ne sera
pas explicitée ici, car l’autre méthode de GC nous offre cet avantage « gratuite-
ment ».

Optimisation de Schorr et Waite

La technique récursive de marquage peut être dangereuse. Le tas d’habitude est


beaucoup plus grand que la pile utilisée pour le stockage des frames d’activation
des procédures récursives. Le GC peut donc déborder cette pile, ce qui n’est pas
réparable. Schorr et Waite ont eu l’idée d’exploiter la structure en train d’être
marquée, elle même comme une pile. Elle sera temporairement « massacrée »,
mais restaurée à la fin.

Regardons le dessin (. . . ). Le marquage peut commencer par A ensuite E, B et F.


Le GC à tout moment garde deux références : le noeud en train d’être marqué, et
son ancêtre. Quand il descend du second champ de E vers B, il doit sauvegarder
l’accès à A. 293
Mais au lieu de l’empiler, il effectue l’« inversion des pointeurs », on place l’adresse
de A dans le second champ de E, et on passe à B en mode itératif plutôt que ré-
cursif. En descendant vers F, l’adresse de E est stocké dans le premier champ de
B, etc.

Ensuite il descend et place B dans le second champ de F, et . . . descend vers B,


où il n’a plus rien à faire. Il remonte vers l’ancêtre, et sachant toujours quel était
le dernier champ traité, restaure le pointeur correspondant. Ensuite il peut encore
descendre, ou continue à remonter.

La stratégie de l’allocation mérite quelques mots. On peut essayer d’allouer tou-


jours l’élément le plus petit disponible dans la liste libre, pour éviter la fragmen-
tation externe, mais ceci augmentera (probablement) la fragmentation interne. Ou
on alloue depuis le chunk le plus grand. Ou le premier. Les stratégies sont di-
verses, et il n’y a pas de recette-miracle, il faut savoir si le temps d’allocation est
plus important que le gaspillage de mémoire. . . 294

Algorithme de recopie

L’algorithme canonique de Cheney commence assez mal. . . Il faut destiner la moi-


tié de la mémoire à ne pas être utilisée, elle constitue une « jachère ». Quand la
partie utilisée est épuisée et le GC est lancé, toutes les structures vivantes sont re-
copiées dans la jachère, en formant une zone contiguë, ce qui facilite l’allocation
ultérieure. Les rôles : jachère et zone de travail sont échangés.

L’algorithme de recopie est formellement un parcours de graphes en largeur. On


commence par le « noyau », les objets accessibles directement. On les copie, et
place au début de la jachère. Chaque objet recopié est « massacré » : son contenu
original est détruit, et il est remplacé par le « forwarding pointer » – la référence
sur son clône dans la nouvelle zone. Il doit être identifiable comme déplacé (donc
on peut lui ajouter un bit GC aussi). 295
La procédure parcourt la nouvelle zone record par record, champ par champ, sé-
quentiellement, comme une file. Tout champ qui accède à l’ancienne zone dé-
clenche la recopie, et le nouveu exemplaire est placé à la fin de cette file. Tout
champ qui accède à un objet déjà déplacé est tout simplement modifié, en récupe-
rant l’information du forwarding pointer.

Ramasse-miettes générationnel

On a constaté plusieurs fois que l’usage de mémoire dynamique est rarement


« juste et démocratique » : il existent des objets relativement stables qui peuvent
survivre plusieurs collections de miettes, et autres – par exemple les variables
temporaires dans plusieurs procédures de traitement des listes – qui sont crées, et
oubliées aussitôt.

On pourrait économiser beaucoup de temps si on s’occupait surtout des structures


éphémères, et les structures plus persistantes étaient visités rarement par le GC.
On divise donc la mémoire en « générations ». La plus « jeune » est la plus petite,
et elle destinée à stocker les objets de très courte vie. 296

Quand elle se remplit, le GC la nettoie sans toucher le reste. Chaque objet possède
encore un autre champ caché : le compteur des survies. Si un objet est toujours là
après un certain nombre (disons 3) de ramassages, il est considéré agé, et recopié
dans la génération suivante, plus « agée ». Quand la zone agée se remplit, le GC
nettoie les deux.

On peut, naturellement introduire plusieurs générations (2 à 6 ; les plus agées sont


normalement plus grandes que les plus jeunes, facteur 4 semble raisonnable). L’ef-
ficacité du schéma est très bonne quand les objets agés ne référencent pas (ou très
rarement) les objets plus jeunes. Ceci est donc très bien adapté au style fonction-
nel, non-destructeur de programmation.

Ramassage de miettes incrémental

Cette catégorie est rarement implémentée, elle répond à la question comment lan-
cer le GC sans arrêter le programme pendant un bon moment, comment faire co-
exister les deux processus. Ceci peut être important pour les systèmes travaillant
en temps réel, les jeux, les programmes de simulation, de communication, etc. 297
On généralise la notion de marquage. Les objets peuvent être blancs, noirs ou gris.
Les blancs n’ont pas encore été visités. Les gris ont été déjà vus, mais leurs enfants
– non. (Si on applique la technique mark-and-sweep classique, de tels objets se
trouvent sur la pile ; pour l’algorithme de recopie ils se trouvent dans la nouvelle
file, mais ils n’ont pas encore été touchés pour modifier leur champs). Les noirs
ont été marqués complètement, avec leurs descendants. Le marquage tri-couleur
peut être décrit par le pseudo-code ci-dessous, et ces deux propriétés invariantes
sont toujours valides :

– Aucun objet noir ne pointe sur un blanc.


– Tout objet gris est soumis à une action du GC, il appartient à un ensemble, le
grey-set qui change durant le travail du marqueur.

for p in grey_set():
for p_field in p:
if p_field is White: p_field<-Grey
p<-Black
298

Quand le collector termine son travail, tous les objets blancs restants sont morts.
Il faut récupérer la mémoire, alors, vu le fait que le marquage est un processus
itératif (boucle while), naturellement la stratégie est mieux adaptée à l’algorithme
de recopie.

L’application de l’utilisateur, le mutator qui créé et modifie les objets dans la


mémoire n’arrête pas le travail durant le marquage (copie). Plusieurs variantes
sont alors possibles. On peut imaginer que quand une référence blanche a est
stockée dans un objet noir b, a devient gris. Ou, quand le mutator accède à un
objet blanc, il devient gris. En tout cas, un nouvel objet créé est noir, et non pas
blanc.

Une des stratégies détaillées populaires est l’algorithme de Baker, basé sur la re-
copie, et compatible avec le GC générationnelle. Le mutator alloue des objets tou-
jours dans la nouvelle zone de travail (typiquement : non pas à la fin de la « file »,
(il n’y a pas des champs blancs), mais à la fin de la nouvelle zone, en marchant
vers l’arrière). 299
Chaque fois, en parallèle, le collector scanne et marque (copie) quelques champs.
Quand le mutator accède à un objet dans la zone ancienne, l’invariant mentionnée
ci-dessus est violé, et objet-cible doit être immédiatemment recopié. Le compila-
teur ajoute donc plusieurs instructions à chaque opération d’allocation des nou-
veaux objets. Ceci rallentit l’application.

Le reste ce sont des détails d’implantation. Mais cela suffirait pour nous occu-
per pendant quelques semaines. Le GC demande l’existence de plusieurs champs
cachés dans tout record, le GC doit connaître toute structure pour pouvoir suivre
les pointeurs. Il faut faire attention si l’application écrite en, disons, C stocke les
références aux objets sur la pile système. L’usage des registres doit également être
contrôlé.

300
Compilateurs et interprètes
(Annexe A: Entrées et sorties en Haskell)

Dans des langages impératifs, les opérations I/O sont des effets de bord. On écrit
read(x) (ou quelque chose similaire), et x change. En LF (langages fonctionnels)
ceci est illégal. On peut écrire let x=read() in ..., mais ceci est délicat.
Prenons la construction

... let x=[read(),read()]


y=[read()]
in f x y
... 1

Quel est l’ordre de lecture, comment les données seront-elles placées? La paresse
du langage rend la réponse impossible. Pour lire un caractère il suffit d’instancier
c = getChar (sans parenthèses, ceci n’est pas un appel de fonction). Cependant,
dans

let c = getChar
in [c,c]

si c est identifié avec le caractère lu, alors deux occurrences de c signifient deux
caractères différents? C’est ridicule, la variable c dans un programme signifie une
seule chose. Mais c n’est pas un caractère. C’est une action.

D’autre part, l’écriture print(x) n’est même pas fonctionnel, ceci n’est pas une
expression. On ne peut pas l’enchaîner avec d’autres constructions syntaxiques
afin de bâtir un programme fonctionnel. Or, nous voulons être fonctionnels, et, de
plus, typés. Il faut effectuer des actions sur le « monde extérieur », mais tout doit
être formalisable comme des expressions et des fonctions. En Haskell nous avons
un type abstrait, opaque, primitive et magique : 2
Le type IO

C’est un type parametré par un autre : IO a. Imaginez qu’une valeur qui appar-
tient à ce type, quand elle est vue par la « machine Haskell », déclenche une
action. Mais à l’intérieur du programme ce qui compte pour la construction c’est
la valeur, non pas l’action. L’action est importante pour la sémantique de cette
construction.

Si vous voulez imaginer ce qui représente IO a, on peut supposer que c’est une
fonction qui agit sur le Monde Extérieur Actuel, et retourne un Nouveau Monde
Extérieur, ensemble avec une donnée de type a. Or, le Monde Extérieur existe en
un seul exemplaire. . . Donc ce Nouveau Monde est toujours notre vieux Monde
Actuel, mais modifié ! 3

Opérateurs « bind » et « return »


Ainsi, nous pouvons écrire let x=getChar, où getChar :: IO Char. (Ou,
x=getString, y=getInteger, avec getInteger :: IO Integer, etc.). Mais
– soulignons le – y n’est pas un entier, il appartient au type IO Integer, et à pré-
sent il faut « décortiquer » cette valeur interne pour en faire usage. Mais IO est
un type opaque, nous n’avons pas le droit de définir une fonction avec l’entête : f
(IO x) = .... Alors, qu’est-ce que l’on fait?

Le seul opérateur censé de décortiquer une valeur de type IO a est l’opérateur


d’enchaînement (>>=) dont le nom est bind. Il est surchargé et figure dans d’autres
constructions, mais ici son type est

(>>=) :: IO a -> (a -> IO b) -> IO b

Il prend une valeur emballée, p. ex., IO x, et lui applique une fonction qui accède
à la valeur « nue » x. Cette fonction fait quelque chose avec x, mais à la fin elle
doit obligatoirement emballer son résultat, disons, y, et construire un IO y). 4
Ou, si on veut une formulation différente : le résultat de cette fonction est obliga-
toirement une autre action I/O. Donc, nous pouvons écrire

let c = getChar >>= putChar -- ou :


getChar >>= \ch -> putStr ("on a lu : " ++ [ch])

La question comment extirper un objet de l’intérieur de l’emballage IO, et le ma-


nipuler tel quel admet une réponse : il faut placer toute manipulation à l’intérieur
de la fonction à droite d’une occurrence de >>=). Peut-être faudra-t-il enchaîner
d’autres occurrences de cet opérateur.

Donc, quand on touche un objet de type IO a, on ne sort plus jamais du « monde


IO », on est « coincé ». Toutes les opérations agissant sur les valeurs non-emballées
qui ont place à l’intérieur de la fonction passée par (>>=), reconstruisent des ob-
jets IO. Est-ce contraignant? Non ! 5

Imaginez que toute fonction qui traite les IO n’est pas une fonction quelconque,
mais une composante du programme principal. On ne sort jamais du programme
principal. En fait, vous devez concentrer effectivement toute votre activité IO dans
une fonction concrète, typiquement appelée main (quelle surprise, non?. . . ). Cette
fonction sera le programme principal qui appelle d’autres fonctions.

Voici, ci-dessous une fonction qui lit les caractères depuis le flot d’entrée standard
à l’aide du getChar, et qui en construit une chaîne contenant une ligne complète.

Notez l’usage de la primitive return qui emballe un x quelconque en IO x. Ou-


bliez toute autre signification de cet identificateur dans d’autres langages de
programmation ! Ici la fonction return produit IO String. Aucun « retour »
n’a lieu, même si souvent cette fonction est appelée comme la dernière opération
d’une fonction. 6
Enchaînement des opérations

getLine = getChar >>= \c -> gl c "" where


gl ’\’ tmp = return (reverse tmp)
gl ch tmp = getChar >>= \c -> gl c (ch:tmp)

Vous pouvez essayer de résoudre un problème plus complet. Que faire si le flot
d’entrée est vide, ou si le dernier caractère lu avant \n étant le backslash ’\’ doit
forcer la lecture de la ligne suivante. . .

Bien sûr, on peut définir des fonctions locales qui traitent d’autres objets IO. Par
exemple, la fonction getInteger n’existe pas en tant que primitive. Nous pou-
vons la construire. En TD nous avons construit une fonction qui lit un entier depuis
une chaîne de caractères : strToInt :: String -> Integer. 7

strToInt "" = error "chaîne vide dans strToInt"


strToInt lst = sti lst 0 where
sti "" tmp = tmp
sti (c:cs) tmp = sti cs (10*tmp + ord(c)-48)

Si les caractères ne sont pas accessible statiquement, mais il faut les lire dynami-
quement, on combine strToInt et une fonction de lecture des chaînes, comme
getLine qui s’arrête sur un caractère qui n’est pas un chiffre.

Il est préférable – évidemment, de lire d’abord tous les caractères faisant partie
d’un objet, et jouer avec la construction interne ensuite, mais essayez de faire une
fonction qui lit les caractères et les assemble dans un entier.

Il faut savoir aussi comment écrire des choses sur le flot de sortie. 8
Opérations de sortie
La fonction putChar possède le type putChar :: Char -> IO (), on peut
trouver ou construire putInteger :: Integer -> IO (), etc. Ici la « valeur »
résultante est nulle, l’objet () est comme void en C. Mais putChar, putStr,
putInteger etc. sont des véritables fonctions, et on peut les composer/combiner.
Pour écrire deux caractères, a et b sur le flot de sortie, on peut écrire :

putChar a >>= \bidon -> putChar b

\bidon ou \_ (paramètre anonyme) n’est jamais vraiment utilisé. Donc, il existe


un raccourci (>>) :: IO a -> IO b -> IO b

expr >> suite = expr >>= \_ -> suite

Voici le programme qui lit un texte et le copie sur le flot de sortie avec la numéro-
tation des lignes. 9

outlines = outl 1 where


outl n = isEof >>=
\b -> if b then return ()
else
getLine >>= \s -> putStr (show n) >> putStr " "
>> putStr s >> putChar ’\n’
>> outl (n+1)

Tout ceci est un peu pénible, à cause de la prolifération de (>>), ce qui reste man-
geable (c’est comme les points-virgules), et – pire – à cause de l’enchaînement
des constructions ... >>= \x -> .... Il existe donc un raccourci, qui fait que
Haskell commence à paraître comme un langage impératif, avec des « instruc-
tions », affectations séquentielles, et même des blocs d’instructions. 10
Notation « do »

La construction a >> b est équivalente à do {a; b}, ou

do
a
b

et la construction a >>= \x -> b devient

do
x <- a
b

ou do {x<-a;b}. Donc les exemples ci-dessus deviennent 11

getLine = do
c <- getChar
gl c "" where
gl ’\’ tmp = return (reverse tmp)
gl ch tmp = do
c <- getChar
gl c (ch:tmp)

et pareil pour la fonction outlines Mais il ne faut jamais oublier comment une
telle séquence est compilée avec bind etc. Même si la forme structurelle d’un tel
programme ressemble à C, il reste purement fonctionnel. Donc, sa représentation
interne et sa compilation sont toujours assez simples.

Voici le restructuration de la fonction outl, interne à outlines : 12


outl n = do
b <- isEof
if b then return () else
do
s <- getLine
putStr (show n)
putStr " "
putStr s
putChar ’\n’
outl (n+1)

(Bien sûr, isEof est une fonction qui vérifie l’état du flot d’entrée, mais – comme
getChar :: IO Char elle est représentée comme une expression, de type IO
Bool, pas besoin de parenthèses, d’argument bidon (), etc.)

Pour votre devoir vous aurez probablement besoin de fonctions IO qui opèrent sur
des fichiers extérieurs. Il faudra lire la documentation de Haskell ! 13

Opération sur les fichiers


La façon la plus simple de trater les ficheirs textuels est d’opérer à l’intérieur
du programme uniquement avec chaînes, et lire/écrire le fichier une seule fois,
en effectuant la conversion entre String et IO String. Haskell est paresseux,
donc même si vous lisez le fichier entier une fois par readfile, l’action physique
consiste à lire le fichier caractère par caractère selon l’utilisation du résultat. Voici
quelques fonctions utiles :

type FilePath = String

readFile :: FilePath -> IO String


writeFile :: FilePath -> String -> IO ()
appendFile :: FilePath -> String -> IO ()

Bien sûr, il existent des fonctions qui ouvrent/ferment un fichier et qui opèrent sur
la notion de « handle ». On peut lire un caractère à l’aide de hGetChar hndl,
etc. Tout est à voir dans le rapport de Haskell 98. 14
Remarques finales ; intermezzo monadique nous attend. . .

• IO a est un exemple de type monadique. Les monades « emballent » des


valeurs quelconques dans des objets spéciaux, parfois traditionnels, comme
les listes, parfois spéciaux, comme les actions d’entrée-sortie.
• Si nous savons opérer avec les valeurs simples, les monades facilitent le « lif-
ting » vers ces objets spéciaux, qui représentent les « calculs » (ang.: com-
putation).
• Ici ce « calcul » est l’action d’entrée/sortie. Nous verrons aussi les valeurs
non-déterministes, qui peuvent être multiples, comme en Prolog, ou déclen-
cher une exception.
• Un parseur sera défini comme un type monadique qui emballe le résultat
de l’analyse : une arbre syntaxique, etc. Nous aurons des outils permettant
d’enchaîner les étapes d’analyse. Mais il faudra définir les opérateurs return
et (>>=) spécifiques, ceux utilisés dans le domaine IO ne serviront à rien !
15

(Annexe B: Un exemple du parsing)

Les sujets appartenant à l’analyse seront discutés plus tard. Mais il faut s’entraîner
tout de suite. . .

En TD (groupe 1) nous avons posé la question suivante : comment construire en


Haskell un type de données qui représente des listes Lisp simplifiées, du genre :

(aa(a)a((a)a((aa)a)(a))a)

C’est-à-dire : une liste de longueur l arbitraire (peut-être restreinte à l > 0)


d’items, où chaque item est soit l’atôme (caractère) a, soit une liste ; toute liste
étant délimitée par des parenthèses.

Nous ne voulons pas utiliser listes Haskell, la construction doit être autonôme.
Construisons alors un type de données nommé Elem qui peut être « atomique » –
une Feuille de l’arbre structurel d’une telle donnée, ou bien une Liste. 16
Une Liste peut être vide, ce qui sera dénoté par le symbole Nil, ou c’est une
paire dont le premier élémént est un Elem, et le second – une Liste. La repré-
sentation graphique de (aa(a)a((a))a) sera

a a a

L’objet Nil est dénoté par la croix diagonale. Voici la définition de la structure
permettant de construire de telles instances : 17

data Elem = Feuille | L Liste deriving (Eq,Show)


data Liste= Nil | Nd Elem Liste deriving (Eq,Show)

(La clause deriving sert pour tester, et pour pouvoir effectuer le filtrage des
paramètres pour la preocédure de l’affichage).

Pour construire le parseur il suffit d’appliquer le raisonnement récursif et d’avoir


une certaine expérience de programmation en Haskell. On commence par une
donné, un élément. Soit on trouve a, soit la parenthèse ouvrante, toute autre pos-
sibilité est exclue.

La parenthèse est consommée, et ensuite on itère jusqu’à la parenthèse fermante


correspondante. Un pas consomme un Elem. Pour faciliter la construction ité-
rative, les fonctions qui consomment le flot de données retournent la structure
construite avec le reste de la chaîne encore non parcourue. 18
elemn :: String -> (Elem,String)
elemn (c:q) | c==’a’ = (Feuille,q)
| c==’(’ = let (x,s) = lseq q
in (L x,s)
| otherwise = error "Pagaille"

Le caractère a est une Feuille, pas besoin de la paramétrer. L’itérateur s’appelle


lseq. Le voici :

lseq [] = error "Incomplet"


lseq l@(c:q) | c==’)’ = (Nil,q)
| otherwise = let (x,s) = elemn l
(p,s1) = lseq s
in (Nd x p, s1) 19

Cette fonction agit à l’intérieur d’une liste ouverte, et elle doit trouver la paren-
thèse fermante tôt ou tard.

Le reste est assez trivial. Analysez cette construction, ceci vous sera très utile plus
tard, afin de comprendre mieux les parseurs.

Passons à la procédure d’affichage, qui est un archetype de procédure de parcours


récursif des arbres ; les détails dans des contextes plus généraux ont été discutés
en TD. Ici, on ne discutera pas cette fonction.

elstring :: Elem -> String


elstring x = elst x "" where
elst Feuille s = ’a’:s
elst (L x) s = ’(’ : listr x (’)’ : s)
listr Nil s = s
listr (Nd x p) s = elstring x ++ listr p s 20

Vous aimerez peut-être aussi