Académique Documents
Professionnel Documents
Culture Documents
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
(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.
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.
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 !
• 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 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
f . g = \x -> f (g x)
id x = x
flip f x y = f y x
const x y = x
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.
dupl f x = f x x
subs f g x = f x (g x)
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
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
et la composition (.) :
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) ?
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]
Programmation paresseuse
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 :
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
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 » :
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
∗ ∗
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 :
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 +
− /
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
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))]
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
eval (L a) = case a of
(F x) -> x
(V s) -> findvar s -- dans : environment
...
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.
La conditionnelle
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.
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
(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 :
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 :
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.
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)
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
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
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 :
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.
sqrl [D x] = D(sqrt x) 47
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
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 :
∗ 2.4
2.3 +
− /
2.4 1.0
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.
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?
(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 « 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 :
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] :
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).
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.
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 :
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 :
S x == S y = x==y
L x == L y = x==y
P x == P y = x==y
_ == _ = False
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.
Une autre imperfection est critique : nous avons encore besoin de conditionnelles.
Les fonctions utilisateur sont là, il suffit d’écrire
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.
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
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
... :> cond :> cld <proc_T> :> cld <proc_E> ifelse ...
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 !!
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 :
ce qui correspond à
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 :
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.
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.
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 :
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.
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
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 :
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 !"]
Break et continue
continue break
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 :
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
stop stk _ _ =
putStrLn "STOPPING" >> return stk
97
Les générateurs du code (p. ex. conditionnel) doivent aussi savoir enchaîner cor-
rectement les instructions avec les « effets de bord »
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.
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.
Tout ceci reste valable, mais maintenant le CodeItem ne contiendra plus le Code
(la liste d’instructions) comme son paramètre.
Trivial, non?
Rappelons aussi qu’une fonction du genre donnée -> donnée peut être liftée
comme suit :
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.
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
Pour combiner les instructions nous proposons l’opérateur (.>) défini ci-dessous,
qui permet la construction des codes comme
etc. Nous n’avons plus de pile des retours, et l’instruction ret manque aussi. 105
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.
infixr 0 .>
instr1 .> instr2 = \stk -> nxt
instr1 stk (\ns -> instr2 ns nxt)
Un exemple? Le voilà :
(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
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
4
2 5
1 3 7
6 8
9
ce qui est facilement implémentable par le programme ci-dessous. 111
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
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?
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 ...
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é.
shows = showsPrec 0
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.
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
− −
× c a ×
a b b c
− c
a b
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
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é :
(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
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..."
Et la lecture?
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
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
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 :
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
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).
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 :
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.
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
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])])
(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).
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.
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
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
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.
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 :
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
– 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.
Le parseur alt sera utilisé très rarement. Nous pouvons à présent construire
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
Commençons cependant par un parseur concret, plus simple, qui accepte la chaîne
« -*> ». La grammaire
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]
appop = lit ’-’ >> lit ’*’ >> lit ’>’ >> return "-*>"
Dans la classe Monad l’opérateur « ensuite » (>>) est définit de manière univer-
selle
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.
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
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é :
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
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.
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).
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 :
filewords = do
hndl <- openFile "declar.txt" ReadMode
str <- hGetContents hndl
let lst = wordlist -*> str
print lst
hClose hndl
[(["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
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.
− c
a b
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.
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.
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
et le test
donne 181
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.
et elle sera la base d’un parseur qui construit des expressions du type
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.
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)
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
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.
[(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.
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).
N → a1 | a2 | ... | ak | N b1 | N b2 | ... | N bl
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 :
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 | ∅
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
[(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
termseq c = (do
o <- addop
t <- term
termseq (Eop o c t) ) # return c
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
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 pouvons précéder le parser word etc. par pos, et ensuite utiliser cette infor-
mation à notre guise.
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.
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.
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
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
La « suite » c’est un bind qui ignore le résultat passé. Mais parfois on peut opti-
miser cette définition. 203
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
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 :
ndinsert(X,L,[X|L]).
ndinsert(X,[Y|Q],[Y|R]):-ndinsert(X,Q,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.
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
Ensuite introduirons les fonctions du lifting, p. ex. lift0 qui monadise les va-
leurs, et lift1 qui monadise les fonctions à un paramètre.
(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 à
Pour les opérateurs binaires et les fonctions (opérateurs) binaires, les constructions
sont un peu plus compliquées, mais seulement techniquement.
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.
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.
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.
N → A1 A2 ... An
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 . . . ).
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
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 :
(while condition
instr1 instr2 ... instrN)
sera traduite en
(let loop1765 ()
(if condition (begin instr1 instr2 ... instrN (loop1765)))
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
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
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 :
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
E → E Op E | Prim
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.
expression = exs 0
exs p = primary >>= opseq p
ce qui correspond à
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.
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
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
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.
wloop = key WHILE >> lpar >> expr >>= \e -> rpar >>
lbrace >> bloc >>= \b ->
let cont = [pass]
cod = e ++ [ifnjmp cont] ++ b ++ [jmp cod] ++ cont
in cod
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
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
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 !
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.
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
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
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
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.
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.
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
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 :
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 . . ..
(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. . .
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.
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.
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.
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 (*).
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.
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 !
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
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
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 !
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 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
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.
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)
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.
B
C
y
D
F
G
288
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 :
Algorithme marquage-balayage
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.
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 ».
Algorithme de recopie
Ramasse-miettes générationnel
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.
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 :
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.
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
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
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
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.
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
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 :
Voici le programme qui lit un texte et le copie sur le flot de sortie avec la numéro-
tation des lignes. 9
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 »
do
a
b
do
x <- a
b
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.
(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
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. . .
Les sujets appartenant à l’analyse seront discutés plus tard. Mais il faut s’entraîner
tout de suite. . .
(aa(a)a((a)a((aa)a)(a))a)
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
(La clause deriving sert pour tester, et pour pouvoir effectuer le filtrage des
paramètres pour la preocédure de l’affichage).
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.