greibach normal form example

Transcription

greibach normal form example
Analyse
On passe doucement à l’analyse des programmes sources : l’analyse lexicale (scanning) et l’analyse syntaxique (parsing). Les résultats de l’analyse peuvent – si
on le veut – être stockés dans un arbre syntaxique (un code intermédiaire nonexécutable), mais ceci n’est pas important, on peut générer le code de plus bas
niveau plus directement.
Ce qui compte c’est la sémantique du programme, la signification de toutes les
constructions, la cohérence des « connaissances » du compilateur concernant le
programme-source.
Donc, nous n’allons jamais séparer l’analyse purement formelle, théorique, le
traitement des chaînes et des arbres, de notre vision plus concrète, de la nécessité
de générer un code exécutable par une machine physique ou virtuelle !
109
Ceci dit, un peu de théorie sera nécessaire. Dans le champ de compilation les
arbres poussent partout : structures syntaxiques, environnements hiérarchiques (lexicaux et dynamiques), intégration du programme à partir des modules, etc. Il est
donc souhaitable de rappeler quelques techniques modernes de traitement des
arbres, ainsi que leur réalisation dans le style fonctionnel.
Arbres de recherche
Les structures
data Arbre a = Nil | Nd a (Arbre a) (Arbre a)
permettent (dans le cas optimal) insérer et accéder à un élément en temps logarithmique. Rappelons que la technique d’insertion d’un élément consiste à descendre
jusqu’au niveau des feuilles, en allant à gauche si l’élément inséré est plus petit
que la racine (courante), sinon – à droite.
110
On remplace le Nil par le nouveau élément. Si on insère dans un arbre initialement vide les éléments de la liste [4,5,2,7,1,8,6,9,3], on obtient la structure
4
2
1
5
3
7
6
8
9
ce qui est facilement implémentable par le programme ci-dessous.
111
Ce programme définit l’insertion d’un élément, l’itérateur convertissant une liste
en arbre, ainsi qu’une fonction inverse, qui « aplâtit la liste » selon le parcours
inorder.
ins x Nil = Nd x Nil Nil
ins x (Nd y g d) | x < y = Nd y (ins x g) d
| otherwise = Nd y g (ins x d)
listree l = ls l Nil where
ls [] t = t
ls (x:xq) t = ls xq (ins x t)
flat tr = flt tr [] where
flt Nil l = l
flt (Nd x g d) l = flt g (x : (flt d l))
112
L’opération un peu plus délicate est la suppression d’un élément. Si l’objet à supprimer possède au maximum 1 fils, la solution est triviale, on fusionne le descendant unique avec l’encêtre.
Si le noeud possède deux fils, il faut effectuer les opérations suivantes :
– Localiser le successeur du noeud, l’élément suivant dans inorder. Il ne peut
avoir aucun fils gauche, car c’est le minimum de la première branche à droite
de ce noeud.
– Effacer ce successeur, et remplacer le noeud supprimé par le successeur.
Dans le cas général la recherche du successeur n’est pas triviale (il faut descendre
à droite d’un ancêtre), mais dans le contexte ci-dessus, le successeur est le minimum de la branche droite tout court. Construisons une procédure (deltmin)
qui récupère et qui efface le minimum d’un arbre (elle n’est pas protegée contre
l’échec ; veuillez la compléter vous-mêmes) :
113
deltmin (Nd x Nil d) = (x,d)
deltmin (Nd x g d) =
let (y,p) = deltmin g
in
(y,Nd x p d)
Pour effacer un noeud quelconque il suffit de lancer la procédure delt
delt x (Nd y g d) | x==y =
if g==Nil then d else
if d==Nil then g else
let (s,p) = deltmin d
in
Nd s g p
| x<y = Nd y (delt x g) d
| otherwise = Nd y g (delt x d)
114
Arbres équilibrés
Les arbres de recherche dans la compilation peuvent jouer le rôle d’environnements. Nous avons déjà utilisée une liste linéaire, pour les opérateurs, et pour les
variables. Dans la discussion d’un interprète de la forme lambda, nous avons vu
que l’environnement agit comme une pile, et de nouvelles instances recouvrent
les précédentes.
Même avec des listes linéaires on peut optimiser la recherche : garder tout atome
(identificateur, nom) en un seul exemplaire, mais attacher à son emplacement
une pile (liste) de ses instances hiérarchiques). Ou, éventuellement, pour chaque
module et chaque procédure compilée (ou interprétée) prévoir un environnement
local, et chercher d’abord là. Si la recherche échoue, on cherche dans l’environnement 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 comportement 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 (équilibré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 logarithmique 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
A
x
z
B
C
A
z
BC
D
D
Une autre configuration mauvaise peut également être traitée.
117
x
z
y
A
z
B
x
D
A
y
BC
D
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 permettent la définition des procédures dans des procédures. Ceci signifie l’existence
des variables non-locales qui ne soient pas globales. Comment y accéder?
Les techniques générales d’allocation de mémoire seront discutées plus tard. Il
faudra distinguer soigneusement entre les liaisons dynamiques et lexicales ! Mentionnons ici une astuce (utilisée d’ailleurs en Python au niveau utilisateur pour
simuler les fermetures. . . ). Étant donné une hiérarchie de deux fonctions imbriquées, avec le passage d’une variable non-locale :
119
f x = ...
let ...
g y = ... h x y
...
... g(x+1) ...
on voit que la variable x liée par f est utilisée à l’intérieur de g. Cette fonction
constitue une fermeture. On peut la compiler en dehors de f, et transformer le
code ci-dessus en
g_ y x_ = ... h x_ y
...
f x = ...
... g_ (x+1) x ...
Donc, on ajoute un paramètre supplémentaire pour chaque variable non-locale,
on compile la fonction comme pure, et on l’applique sur les variables extérieures,
traitées comme arguments.
120
Techniques de précédence
On revient à l’analyse proprement dite.
La première classe d’algorithmes de structuration de données très simplifiés est
basée sur le concept dit de « grammaires d’opérateurs », mais nous éviterons toute
référence au concept de grammaire, cela viendra plus tard. Supposons que les
entités primitives appartiennent à une de deux classes : les données, ou atomes, et
les opérateurs. (à cela il faudra ajouter des méta-objets : les parenthèses, même si
en principe on pourrait les traiter comme opérateurs. . . ).
Commençons paradoxalement non pas par l’analyse d’un texte (séquence linéaire
d’entités), mais par un « anti-parseur », ou un pretty-printer, un module qui transforme 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 paramètre entier supplémentaire. Dans le prélude standard de Hugs ceci n’est pas
exemplifié.
Rappelons que la fonction shows (définie globalement, et non pas comme un
méthode) possède la définition
shows = showsPrec 0
mais, indépendamment de cette définition nous l’avons utilisé pour optimiser la
conversion des séquences. Au lieu d’écrire
122
f = show a ++ show a ++ show a + show d ++ ...
ce qui provoque une recopie massive des données (la concaténation (++) recopie
toujours son premier argument), nous avons utilisé la fonction shows, dont la
sémantique était : convertir le premier argument, et concaténer le résultat avec le
second (une chaîne). Sémantiquement shows x str = show x ++ str.
Ceci nous permet d’écrire
f = shows a (shows b (shows c ...
""))
- ou :
f = (shows a . shows b . shows c . ...) ""
Mais, le showsPrec? Ceci nous permettra de jouer avec les précédences des opérateurs, et prendre en considération le parenthésage.
123
Regardons ces deux arbres :
−
×
a
−
c
b
×
a
b
qui correspondent aux expressions a × b − c et a − b × c.
c
124
Cependant, l’arbre
×
−
a
c
b
représentant (a − b) × c, nécessite les parenthèses pour affichage ! Parfois cela
arrive avec les opérateurs de même famille, avec l’associativité particulière. Les
expressions a − b − c et a − (b − c) ne sont pas équivalentes.
125
Dans quelles circonstances faut-il parenthéser une sous-expression? (Considérons
que l’idée générale de l’affichage textuel d’un arbre est complètement triviale, il
s’agit d’un parcours in-order le plus classique. . . )
On voit que le parenthésage est nécessaire si la précédence de l’opérateur interne
est plus faible que celle de l’opérateur-ancêtre.
Commençons par affecter à chaque opérateur binaire deux attributs.
– La précédence, un nombre entier positif ; valeur plus grande signifie l’opérateur « plus fort » (p. ex. la multiplication est plus forte que l’addition).
– L’associativité : gauche (comme les 4 opérations arithmétiques), droite (comme
la puissance, ou l’affectation en C), ou
– la non-associativité.
Ceci sera représenté par la structure de données
126
data Assop = Lft | Rgt | Noa
(Un objet de ce type, purement symbolique et mnémonique, sera converti en -1,
+1 ou zéro). Les opérateurs seront mis dans une liste associative de recherche,
disons :
infops = [(’+’,60,Lft),(’-’,60,Lft),(’/’,70,Lft),(’^’,90,Rgt),
(’<’,40,Noa),(’:’,50,Rgt),...]
qui peut, naturellement, être élargie comme on veut. Il est souhaitable que les
précédences ne soient pas contiguës, qu’il y ait un espace entre ces entiers.
Ensuite nous définissons la fonction findop qui cherche les propriétés d’un opérateur dans la liste infops, et nous pouvons passer au pretty-printer infixe des
structures arborescentes :
127
data Expr = Dat String | Eop String Expr Expr
où Dat balise une feuille, une donnée atomique, et Eop représente un noeud binaire, 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 attributs de l’opérateur trouvé :
findop st ((o,n,as):rst) | st==o = (n,asconv as)
| otherwise = findop st rst
findop st [] = error ("missing operator: " ++ show st)
(s’il existe, bien sûr ; déclencher une exception dans le cas contraire est une possibilité, mais nous pouvons également avoir un opérateur « bidon ».
128
Accessoirement nous aurons
asconv Lft = -1
asconv Rgt = 1
asconv Noa = 0
où asconv est une fonction auxiliaire qui transforme l’associativité symbolique
en numérique. On va ajouter le résultat : ±1 ou 0 à la précédence, et obtenir ainsi
la précédence gauche ou droite.
Voici le Pretty-Printer. Si l’expression est une feuille (donnée atomique), on l’affiche.
instance Show Expr where
showsPrec _ (Dat x) = showString x
129
showsPrec p (Eop op g d) =
let (np,as) = findop op infops
dl = asconv as
txt=showsPrec(np+dl)g . showString op . showsPrec(np-dl)d
in if np<p then showString "(" . txt . showString ")" else
if np>p then txt else error "non-assoc..."
Sinon, on descend récursivement et on affiche la branche gauche, l’opérateur et
la branche droite, comme prévu, mais en descendant on passe la précédence de l’
opérateur actuel, modifié par l’ajout ou la soustraction de 1, -1 ou zéro selon la
branche et l’associativité déclarée.
Enfin, on compare la précédence de l’opérateur actuel avec celle de son ancêtre,
et si le père est plus fort, on ajoute des parenthèses. À présent on voit comment
l’associativité est-elle gérée. Pour un opérateur associatif à gauche, dont la précédence est égale à 60, on passe 59 à gauche, et 61 à droite. Si la branche droite
contient le même opérateur, les parenthèses sont nécessaires.
130
Si l’opérateur n’est pas associatif, les précédences corrigées entrent en collision,
et l’afficheur déclenche une exception.
Et la lecture?
L’exemple précédant nous montre que la librairie standard de Haskell possède
quelques utilitaires de sortie des données structurées. Pour l’entrée des données
de types variés nous avons la classe Read, avec des fonctions comme read, ainsi
que readsPrec, qui peut être exploité pour le parsing – assez primitif – des expressions infixes, selon le modèle du pretty-printer « à l’envers ».
Nous allons établir un modèle plus général un peu plus tard, mais la classe standard Read va nous inspirer. La méthode de cette classe, read possède le type
String -> a, où a est le type du résultat, l’instance de la classe Read.
131
Il existe également la fonction reads, du type String -> [(a,String)]. Concrètement : read "1.23" déclaré comme Double transforme l’argument en 1.23,
mais que fait-on si la chaîne-source contient "1.23abc"?
L’idée est : on lit et on convertit l’argument, mais si la chaîne-source n’est pas
épuisée, on retourne également le reste. Ici (1.23,"abc"). Mais c’est faux, n’oubliez pas les crochets !
Parfois le parsing est non-déterministe, il peut rendre plusieurs résultats. Imaginons un parseur (stupide. . . ) défini sur les entiers, qui transforme une chaîne de
chiffres en entier. Si nous ne précisons plus rien, l’analyser "1079 marquise"
peut resulter en : [(1,"079 marquise"), (10,"79 marquise"), (107,"9
marquise"), (1079," marquise)]. Ce non-déterminisme parfois est plus
difficile à enlever que dans ce cas artificiel où il suffit de dire : accepte la chaîne
numérique la plus longue. Parsing ambigu d’habitude est une faute. Comme auparavant avec shows, la fonction reads est définie comme readsPrec 0.
132
N’oublions pas que read etc. sont des fonctions polymorphes, qui retournent les
résultats ambigus au niveau typage, il faut préciser ce que nous voulons (l’instance
concrète de la classe read !). Cependant, indépendamment du typage du résultat,
on peut séparer un flot (une chaîne) de caractères quelconques en items selon
notre intuition, p. ex. "12.54abc /=+>bbb(p+12.e)" après quelques itérations
peut nous fournir la liste ["12.54", "abc", "/=+>", "bbb", "(", "p",
"+","12", ".e", ")"].
Un tel séparateur de « tokens » est prédéfini dans la librairie standard de Haskell,
et s’appelle lex ; profitez-en. Nous allons reconstruire un scanneur lexical plus
sélectif que celui-là (adapté à une grammaire concrète), mais plus tard. En tout cas,
à cause du typage, la construction des procédures de lecture est assez fatigante.
La proposition ci-dessous est un « jouet » qui contient les ingrédients d’un parseur
sérieux, mais qui est extrêmement simplifié. Nous allons parser les expressions du
genre "a*(b+c-a/f)-g^a", composées d’atomes.
133
Ce sont des simples caractères alphabétiques, les 5 opérations arithmétiques, et
les parenthèses. Pas d’espaces, et l’expression en principe doit être correcte. Ces
éléments primitifs seront appelés tokens (jetons).
On peut catégoriser les tokens en définissant un type de données spécial, symbolique, et construire une fonction de recherche.
data Toks = Atom | Lpar | Rpar | Add | Sub | Mul
| Div | Pow | Err | Fin
toklist = [(’(’,Lpar),(’)’,Rpar),(’+’,Add),(’-’,Sub),
(’*’,Mul),(’/’,Div),(’^’,Pow)]
findtok x ((c,t):es) | c==x = t
| isAlpha x = Atom
| otherwise = findtok x es
findtok _ [] = Err
token (c:str) = let t = findtok c toklist
in (t,[c],str)
134
Notez que la fonction de recherche findtok n’échoue pas avec une erreur de
Haskell, mais rend un « token illégal » Err. Ceci constitue un ingrédient du module d’analyse qui ne se laisse pas tuer par un programme erroné (rappelons que
la fonction de recherche dans la liste des opérateurs n’avait pas cette protection ;
essayez de l’ajouter. Ceci est un bon sujet d’examen).
Nous avons également opté pour que la fin de la chaîne-source soit considérée
comme un token spécial Fin. Le caractère retourné par token est emballé dans
une chaîne en accord avec notre définition précédante du type Expr.
Nous allons lire à présent les expressions complètes, et construire les arborescences correspondantes. La solution n’est pas optimale (en particulier, quelques
tokens sont lus deux fois).
135
Voici le code :
instance Read Expr where
readsPrec p str =
let (t,ch,rst) = token str
in case t of
Atom -> readsOp (Dat ch) p rst
Lpar -> let [(e,rst1)] = readsPrec 0 rst
(r,_,rst2) = token rst1
in if r==Rpar then readsOp e p rst2
else error "Parens."
_
-> error (str)
Commentaires. On commence par la lecture du premier token qui doit être un
atome, ou la parenthèse ouvrante ; dans le second cas on appelle readsPrec récursivement, et ensuite on force la lecture de la parenthèse fermante.
136
readsOp ctx p str =
let (t,op,rst) = token str
in if any (t==) [Rpar,Fin] then [(ctx,str)] else
let (np,assc) = findop op infops
(nl,nr) = (np+assc,np-assc)
in
if nl<p then [(ctx,str)]
else let [(rgt,rst1)] = readsPrec nr rst
nctx = Eop op ctx rgt
in readsOp nctx p rst1
La suite est assurée par la fonction readsOp qui prend trois paramètres : la précédence d’entrée, et la chaîne, comme dans readsPrec, ainsi que le contexte
gauche, le paramètre ctx. Cette fonction démarre avec la « tête de lecture » positionnée devant un opérateur infixe. Il va de soi qu’il est précédé par une ou plusieurs données. La partie de données déjà analysée constitue ce contexte gauche.
La fonction s’arrête net si au lieu d’un opérateur elle trouve la fin de données ou
la parenthèse fermante, en retournant le contexte gauche.
137
La fonction retourne aussi si le nouveau opérateur possède la précédence inférieure par rapport à l’argument p. Notez que p est comparé avec la précédence
gauche de l’opérateur.
C’est ici que l’inefficacité se manifeste ; un opérateur plus faible, ou la parenthèse fermante seront lus deux fois.
Sinon, après avoir consommé l’opérateur, readsOp relance readsPrec, en lui
passant comme p la précédence droite de l’opérateur. Après avoir récupéré le résultat le parseur forme un noeud de l’arbre-résultat, et le passe comme le nouveau
contexte gauche à l’appel récursif terminal.
Quelques techniques d’optimisation, notamment l’usage des piles (privées) d’opérateurs, seront discutées plus tard. Mais le squelette conceptuel est là, et il doit être
assimilé.
138
Grammaires et parseurs
Passons à la théorie générale (appliquée !) du parsing. Rappelons qu’un langage
est un ensemble de phrases valides, et que la validité est établie par une grammaire. Nous n’envisageons pas répéter le cours Langages et Automates !, son
contenu est considéré acquis. Une grammaire formelle (sans sémantique) est un
ensemble qui contient
1. Un certain nombre de symboles terminaux ou constantes syntaxiques. Ce
sont des « mots » concrets qui appartiennent au langage ; des littéraux.
2. Les non-terminaux ou variables syntaxiques. Ce sont des méta-mots, des
descriptifs des catégories, comme « expression », « phrase verbale », « opérateur », « boucle », etc.
3. Un non-terminal spécifique est considéré le symbole initial du langage, par
exemple la variable « programme ».
4. Un ensemble de règles ou productions qui définissent récursivement les nonterminaux.
139
On appliquera cette caractérisation également à analyse lexicale, où une phrase
formelle est un mot normal, et les éléments, les mots formels sont des lettres
(chiffres, caractères spéciaux, etc.) La grammaire peut donc posséder 2 ou plus niveaux. Mots composés de lettres. Phrases (sentences) composées de mots. Poèmes
composés de phrases, etc.
Dans le domaine de compilation de langages de programmation, les grammaires
utilisées sont d’habitude non-contextuelles, ce qu’implique que toute production
aura la forme :
non-terminal → Séquence de terminaux et non-terminaux
où la séquence peut contenir des symboles juxtaposés, ce qui signifie la concaténation, la barre verticale significant l’alternative, ou les parenthèses (comme
méta-symboles, dont le rôle est évident). On ajoute à cet ensemble le symbole ∅
qui dénote la chaîne vide.
140
Attention. La grammaire peut être non-contextuelle, mais le processus du parsing,
et en particulier plusieurs éléments d’analyse sémantique sont contextuels, et il
faudra durant l’analyse maintenir des traces des choses faites, et passer « en aval »
quelques paramètres qui détermineront les choix des futures stratégies.
Quelques grammaires
Voici une séquence itérative, une chaîne de caractères entre guillemets, ou – après
quelques modifications de notation, sans changer la structure : les listes (Scheme/Lisp)
composées d’atomes (considérés irréductibles) et entourées de parenthèses (les
listes complètes seront traitées plus tard).
Liste → Lpar Sequence Rpar
Lpar → ’(’
Rpar → ’)’
Sequence → ∅ | Atom Sequence
ou, peut-être : Sequence
→ ∅| Sequence Atom.
141
La concaténation est évidemment associative, et syntaxiquement les deux variantes sont identiques. Une chaîne :
Chaine → ’"’ SeqChar ’"’
SeqChar → ∅ | Char SeqChar
ou la variante récursive à gauche. Notons la similitude de ces deux grammaires.
En construisant les parseurs nous allons exploiter les fonctions d’ordre supérieur
pour mettre sous le même chapeau – différemment paramétré – de telles et d’autres
structures linéaires, itératives.
Nous avons vu également la grammaire qui décrivait les listes en Prolog. Les
éléments pouvaient être des atomes, mais aussi d’autres listes (en fait, des termes
Prolog quelconques, mais c’est un bon exercice personnel). La liste pouvait être
« irrégulière », et se terminer par un item plutôt que par la liste vide. Ces listes
sont légales : [], [a,b,c], [a,[b,c],d | e], [[],a,b | [c,d]].
142
Les complications sont assez nombreuses :
– Si les listes peuvent être imbriquées, la structure de la grammaire est récursive
et non pas itérative.
– La virgule entre les éléments constitue un élément non-trivial, elle se trouve
toujours entre deux items, jamais au début ni à la fin.
– L’irrégularité terminale doit être traitée soigneusement. La barre ne peut se
trouver devant rien.
Exercice. Modifiez la grammaire ci-dessous afin de rendre légal la construction
[| x] (ceci était valide en micro-Prolog). On y va :
Trm → Atom | Liste
Liste → ’[’ Seq ’]’
Seq → ∅ | Trm Seq_
Seq_ → Fin | ’,’ Seq_
Fin → ∅ | ’|’ Trm
143
Deuxième classe d’exemples concerne le langage des expressions algébriques
(arithmétiques), avec des atomes, les 4 (ou 5) opérations infixes de base, ainsi
que les parenthèses. La grammaire est rigide. Au lieu de parler de précédence des
opérateurs on divise les expressions parsées en
– primaires : ce sont des atomes, ou des expressions parenthésées ; on les appelera également des facteurs ;
– secondaires, ou termes (ne pas confondre avec aucun autre usage de ce terme. . . ) :
ce sont des séquences de facteurs connectés par des opérateurs multiplicatifs
(* ou /) ;
– enfin, des expressions générales, des sommes de termes – séquences de termes
liés par les opérateurs additifs (+ ou -, plus faibles que les multiplicatifs).
Les atomes sont considérés comme primitifs. Voici la grammaire :
144
Expr → Terme | Expr OpAdd Terme
Terme → Facteur | Terme OpMul Facteur
Facteur → Atom | ’(’ Expr ’)’
OpAdd → ’+’ | ’-’
OpMul → ’*’ | ’/’
Notez la propriété essentielle des constructions ci-dessus. L’associativité gauche
des opérateurs implique la récursivité à gauche des productions syntaxiques ; la
« première chose faite » par Expr est de référencer Expr si le premier variant
échoue. La récursivité à gauche est une affaire délicate, et elle sera discuté dans
les détails.
On peut inventer de dizaines d’autres exemples de grammaires utiles, disons : les
commentaires, simples et imbriqués ; la structure de nombres flottants avec l’exposant (et la base variable).
145
Et encore : les grammaires qui décrivent les grammaires (pour la construction des
méta-parseurs) ; les grammaires décrivant les expressions régulières (pour les générateurs des scanneurs lexicaux) ; la grammaire des structures en Haskell, avec
des opérateurs « sectionnés » : (x ==) ou (* y) ; les blocs en Smalltalk qui mélangent la structure d’une liste avec une séquence d’instructions ; les déclarations
en C++ (avec les pointeurs, tableaux, références procédurales, avec les modificateurs du genre const, etc.)
Parfois on peut s’étonner que les langages déclaratifs (descriptifs : VRML, SVG ;
les déclarations de données en Haskell, Java ou C++, ou . . . ) sont sur le plan
syntaxique beaucoup plus riches que les langages « de programmation », mais
c’est normal.
Quelques exemples seront discutés en TD, et mentionnés ad hoc pendant notre
travail de construction des parseurs.
Essayons de préparer un cahier des charges conséquent, pour la construction des
parseurs et les utilitaires qui vont avec.
146
1. L’analyse c’est la reconnaissance des structures, la validation de la source.
Pour nous la compréhension sera active, et on doit prouver que le texte analysé est sain. Les parseurs seront donc équipés de procédures sémantiques qui
assembleront les propriétés des objets analysés, et permettront de générer, par
une séquence de transformations, un code potentiellement exécutable.
2. La construction des parseurs doit suivre de manière la plus fidèle possible
la grammaire du langage source. On fait la publicité des générateurs de parseurs (p. ex. Yacc) en disant : vous définissez la grammaire, le générateur
vous produit un programme de parsing. Nous voulons avoir le même comfort
intellectuel.
3. Avec les générateurs la vie n’est pas tellement douce, car les procédures sémantiques doivent être écrites par l’utilisateur quand même. Ajouter des procédures sémantiques à un parseur écrit en Yacc n’est pas facile. Nous voulons
intégrer – en accord avec la philosophie du point (1) – les procédures sémantiques de manière naturelle et universelle. Les fonctions d’ordre supérieur
seront inestimables !
147
4. Nous n’allons pas séparer – comme dans quelques autres approches à la compilation – l’analyse lexicale de l’analyse syntaxique. Notre approche, fonctionnelle 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 ascendantes 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 entités connectées ensemble par des opérateurs (>>=) (bind) et (>>), et la fonction
return. Les monades dans ce contexte se ressemblent beaucoup à des continuations, et c’est normal : l’analyse consomme le flot de données, et à chaque moment
il faut préciser ce que l’on fait ensuite, on continue la lecture, ou réduit les tokens
à un sous-arbre, etc.
Mais nous profiterons de cette occasion pour parler de monades dans un contexte
plus général, comme d’une réalisation fonctionnelle des concepts sématiques
liés au « calcul » général, évaluation des expressions, mais également des concepts
impératifs et du non-déterminisme logique (utile pour savoir comment on compile
Prolog).
149
Parseurs monadiques
Les techniques fonctionnelles, combinatoires du parsing appartiennent à la catégorie des parseurs descendants, où on construit l’arbre syntaxique (physique ou
conceptuel) à partir de la racine. Les non-terminaux de la grammaire seront transformés en fonctions d’analyse, et les paramètres de ces fonctions (ainsi que leurs
résultats) permettront de transmettre l’information sémantique. Pour le contraste :
le parseur construit auparavant, qui utilisait les opérateurs et leur précédences était
un parseur ascendant, on voit clairement comment on récupère les feuilles et comment on les assemble en expressions.
Nous allons suivre quelques règles générales répertoriées ci-dessous. Ceci doit
permettre de construire des parseurs universels, de réagir aux situations exceptionnelles, 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 accepte aussi la possibilité d’échec, ou le parsing ambigu : plusieurs résultats
possibles. Donc, on retourne une liste de paires (résultat,flot restant), comme
dans notre parseur avec précédences. L’échec c’est la liste vide.
– Sur le plan opérationnel le parseur est une fonction. Mais pour des raisons
techniques nous préférons de le considérer comme une donnée, un objet typé
spécifique. Pour qu’il agisse sur son flot d’entrée on utilisera un opérateur
d’application spécial, disons (-*>).
151
Définissons donc :
infix 0 -*>
newtype Parser c a = Pa ([c] -> [(a,[c])])
Pa pfun -*> inpt = pfun inpt
(Rappelons que newtype en Haskell peut être assimilé à data, mais son implantation ressemble plutôt à type ; la balise joue uniquement le rôle d’identification,
la représentation interne du parseur est une fonction (et l’opérateur (-*>) tout
simplement l’applique, comme le dollar).
Le type a dénote le résultat ; les parseurs seront paramétrables et polymorphes.
Le type c décrit les items élémentaires dans le flot d’entrée : les caractères pour
le scanneur, les lexèmes pour le parseur, ou, p. ex. chunks de code intermédiaire
pour un « parseur » qui en fait est un générateur de code.
152
Les lexèmes classiques sont – par exemple –
data Lexem = I Integer | F Double | Lpar | Rpar | Comma | Semic
| Colon | Idn String | Lbrack | Rbrack | Op String
| Apo | Quot | Period | Str String
Ils doivent être distincts et reconnaissables. Le scanneur lexical est un parseur
universel restreint :
type Scanner = Parser Char Lexem
Ce scanneur doit s’appliquer à une chaîne, et fournir un lexème. Si nous voulons intégrer notre compilateur comme une séquence de « boîtiers » formant une
« pipeline », il faudra placer les lexèmes dans une liste (paresseuse).
153
On devra donc construire une procédure qui lit un fichier textuel et produit une
liste de lexèmes. Ceci viendra un peu plus tard. Désormais les espaces seront
considérés comme séparateurs, coupant p. ex. les identificateurs, mais ils ne feront
jamais partie d’un lexème, sauf dans les chaînes littérales "Belle marquise
etc."
La philosophie générale qui va nous guider tout le temps est : un parseur qui
retourne un résultat de type a (avec tout le « bazar » : le input restant, et tout
emballé dans une liste) est analogique à IO a, c’est une action qui engendre un
objet de type a. On utilisera le terme token pour désigner un item primitif dans le
flot d’entrée.
Parseurs primitifs
Commençons par quelques parseurs très simples. Voici un parseur qui découpe et
retourne un token :
154
item = Pa (\(x:xs) -> [(x,xs)])
Attention. Cette définition est incomplète. Et si le flot d’entrée est vide? Le parseur
doit échouer (au lieu de déclencher une exception Haskell. . . )
Si on est là, nous pouvons imaginer des parseurs encore plus simples que ça !
Voici deux parseurs primitifs, l’un échoue toujours, et l’autre retourne un résultat
constant, sans lire quoi que ce soit.
failure = Pa (\_ -> [])
succeed x = Pa (\s -> [(x,s)]
Les deux seront très utils comme éléments des compositions. Parlant de composition : comme il a été suggéré, la chaîne de parseurs qui mastique et digère le flot
d’entrée, ressemble un peu les opérations d’entrée/sortie formulées dans le style
monadique.
155
Donc, nous voudrons réutiliser les mécanismes de composition définies pour la
monade IO. Concrètement, nous déclarons les parseurs comme des instances de
la classe Monad. Attention : le constructeur, Parser c sans « a » peut être monadique. IO est une monade, non pas IO Char etc.
instance Monad (Parser c) where
fail s = failure
return = succeed
Nous connaissons déjà return, l’échec existe aussi dans IO, car la lecture ou écriture peuvent échouer. La définition est incomplète, il nous manque le compositeur
(>>=), mais construisons d’abord notre item
item = Pa itm where
itm (x:xs) = [(x,xs)]
itm [] = []
156
La forme bind est intuitivement triviale. Un parseur produit un résultat. Une fonction récupère ce résultat, et en produit un autre parseur qui agira sur le flot d’entrée
restant. Mais – une seconde. . . et si le premier parseur échoue, ou s’il retourne
plusieurs résultats (et plusieurs flots restants possibles)?
Alors il faudra appliquer la fonction à droite du bind à tous résultats possibles,
et rendre la liste aplâtie. Sachant que la fonction produit un parseur générant une
liste d’un résultat primaire, individuel, ensemble nous aurons une liste de listes
qu’il faut mettre à plat.
instance Monad (Parser c) where
fail s = failure
return = succeed
(Pa prs) >>= fun = Pa
(\inp -> concat [fun v -*> inp1|(v,inp1) <- prs inp])
157
La fonction concat est prédéfinie : concat = foldr (++) [], elle transforrme
[[a],[b,c],[d,e]] en [a,b,c,d].
Combinateurs élémentaires
Tout langage est structuré. Les chiffres forment des nombres, les lettres – identificateurs, etc. Il faut équiper notre boite de quelques utilitaires de reconnaissance.
Voici le parseur sat p où p est un prédicat Booléen, qui rend un item à condition
qu’il satisfait ce prédicat, sinon le parseur échoue.
sat p = item >>= \x -> if p x then return x else failure
Notez que ce parseur n’est plus élémentaire, mais une combinaison. cette combinaison 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 scanneurs lexicaux primitifs :
lit x = sat (\y -> x==y)
-- {\em ou } sat (x==)
digit = sat (\x -> ’0’ <= x && x <= ’9’)
lower
= sat (\x -> ’a’ <= x && x<=’z’)
upper
= sat (\x -> ’A’ <= x && x<=’Z’)
Mais comment définir une lettre tout court, majuscule ou minuscule? Nous aurons
besoin d’un autre combinateur : l’alternative (comme dans le domaine de grammaires, n’est-ce pas?)
159
L’alternative dans le monde des parseurs existe en deux variantes :
– Logique, inclusive. On retourne tous les résultats produits par l’un ou par
l’autre parseur.
– Opérationnelle, ordonnée. On lance le premier parseur. S’il retourne un résultat, on l’accepte. Sinon on lance le second, et on prend son résultat. Ceci
est évidemment la solution privilégiée, plus efficace, à condition que les deux
parseurs s’excluent réciproquement.
Appelons le premier parseur, inclusif alt, et l’autre, séquentiel et plus efficace –
(#), c’est un opérateur infixe de précédence assez basse (mais supérieure à celle
du (-*>)). Voici leurs définitions.
160
infixl 1 #
alt (Pa p) (Pa q) = Pa (\inp -> p inp ++ q inp)
(Pa p) # (Pa q) =
Pa (\inp -> let s=p inp
in if s==[] then q inp else s)
Le parseur alt sera utilisé très rarement. Nous pouvons à présent construire
letter = lower # upper
alphanum = letter # digit
etc. On peut passer aux séquences qui permettront, p. ex. la construction des mots,
des nombres, etc. Il faut qu’un parseur suit un autre, ce qui implique des définitions récursives, comme dans la définition syntaxique
Word
→
Letter | Letter Word
161
Commençons cependant par un parseur concret, plus simple, qui accepte la chaîne
« -*> ». La grammaire
AppOp
→
’-’ ’*’ ’>’
ne nous dit rien à propos du résultat retourné. On retourne alors tout simplement
cette chaîne. Voici le parseur, qui accepte une chaîne de longueur quelconque,
mais qui exige que les trois premiers caractères forment le symbole désiré.
appop = lit ’-’ >>= \c1 -> lit ’*’ >>= \c2 ->
lit ’>’ >>= \c3 -> return [c1,c2,c3]
L’appel appop "-*>abc" rend [("-*>","abc")]. Mais – allons !. . . Si on sait
a priori quels sont les caractères qui doivent être lus, on n’a pas besoin de les
stocker. Nous pouvons les lire et oublier, et à la fin retourner le même résultat.
162
La définition ci-dessous serait plus compacte :
appop = lit ’-’ >> lit ’*’ >> lit ’>’ >> return "-*>"
Dans la classe Monad l’opérateur « ensuite » (>>) est définit de manière universelle
m1 >> m2 = m1 >>= \_ -> m2
mais il est inutile de passer à la fonction à droite un argument jamais lu. Donc,
nous augmentons notre instance monadique par la définition plus explicite, un peu
plus efficace.
(Pa pr1) >> (Pa pr2) = Pa
(\inp -> concat [pr2 inp1 | (_,inp1) <- pr1 inp])
163
Les mots composés de lettres seront assemblés par le parseur qui contient une
séquence (récursive), et une alternative. Voici une solution
word = (letter >>= \c -> word >>= \s -> return (c:s))
# (letter >>= \c -> return [c])
où on note immédiatemment une légère inefficacité. La dernière lettre sera lue
deux fois, car la première clause alternative finalement échouera. Il serait bien
d’accepter une règle d’or : l’optimisation d’un parseur commence souvent par
l’optimisation de la grammaire :
Word = Letter (Word | ∅)
Notez d’ailleurs que l’ordre inverse dans la clause récursive produirait un résultat
correcte, mais indésirable : le parseur lirait la première lettre et terminerait l’analyse, car la première alternative (vide) se termine bien et le parseur n’a aucune
raison de continuer.
164
Par contre, la construction avec alt fournirait toutes les solutions : une lettre,
deux, trois,. . . . Voici le parseur optimisé :
nullp = return ""
word = letter >>= \c ->
(word # nullp) >>= \s -> return (c:s)
Son application à "Belle marquise" retourne [("Belle"," marquise")],
l’espace arrête l’analyse, car letter échoue. On pourra utiliser la technique décrite ici pour assembler toute simple séquence, par exemple les entiers composés
des chiffres. L’itération suit exactement le même modèle. Cependant dans ce cas
on n’est pas tellement intéressé par une chaîne de chiffres, on veut un nombre
entier comme résultat.
Nous allons donc construire un parseur d’ordre supérieur, un itérateur, qui applique 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’assemblage numérique pour les entiers, etc.
165
Le parseur élémentaire et la fonction d’assemblage seront des paramètres de notre
itérateur. Il manque tout de même un ingrédient : la valeur initiale du tampon
d’assemblage, disons "" pour les chaînes et 0 pour les nombres.
Voici une proposition avec une erreur !!
many p cnstr ini =
let loop = p >>= \x -> (loop # return ini)
>>= \s -> return (cnstr x s)
in loop
word = many letter (:) ""
integ = many digit (\d n -> 10*n+ord d - 48) 0
La fonction many est OK, et le parsing word -*> "Belle marquise" est correct. Cependant l’exécution de integ -*> "0102360X" produit [(632010,"X")]. . .
166
Notre fascination par la généralité de la solution nous a fait oublier que la règle récursive à droite n’est pas bonne pour un assemblage direct des nombres. Consultez
vos notes de TD !
Une solution de ce dilemme est évidente. Le parseur assemble toujours une chaîne
(une liste de tokens légaux), et à la fin, à l’extérieur de la boucle on lance une
procédure séparée de conversion. Ceci est plutôt inefficace.
Une autre possibilité, exploiter la récursivité à gauche, selon la grammaire
Word
→ Letter | Word Letter = (Word | ∅) Letter
etc., engendre un désastre imminent. Construisez le parseur. Le bind exécuté à
l’intérieur du word relance à nouveau word avant de consommer quoi que ce soit.
Le système boucle. . .
Ce problème reviendra lors du parsing des expressions arithmétiques, où l’associativité gauche est inéluctable.
167
Rappelons-nous de la fonction construite en TD, qui construisait corretement un
entier à partir d’une chaîne numérique.
intf s = itf s 0 where
itf "" t = t
itf (c:s) t = itf s (10*t + ord(c)-48)
Il suffisait d’ajouter un tampon. . . L’itérateur universel many ne prévoit pas cette
possibilité. L’autre solution n’est pas plus compliquée que many, mais cette fois
le parseur qui boucle sera parametré par le tampon.
lmany p cnstr tmp = loop tmp where
loop t = p >>= \x -> let y = (cnstr x t)
in (loop y # return y)
integ = lmany digit (\d n -> 10*n+ord d-48) 0
168
On peut modulariser un peu plus cette solution, car la séquence de deux ou plusieurs parseurs est un pattern commun, indépendamment de la récursivité (sans
ou avec tampon).
seqr p1 p2 cnstr ini =
p1 >>= \x -> (p2 >>= \s -> return (cnstr x s))
# return (cnstr x ini)
many p cnstr ini = loop where
loop = seqr p loop cnstr ini
seql p1 pp cnstr tmp =
p1 >>= \x -> let y = (cnstr x tmp)
in (pp y # return y)
lmany p cnstr = loop where
loop = seql p loop cnstr
Les parseurs word et integ restent sans modification.
169
D’habitude il est utile de considérer les espaces comme des caractères qui n’apportent rien, et qui peuvent se trouver optionnellement devant un token sérieux.
Construisons le parseur optspaces p qui « avale » un nombre quelconque d’espaces qui précèdent l’item reconnu par le parseur p (qui retourne le résultat).
space = lit ’ ’ # lit ’\n’
optspaces p = optsp where
optsp = (space >> optsp) # p
Quelle est le résultat de cette expérience :
wordlist = many (optspaces word) (:) []
res = wordlist -*> "Belle marquise vos 876 yeux d’amour"
170
Analyse d’un fichier
Avant de passer aux parseur plus complexes, discutons l’usage de nos parseurs
non pas pour analyser une chaîne donnée, mais pour lire et digérer un fichier
complet. Rappelons-nous : quand on commence à lire un fichier, ou – en général –
à s’engager dans les activités I/O, on passe à un protocole « impératif », séquentiel
de Haskell, on « entre » dans la monade IO, et on ne peut pas en sortir « comme
ça ». Si on commence par l’ouverture d’un fichier, cette manipulation produit un
descripteur du fichier, de type IO Handle, Ensuite nous pouvons construire un
flot paresseux de caractères qui correspond au contenu de ce fichier, mais toutes
les opérations auront lieu dans ce « programme principal » IO. Essayons donc de
découper en mots un fichier declar.txt, qui contient :
Tous les etres humains naissent libres et égaux en dignité
et en droits. Ils sont doués de raison et de conscience et
doivent agir les uns envers les autres dans un esprit de
fraternité.
171
L’affichage du résultat est obtenu en lançant la fonction filewords définie cidessous.
filewords = do
hndl <- openFile "declar.txt" ReadMode
str <- hGetContents hndl
let lst = wordlist -*> str
print lst
hClose hndl
où nous avons ajouté à l’ensemble de lettres auusi le « é » accentué, et le point.
Cette fonction ne retourne rien. Nous pouvons ajouter quelque chose, comme
return lst, mais ceci ne changera rien, on ne pourra utiliser directement ce
résultat, car il restera emballé dans la monade IO.
172
Vraiment, toutes les opérations sur le texte lu doivent rester dans le bloc do. Le
résultat imprimé est
[(["Tous","les","etres","humains","naissent","libres","et",
"\233gaux","en","dignit\233","et","en","droits.","Ils","sont",
"dou\233s","de","raison","et","de","conscience","et","doivent",
"agir","les","uns","envers","les","autres","dans","un","esprit",
"de","fraternit\233."]," ")]
Notez que le système d’affichage standard de Haskell n’aime pas tellement les
caractères hors ASCII. Comment y rémédier?
En fait, en disant que tout doit se trouver alors dans le bloc do ci-dessus nous
avons menti. . .
173
Dans un autre variante, la fonction filewords n’imprime rien, mais on lui ajoute
comme sa dernière instruction est return lst. C’est un résultat monadique,
donc une action, invisible. Ensuite on définit
printwords = filewords >>= print
et notre liste peut être affichée. Mais elle ne le sera pas pour des raisons techniques pertinentes à Haskell !
Le fait que Haskell est un langage paresseux doit être toujours présent à l’esprit ! 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 fonction filewords est lancée. Toutes les instructions qui engendrent lst (p. ex. sa
lecture) restent latentes jusqu’à la demande d’imprimer lst.
174
Mais si avant cette impression on ferme le fichier par hclose hndl, la lecture
échoue. On doit le laisser ouvert, et fermer plus tard, donc dans un programme
professionnel la fonction qui imprime, ou fait quelque chose avec des données
lues, doit recevoir comme paramètres toutes les handles des fichiers ouverts, pour
pouvoir dûment nettoyer le contexte du travail.
Nous avons terminé – pour l’instant – la discussion des parseurs « lexicaux »,
relativement simples, itératives. Les parseurs syntaxiques peuvent être beaucoup
plus élaborés, et nous verrons d’autres combinateurs universels. Cependant nous
reviendrons encore une fois à la couche de base, la lecture du fichier et l’assemblage des tokens lexicaux, car il faudra répondre au moins à la question : comment
diagnostiquer une faute dans le programme source? Comment tranmettre à l’utilisateur l’information où l’erreur a été découverte?
175
Parsing phrasal général
Cette fois notre volonté d’avoir toujours une relation immédiate entre la grammaire et le parseur correspondant est encore plus forte. Commençons par un sousensemble très réduit d’expressions algébriques, avec l’addition, la multiplication
et les parenthèses. Les opérateurs impliqués sont associatifs et symétriques, donc
on pourra prétendre qu’ils soient associatifs à droite.
On essaira de construire des arbres syntaxiques comme déjà vus :
×
−
a
à partir de la forme textuelle.
c
b
176
Cependant on passera d’abord par la couche lexicale, qui construit un flot de
lexèmes définis auparavant. Il faut alors que le scanner soit capable de discerner
et de retourner un Lexem quelconque :
Lexem
→
Integer | Double | Ident | Comma | Lpar | ...
D’abord, rappelons la structure de données qui représentera le résultat sortant :
data Lexem = I Integer | F Double | Lpar | Rpar | Comma | Semic
| Idn String | Lbrack | Rbrack | Op String | Apo
| Quot | Period | Str String
deriving(Eq,Show)
où, naturellement, les symboles purs comme la parenthèse ouvrante se réduisent à
la balise, mais les nombres ou les identificateurs doivent être transmis comme objets possédant un type et un contenu. (On devrait, d’ailleurs, déjà ici introduire un
sous-ensemble très important des symboles parametrés : les mots-clefs, différents
des identificateurs).
177
L’instance Show pour les lexèmes est redondante, uniquement pour des tests visuels. L’instance Eq peut être essentielle !
Le parseur des expressions ne lira pas directement des chaînes de caractères, mais
des listes lexicales. Construisons le module correspondant.
Scanneur lexical relativement complet
Quelques caractères spéciaux se transforment en simples balises :
tagch c t = sat (c==) >> return t
lpar = tagch ’(’ Lpar
rpar = tagch ’)’ Rpar
comma = tagch ’,’ Comma
apostr = tagch ’\’’ Apo
quote = tagch ’"’ Quot
178
etc. On considère que les opérateurs sont formés uniquement des caractères spéciaux spécifiques. On introduit donc une séquence de caractères discriminés par
l’appartenance à un ensemble précis (lettres, etc.), de manière plus générale qu’auparavant, et on ajoute un parseur-constructeur, qui retourne une chaîne balisée :
charin l = sat (\y -> any (y==) l)
chain p = many p (:) []
tagit
= (return .)
opchar = charin "+-*/=<>#%:!?"
oper = chain opchar >>= tagit Op
ident = seqr letter (chain alphanum) (:) "" >>= tagit Idn
intnum = integ >>= tagit I
179
La notation est très compacte, et si quelqu’un pense à apprendre tout cela durant
l’examen, je lui souhaite bon courage. . .
Mais tout est simple. Rappelons que any est le « ou » logique itéré sur une liste ; on
termine l’itération si on trouve le premier élément « vrai » par rapport au prédicat.
Le parseur chain est un cas trivial de many, où la construction du résultat se
réduit à la concaténation.
Le parseur-baliseur tagit mérite quelques mots. Sa définition expansée est :
tagit balise objet = return (balise objet)
Ensuite, rappelons encore une fois le parseur integ. Il est un peu différent de la
version précedente à cause du fait que ord retourne un Int, et nous avons besoin
d’un Integer.
180
integ = lmany digit (\d n -> 10*n+toInteger(ord d)-48) 0
Finalement, nous construisons un lexème générique comme l’alternative des cas
particuliers, en ajoutant le consommateur d’espaces. La dernière étape est l’itération de ce parseur, ce qui génère une liste de lexèmes.
lexem = foldr1 ((#) . optspaces)
[lpar,rpar,ident,intnum,oper,comma]
lexlist = chain lexem
et le test
res0 = lexlist -*>
"alph0 + (x+17/12)>=(x*6++d1) - beta"
donne
181
[([Idn "alph0",Op "+",Lpar,Idn "x",Op "+",I 17,Op "/",
I 12,Rpar,Op ">=",Lpar,Idn "x",Op "*",I 6,Op "++",
Idn "d1",Rpar,Op "-",Idn "beta"],"")]
La couche lexicale présentée ici n’est pas – évidemment – complète. Il faut faire
quelque chose en TD, et réserver un peu du mystère pour l’examen. Cependant,
tous les ingrédients de reconaissance et d’assemblage sont là. On n’a pas discuté la
gestion des erreurs ni l’intéraction entre le scanneur et la table des symboles (pour
ne garder les identificateurs qu’en un seul exemplaire). Tout ceci est à découvrir
aussi à travers votre travail personnel.
On passe aux structures syntaxiques générales.
182
Structures algébriques simplifiées
Notre grammaire aura la forme suivante :
Expr → Term | Term ’+’ Expr
Term → Factor | Factor ’*’ Term
Factor → Ident | ’(’ Expr ’)’
et elle sera la base d’un parseur qui construit des expressions du type
data Expr = Dat String | Eop Ops Expr Expr
deriving (Eq,Show)
data Ops = Add | Sub | Mul | Div | Pow | Lth | Gth | Equ | Leq
deriving (Eq,Show)
183
presque comme auparavant. Cette fois le noeud d’une expression au lieu de contenir la chaîne "+", etc, contient un opérateur symbolique, la balise Add, etc. On
prolifère les balises, mais ainsi le processus de reconnaissance devient plus simple.
Comme il a été dit, le parseur lira une liste de lexèmes balisés, générée par l’itérateur lexlist, p. ex.
lexpr = lexlist -*> "a+b0*(a1+b2*psi)*(b*(c+x))"
La seconde couche du parsing demandera quelques utilitaires comme la procédure
de recherche des opérateurs dans un environnement :
listop = [("+",Add),("*",Mul),("/",Div),("^",Pow),
("<",Lth),(">",Gth),("==",Equ),("<=",Leq)]
184
opfind st ((x,opt):rst) | st==x = opt
| otherwise = opfind st rst
opfind st [] = error ("operator: " ++ show st)
ainsi que quelques abbréviations, facilitant l’identification des lexèmes.
addop = lit (Op "+") >> return Add
mulop = lit (Op "*") >> return Mul
lparen = lit Lpar >> return (Dat "")
rparen = lit Rpar >> return (Dat "")
Les deux dernières définitions sont naives, on retourne une expression bidon uniquement pour satisfaire le système de types, et assurer le compilateur que lparen
et rparen sont des parseurs de type : Parser Lexem Expr.
185
Le parseur principal est une traduction presque littérale de la grammaire ; la seule
chose intéressante ce sont les éléments sémantiques qui assemblent le résultat
final. La seule chose gênante c’est la lisibilité, toujours médiocre si un fragment
de programme contient trop de lambdas. . . :
atom = item >>= \x-> case x of
(Idn y) -> return (Dat y)
_
expr = term
-> failure
>>= \t ->
((addop >>= \o ->
expr
>>= \s -> return (Eop o t s))
# return t)
186
term = factor >>= \t ->
((mulop >>= \o ->
term
>>= \s -> return (Eop o t s))
# return t)
factor = atom # (lparen >> expr >>= \e -> rparen >> return e)
cependant, grâce aux combinateurs déjà connus comme seqr, on peut simplifier
considérablement ces expressions.
Le résultat du parsing est :
[(Eop Add (Dat "a") (Eop Mul (Dat "b0") (Eop Mul
(Eop Add (Dat "a1") (Eop Mul (Dat "b2") (Dat "psi")))
(Eop Mul (Dat "b") (Eop Add (Dat "c") (Dat "x"))))),[])]
et il suffit d’appliquer notre pretty-printer pour voir si le parsing est correct.
187
Récursivité à gauche
Avec les règles récursives à droite, ou avec les éléments imbriqués, comme les
sous-expressions parenthésées ou des listes hiérarchiques, il n’y a plus de problèmes. Nous pouvons p. ex. analyser les listes en Lisp/Prolog, les séquences
d’instructions avec des blocs, les segments balisés en HTML/XML, etc.
Cependant pour la « vraie » arithmétique, la grammaire possède des productions
récursives à gauche, à cause de l’associativité dans :
Expr
→
Term | Expr Addop Term
Comme nous avons déjà mentionné, la clause à droite (la plus longue) doit être
essayée en premier, et l’opérateur (>>=) relance Expr avant de lire l’item suivant.
La parseur ne progresse pas, il boucle.
188
La solution est simple, mais demande une très bonne connaissance de la sémantique 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écursives à gauche peut être transformée en grammaire récursive à droite équivalente
(qui reconnaît le même langage ; nous parlons ici uniquement de la syntaxe, non
pas de l’interprétation, qui demandera un peu de réflexion).
Supposons que la grammaire analysée possède la règle suivante :
N
→
a1 | a2 | ... | ak | N b1 | N b2 | ... | N bl
Le raisonnement de Greibach est le suivant :
189
Toute instance du flot d’entrée reconnue par cette grammaire doit commencer par
un symbole de la famille a (il peut être composite). Toutes les formes b doivent
être non-nulles, sinon la règle est pathologique (N → N). On affirme, que la grammaire suivante est équivalente :
N
M
→
→
a1 M | a2 M | ... | ak M
b1 M | b2 M | ... | bl M | ∅
Par exemple :
Expr → Term TermSeq
TermSeq → Addop Term TermSeq | ∅
ce qui devrait nous rappeler des formes déjà vues. De même manière on transforme Term → Factor | Term OpMul Factor en
190
Term → Factor FactSeq
FactSeq → OpMul Factor FactSeq | ∅
Le problème syntaxique a été résolu. Cependant un problème conceptuel se pose :
dans la version précédente nous avions une correspondance entre les non-terminaux
de la grammaire, et les noeuds de l’arborescence syntaxique résultante du parsing.
Ici on a introduit un symbole artificiel, les variables TermSeq et FactSeq, même
si intuitivement leur signification est claire, ne correspondent à aucun noeud.
On voit cela un peu mieux si on re rend compte que la forme Addop Term
TermSeq ... commence par un opérateur, alors c’est une expression incomplète !
Nous allons utiliser exactement le même astuce que dans notre modèle de parseur
basé sur les précédences. La variable TermSeq aura besoin d’être complétée, alors
le parseur termesq aura besoin d’un paramètre, de son contexte gauche. Voici le
parseur résultant. :
191
expr = term >>= termseq
termseq c = (addop >>= \o ->
term >>= \t -> termseq (Eop o c t))
# return c
term = factor >>= factseq
factseq c = (mulop >>= \o ->
factor >>= \t -> factseq (Eop o c t))
# return c
factor = atom # (lparen >> expr >>= \e -> rparen >> return e)
Il transforme l’expression "a+b0*(a1-b2*psi-c)*(b/(c+x+y)/f)" en
[(Eop Add (Dat "a") (Eop Mul (Eop Mul (Dat "b0") (Eop Sub
(Eop Sub (Dat "a1") (Eop Mul (Dat "b2") (Dat "psi")))
(Dat "c"))) (Eop Div (Eop Div (Dat "b") (Eop Add
(Eop Add (Dat "c") (Dat "x")) (Dat "y"))) (Dat "f"))),[])]
192
Il serait peut-être utile de visualiser graphiquement un parseur-séquence résultant
de la normalisation de Greibach. La partie encerclée du graphe ci-dessous est
obligée de couper un arc, qui représente le paramètre, le contexte gauche..
+
−
a
×
b
d
f
Nous avons terminé la description des parseurs combinatoires essentiels. On
sait comment parser les structures itératives et récursives à gauche, et comment
assembler le résultat. Nous avons exploité souvent le bind, mais il ne faut pas
hésiter à utiliser les blocs do :
193
termseq c = (do
o <- addop
t <- term
termseq (Eop o c t) ) # return c
Ils sont utiles aussi en dehors de la monade IO.
Parseurs positionnels
Supposons que le parseur lexical passe à sa continuation, le parseur phrasal pas
seulement les tokens, mais aussi – si telle est la volonté de l’utilisateur – la position : (ligne,colonne) de chaque token.
Tout parseur primitif comme item, ou lit qui consomme un caractère doit permettre au mécanisme de lecture d’incrémenter les compteurs correspondants. Cependant, nous ne voulons pas que cette information soit « pompée » inutilement
sur le flot de sortie. Nous voulons qu’elle soit accessible en cas de besoin.
194
Il faudra rédéfinir l’état de la machine qui consomme le flot d’entrée (la chaîne
de caractères). Par exemple, nous pourrons définir un item dédié aux chaînes de
caractères
data Cstate c = Cs Integer Integer [c]
newtype Parser c a = Pa (Cstate c -> [(a,Cstate c)])
itchar = Pa itm where
itm (Cs col row (x:xs)) = [(x,Cs c1 r1,xs)] where
(c1,r1) = if x==’\n’ then (0,row+1) else (col+1,row)
itm (Is _ _ []) = []
mais un problème d’universalité se pose : pour les parseurs qui agissent sur un flot
de lexèmes déjà construit, ou sur encore une autre structure, peut-être il n’est pas
souhaitable de garder ces informations. Les modifications toucheront le reste du
paquetage.
195
Nous ne pouvons discuter en détails toutes les modifications possibles. Quelques
détails (sans commentaires) se trouvent dans le fichier newparse.hs, stocké dans
le même endroit que ces notes. Voici le parseur qui récupère la position courante
(sans lire quoi que ce soit, alors on peut le lancer même après avoir épuisé le flot
d’entrée) :
pos = Pa ppos where
ppos ss@(Cs col row s) = [((col,row),ss)]
Nous pouvons précéder le parser word etc. par pos, et ensuite utiliser cette information à notre guise.
Nous reviendrons encore sur la problématique du parsing, en particulier dans le
contexte de gestion des attributs qui constituent l’information sémantique du flot
traité. Cependant, vu le flou autour du bind et autres concepts monadiques, il nous
semble utile de parler un peu de la sémantique calculatoire en général
196
Intermezzo monadique
Le « protocole » de la programmation fonctionnelle pure est très simple. Nous
avons des expressions composées des données (primitives ou pas), auxquelles on
applique des fonctions, en construisant d’autres expressions : x → f (x). La seule
opération magique est l’application fonctionnelle (indépendamment de la possibilité de réaliser un système calculatoire fonctionnel dans le cadre du calcul lambda
de Church. . . ).
Dans la pratique on a vu déjà d’autres entités/catégories plus complexes :
– Évaluation d’une expression peut échouer. Ceci peut se dérouler de manière
« douce », on retourne un objet qui constate l’échec, et on interdit aux autres
fonctions de s’y apopliquer. Ceci peut se dérouler de manière « forte », en
déclenchant une exception, la rupture du flot de contrôle existant. Ceci peut
être généralisé :
197
– Dans le cadre de programmation nondéterministe (Prolog ou parsing général), une expression peut résulter en plusieurs résultats possibles (ou aucun).
– Une expression peut être « incomplète » ; elle ne sera évaluée que si on prévoit un mécanisme de récupération de la valeur – la continuation. Une donnée
cesse d’être statique, mais devient un objet fonctionnel qui prend comme argument cette continuation.
– Le concept de continuation peut être beaucoup plus élaboré que cela. Scheme
et quelques autres langages permettent la construction des continuations de
première classe ; on peut, au milieu d’évaluation d’une expression demander la création d’un objet qui représente le « futur » de ce calcul. Cet objet peut être transmis ailleurs, et exécuté, ce qui provoque la restauration du
contexte de sa création. Le programme « redémarre » en complétant l’évaluation de l’expression d’origine. Ce mécanisme peut être utilisé pour créer
des co-procédures, et organiser un système de simulation des processus parallèles.
198
– On peut imaginer qu’une expression ou une donnée possède un « état interne » qui est modifié par le programme. Ceci est le trait caractéristique le
plus important de la programmation impérative, explicitement non-fonctionnelle.
– Une version très concrète de ce contexte est spécifiée par les entrées/sorties.
Quand on lit un fichier, quand on écrit quelque chose, on modifie « l’état
du monde ». Sur le plan strictement fonctionnel c’est comme si on créait un
nouveau monde avec les modifications, en « oubliant » le monde précedant.
Bien sûr, ceci n’est pas réalisable dans la pratique, il faut savoir modifier le
monde donné, qui n’existe qu’en un seul exemplaire.
– (On peut aussi vouloir avoir des variables internes modifiables, pour des raisons d’efficacité.)
– On peut vouloir ajouter à un programme fonctionnel des effets de bord, par
exemple tracer le programme en processus du débogage. Encore une fois, il
s’agit d’ajouter une couche impérative à un programme fonctionnel.
199
On peut continuer cette liste, mais on voit déjà que le monde de programmation
est beaucoup plus riche que la vision de la programmation fonctionnelle enseignée
en Licence. Pourtant, nous affirmons que la programmation fonctionnelle est
suffisamment riche pour spécifier et implémenter ces catégories de calculs,
et le mot-clé concerné est : la Monade, un concept qui appartient à la théorie des
catégories, et qui a été introduite – relativement récemment – dans la théorie de
programmation par Eugenio Moggi.
Les monades dans le monde de compilation sont vraiment irremplaçables, on les
voit partout !
Une Monade (au sens spécifique, exploité ici) est un constructeur de données qui
« emballe » une donnée en quelque chose qui peut être appelée le « calcul » (ang. :
computation). La Monade triviale correspond à la programmation fonctionnelle
pure. Une expression « emballée » ou « liftée » au domaine des monades reste
intacte. Les transformations des objets monadiques sont des simples fonctions.
200
Pour définir une monade m sur un type quelconque a il faut définir une fonction polymorphe return du type a -> m a qui effectue ce lifting. Le nom m est
icipurement symbolique, générique. On peut avoir la monade IO ou la monade
Parser ..., ou tout autre chose. Pour la monade triviale return ≡ id, et le
constructeur-lifteur est inexistant.
Deuxième ingrédient indispensable pour la définition d’une monade est l’opérateur bind : >>= qui sait comment appliquer une fonction à un objet monadique. Le
résultat de l’pplication doit également être un objet monadique. Pour la monade
triviale x >>= f ≡ f x. En général le type de cet opérateur est :
(>>=) :: Monad m =>
m a -> (a -> m b) -> m b
Troisième ingrédient qui n’a pas de nom propre unique, et qui parfois n’est pas
utilisé, est la fonction d’« aplâtissement » qui transforme un objet de type m(m a)
en m a, une sorte de concat généralisé.
201
On ajoute à cette panoplie un opérateur de séquencement (>>) qui peut combiner
deux objets monadiques en un seul, et finalement une fonction fail :: String
-> m a qui n’appartient aux monades mathématiques, mais qui peut être très utile
sur le plan pratique. Très souvent, quand un type de constructeur est monadique,
il est également l’instance de la classe Functor qui définit une application généralisée, la fonctionnelle fmap, similaire conceptuellement au map sur les listes.
On peut passer aux exemples non-triviaux. L’exemple classique d’une monade
« échec » et le type Maybe. Rappelons le, et ses propriétés monadiques :
data Maybe a = Nothing | Just a
deriving (Eq, Ord, Read, Show)
instance Functor Maybe where
fmap f Nothing = Nothing
fmap f (Just x) = Just (f x)
202
instance Monad Maybe where
Just x
>>= k = k x
Nothing >>= k = Nothing
return
= Just
fail s
= Nothing
En général dans la classe Monad (comme dans toute autre) on peut définir des
méthodes par défaut qui sont valables sauf si on les redéfinit dans une ou plusieurs
instances. Ainsi, nous pouvons préciser que
m1 >> m2 = m1 >>= \_ -> m2
La « suite » c’est un bind qui ignore le résultat passé. Mais parfois on peut optimiser cette définition.
203
Dans Maybe l’aplâtissement (la concaténation) est simplement Just (Just x)
→ Just x. Le bind du Nothing avec une fonction quelconque donne toujours
Nothing, les fonctions refusent de s’appliquer à « l’échec ».
Alors si dans un programme fonctionnel typique on remplace toutes les applications f x par (lift x) >>= (lift f), ou « lift » est un convertisseur
(générique, symbolique, il doit être concrétisé selon la sémantique voulue !) des
objets « normaux » en monadiques, on obtient un programme enrichi. La monade
Maybe permet de neutraliser les erreurs, en propageant l’échec. Avant de passer
aux autres monades, un rappel important :
Les monades ne vous donnent directement aucun outil pour résoudre vos
problèmes. Elles permettent de les formuler de manière universelle et souvent 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 monade 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 solutions d’un problème. En parcourant cette liste nous pouvons traiter une par une
ces solutions. La liste peut même être infinie (potentialement), si telle est la sémantique du problème. Nous savons déjà que les listes constituent un instance du
Functor, où fmap ≡ map. Voici l’instance de la classe Monad :
205
instance Monad [ ] where
(x:xs) >>= f = f x ++ (xs >>= f)
[]
>>= f = []
return x
= [x]
fail s
= []
Notez bien que bind peut être exprimé de manière plus générique. D’abord on
applique f à tous les éléments de la liste-source, mais ensuite, à l’instar de map,
au lieu de cons’er les résultats, on les concatène avec (++). Donc :
l >>= f = concat (map f l)
à titre d’exemple, transformons un problème nondéterministe classique, la génération de toutes les permutations d’une liste, en un programme monadique, de
Prolog vers Haskell. Le prédicat original en Prolog est présenté ci-dessous.
206
Commençons par un prédicat nondéterministe ndinsert qui peut insérer un objet
X dans une liste n’importe où.
ndinsert(X,L,[X|L]).
ndinsert(X,[Y|Q],[Y|R]):-ndinsert(X,Q,R)
permut([ ],[ ]).
permut([X|Q],R):-permut(Q,R1),ndinsert(X,R1,R).
(Si la liste est vide, ndinsert ne peut que mettre X à la tête ; la seconde clause
du ndinsert est inactive). Les permutations sont générées de manière intuitive.
On produit une permutation de la queue, et on insère la tête dans une position
quelconque.
207
La solution Haskell doit être soigneuse pour être optimale, mais nous proposons
une translation presque directe, moins efficace, mais plus lisible sur le plan monadique. 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 nondéterministe complet. Le résultat est très court.
ndinsert x [] = return [x]
ndinsert x l@(y:yq) =
let l1 = return (x:l)
l2 = ndinsert x yq >>= \p -> return (y:p)
in (l1 ++ l2)
permut [] = return []
permut (x:xs) =
permut xs >>= ndinsert x
208
La monade des continuations (CPS)
Le principe nous est connu. Au lieu d’avoir une donnée, nous traiterons un objet
fonctionnel dont le paramètre est le « futur » du calcul. Toute fonction monadique
appliquée par bind reconstruit une expression continuée. La construction explicite
et complètement générique n’est pas possible, car le « vrai » résultat final est
différé jusqu’à la sortie de la chaîne monadique. On peut lifter une simple donnée
par
lift0 x = \c -> c x
mais on n’a aucune chance de savoir quel est le type retourné par lift0. La
construction peut être donc la suivante (ce n’est pas la seule possibilité). D’abord
nous allons spécifier le type du résultat, p. ex type Ans = Double. Définissons
un type synonymique restreint qui représente un objet continué :
209
type Ans = Double
newtype Cnt a = Cnt ((a->Ans) -> Ans)
Ensuite introduirons les fonctions du lifting, p. ex. lift0 qui monadise les valeurs, et lift1 qui monadise les fonctions à un paramètre.
lift0 x = Cnt (\c -> c x)
lift1 f x = Cnt (\c -> c (f x))
(Bien sûr, lift0 = lift1 id). On peut définir quelques fonctions et valeurs
liftées
cq = lift0 5.0
csqrt = lift1 sqrt
ccos = lift1 cos
210
L’instance monadique de notre type se réduit à
app f a = g where Cnt g = f a
instance Monad Cnt
where
return = lift0
Cnt m >>= f =
Cnt (\c -> m (\a -> app f a c))
La balise Cnt et la fonction app sont strictement accessoires et redondantes,
présentes uniquement pour satisfaire le typage. À présent la composition fonctionnelle cos(sqrt(5.0)) et le résultat numérique final seront exprimés par
xpr = cq >>= csqrt >>= ccos
res = r id where Cnt r = xpr
211
Pour les opérateurs binaires et les fonctions (opérateurs) binaires, les constructions
sont un peu plus compliquées, mais seulement techniquement.
Si on change le type du résultat final Ans, il faudra re-coder la totalité. Haskell
ne permettra pas de définir les types ci-dessus en remplaçant Ans par un type
générique b.
La monade des états
Cette monade est assez complexe. À chaque valeur dans le programme nous associons 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 nouvel objet monadique : une fonction d’état initial. L’objet m agit sur cet état initial,
et produit une valeur concrète appariée avec un état intermédiaire. La fonction
récupère la valeur et produit un objet monadique censé de s’appliquer à l’état
intermédiaire et produire l’état final.
Voici la définition essentielle de la monade de transformateurs d’états :
newtype Stc st a = Stc (st -> (a,st))
instance Monad (Stc st) where
return x = Stc (\s -> (x,s))
Stc m >>= f = Stc (\ini -> let (x,mid) = m ini
Stc k = f x
in
k mid)
213
Souvent, pour la documentation et l’enseignement, on laisse tomber la balise artificielle qui caractérise le newtype. Alors le type monadique est tout simplement
une fonction \s -> (x,s). Le bind est très simplifié :
(m >>= f) ini = case m ini of (x,mid) -> f x mid
Exemple? Mais on la connaît, cette monade, un parseur en est un exemple. Les
parseurs combinent le non-déterminisme et l’état. Si on déclare que tout parseur
rend un et un seul résultat, les définitions monadiques subissent les simplifications
suivantes :
newtype Parser state a = Pa (state -> (a,state))
Pa pfun -*> inpt = pfun inpt
-- pas de failure !
succeed x = Pa (\s -> (x,s))
214
instance Monad (Parser c) where
return = succeed
(Pa prs) >>= fun = Pa
(\inp -> fun v -*> inp1 where (v,inp1) <- prs inp)
où nous avons à peine éliminé quelques crochets et concat.
La monade du tracing
Le modèle est un peu différent du tracing ajouté à notre machine virtuelle (le
modèle précédent permet aussi une formulation monadique, mais c’est un bon
sujet pour votre travail individuel). Ici la paresse du langage Haskell peut jouer
un rôle important. Le lifting monadique d’une valeur x sera une paire (x,s), où s
est une chaîne qui est crée quand x est formé. Cette chaîne contient un message
informatif.
215
Le return peut être très simple, p. ex. return x = (x,""), ou, si on le veut :
return x = (x,show x).
Rappelons encore une fois : les monades ne nous disent rien concernant l’implantation concrète des instances concernées. Elles permettent de structurer le
programme. Cependant, c’est à l’utilisateur de définir return, bind, etc., cas par
cas, selon la sémantique voulue.
Donc, nous pouvons définir un bind générique :
(x,s) >>= f = let (y,s’) = f x
in (y,s++s’)
qui ajoute un nouveau texte au précédant quand la fonction f est appliquée. Cependant, 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 particulier, il est facile de passer l’information en deux sens, de la racine vers les
feuilles, à travers des paramètres des parseurs, et des feuilles vers la racine, en
construisant le résultat. Nous avons déjà vu les deux catégories d’attributs.
En général, le résultat constitue l’attribut sémantique principal d’un symbole
syntaxique. La génération du code est la composition des attributs sémantiques.
Le résultat est un attribut synthétisé, l’information qui passe de droite vers la
gauche, si le parseur correspondant à une production genre
N
→
A1 A2 ... An
est appliquée.
217
Pour les parseurs descendants cette information est assemblée lors du retour de la
procédure du parsing. Les parseurs ascendants (discutés brièvement un peu plus
tard) combinent les informations de manière itérative, et non pas récursive, mais
le résultat est le même.
Voici quelques mots sur l’analyse syntaxique augmentée par l’analyse sémantique : par le jeu des attributs. Rappelons encore une fois une grammaire qui
construit des entiers. Il nous faudra ajouter des indices identifiant les non-terminaux
sémantiquement différents, appartenant à la même catégorie syntaxique.
N0 → Da
N0 → DbN1
Notez que notre grammaire est récursive à droite, pour varier. . . L’attribut qui nous
intéresse est la valeur de N0, disons N0.v . (On peut penser que sémantiquement
chaque non-terminal correspond à un record avec plusieurs champs, qui stockent
les attributs).
218
Il est évident que la valeur finale dépend des valeurs à droite des productions, mais
ces valeurs sont liées entre elles ; par exemple la « valeur concrète » de Da dépend
de sa position dans la chaîne (unités, dizaines, centaines . . . ).
Introduisons donc l’attribut p comme position, ainsi que l’attribut longueur l,
puisque la position d’un chiffre dépend de la longueur de son voisin à droite.
Ajoutons un attribut fondamental de chaque chiffre : son code, sa valeur absolue
D.c (d’habitude son code ASCII - 48). Bien sûr, la longueur d’un chiffre est 1. Il
est facile de constater que les équations suivantes ont lieu :
1. Pour la première alternative :
N0.l = 1
N0.v = Da.c
219
2. Pour la seconde :
Db.p
Db.v
N0.l
N0.v
=
=
=
=
N1.l
Db.c · 10Db.p
1 + N1.l
Db.v + N1.v
et en principe il est facile de construire le parseur correspondant, qui retourne une
valeur composite (n,l) pour un entier. Pour un chiffre il est facile de retourner le
code, mais attention : le parseur chiffre défini ci-dessous ne pourra en aucun cas
retourner la valeur positionnelle (relative) d’un chifre, car ceci est une information
contextuelle. Supposons que le résultat est un nombre entre 0 et 9. Voici le parseur
déduit de notre raisonnement :
entier = chiffre >>= \d ->
(entier >>= \(n1,l1) -> return(n1+d*10^l1,l1+1))
# return(d,1)
220
Et pour la grammaire récursive à gauche? Analysons les attributs avant de normaliser la forme
N0 → Da
N0 → N1Db
Il est clair que la seconde règle produit : N0.v = 10 · N1.v + Db.c. La longueur
de la chaîne n’est pas nécessaire. Le seul problème est l’impossibilité d’implémenter cette grammaire telle quelle par la stratégie descendante. La grammaire
normalisée selon Greibach est
N0 → Da S
Se → ∅
S0 → Db S1
et, attention à présent ! Nous n’allons pas combiner la valeur de Da avec la valeur
de S pour obtenir N0.v , mais nous écrivons brutalement N0.v = S.v . Qu’estce passe-t-il avec le chiffre? Il n’est pas oublié, mais il fournit un attribut hérité,
contextuel, à S . Appelons-le le contexte gauche, S.g . La règle donnant N0 précise
donc que S.g = Da.c.
221
Passons aux productions définissant S . La première résulte en Se.v = Se.g .
Ceci peut être construit automatiquement par un générateur capable d’effectuer la
normalisation. La seconde production donne S1.g = 10 · S0.g + Db.c. L’occurrence de S à droite récupère l’information de gauche. C’est ça, le mystère des
attributs hérités. La production récursive ne retourne jamais rien, elle boucle, en
construisant des nouvelles instances des attributs hérités, jusqu’à l’échec de la
clause récursive (plus de chiffres). Le parseur correspondant a déjà été construit.
Quels sons d’autres attributs intéressants dans notre modèle du compilateur?
– Être une constante (explicite ou symbolique). Un nombre explicit, p. ex.
3.14159 peut être retourné par le parseur comme un record qui contient sa
valeur, mais également son statut, disons, CONST. En générant le code résultant de l’application d’un opérateur à deux arguments, le parseur/générateur
peut vérifier ces attributs. Si les deux arguments sont constants, au lieu de
générer le code le compilateur exécute l’opération (si applicable), et stocke
le résultat. Ceci est une des techniques fondamentales d’optimisation.
222
– « Adresse » d’une variable. Quand le parseur trouve un identificateur, il doit
immédiatemment vérifier dans la table des symboles (l’environnement) si
cette variable possède déjà quelques attributs, notamment l’indice qui permettra localiser sa valeur. Sinon, il faut l’insérer et retourner un nouvel environnement, enrichi par cette variable.
– Type d’une variable/constante. Les types peuvent se propager. L’expression
x+1 (dans un langage monomorphe) déclenche une procédure d’unification,
ou de solution de contraintes, permettant de constater que « + » possède
un argument entier, et donc c’est une opération entière. Donc x doit être
également entier !
Ceci est, bien sûr, loin de la vérité pratique.
1. Dans des langages comme C les expressions mixtes sont parfaitement
légitimes. Mais x doit être déclaré, le compilateur connaît son type. S’il
n’est pas entier, il doit appartenir à un sur-ensemble des entiers (flottant,
complexe, etc.), ce qui permet la conversion appropriée de nombre 1.
D’autres cas sont aussi possibles, p. ex. x + 1.0 force la conversion du
x si celui-ci est entier. Tout est basé sur l’information passée sous forme
d’attributs entre les éléments du parseur.
223
2. En Haskell la situation est un peu différente, car il n’y a pas de déclarations. . . . Comment compiler : f x = x+1? La réponse est délicate. La
fonction f est polymorphe, équivalente à f x = x + fromInteger 1.
L’opérateur (+) ne peut être compilé directement, puisque le compilateur ne sait pas quelle variante sera effective lors de l’appel de la fonction
f (tout dépend du type de l’argument actuel). Il existe plusieurs modèles
de compilation polymorphe, le plus simple (pas forcément le plus efficace) exploite des arguments « cachés » : des dictionnaires de « fonctions
virtuelles ».
Si on applique f y où y est connu, p. ex. complexe, alors la fonction
f reçoit un argument caché – un dictionnaire attachée à la classe Num ;
les classes servent justement à déclarer des dictionnaires, et les instances
remplissent ces dictionnaires avec les associations concrètes entre les
« messages », p. ex l’opérateur (+), et les « méthodes » – p. ex. la procédure d’addition.
224
– Finalement le dernier exemple : la génération du code séquentiel. Supposons
que le programme parsé est une séquence d’instructions P → I1 I2 . . . In.
Toute instruction I possède son attribut principal, sa « valeur », le code généré. Mais également un autre attribut très important pour l’organisation globale : le successeur.
Dans les manuels de compilation « classiques » on parlait d’étiquettes, adresses,
etc. Mais nous pouvons garder une vision plus abstraite. Le successeur c’est
le code qui sera deployé après P . Est-ce un attribut synthétisé, ou hérité?
Hérité, bien sûr. On commence par le parseur identifié avec le « programme
principal » (supposons que c’est notre P ) qui n’a pas de successeur. On le
sait a priori. Pas besoin de lancer le parseur pour savoir que P.s =STOP, ou
quelque chose comme ça. Mais à présent nous pouvons effectivement compiler le reste, sachant que I1.s = I2; . . . In.s = P.s. (Exemples concrets
avec while etc. ont été discutés lors de la description des machines virtuelles). 225
226
227