Cours n° 2-9, 6 mai 2010
Génération de parseurs par Lex & YACC
- Introduction
- But
poursuivi
- Les
outils en question
- Analyse
lexicale
- Différence
entre conventions lexicales et conventions
syntaxiques
- Distinction
- Nature
de la différence
- Lexing
vs. Parsing
- Cahier
des charges de l'analyse lexicale
- Catégories
lexicales
- Lexèmes
"singuliers"
- Lexèmes
"génériques"
- Les
séparateurs
- Travail
du lexeur
- En
principe
- Avec
LEX plus précisément
- Typage
de la variable lexval
- Réalisation
du lexeur en Flex++
- Le
lexeur est construit comme instance d'une classe
ExpLexer.
- Comment
est organisé le fichier "explexer.ll"
- Exemple
: spécification d'un lexeur usuel pour
expressions arithmétiques
- Analyse
syntaxique
- Grammaires
CF en format YACC
- Format
des règles
- Déclaration
des noms de lexèmes
- Règle
de lancement
- Actions
sémantiques
- Exemple
rudimentaire
- Informations
dans la pile
- Accès
à la pile
- Type
des éléments dans la pile
- Exemple
un peu moins rudimentaire
- Actions
d'empilement
- Exemple
de la profondeur de l'arbre de dérivation
- Construction
de l'arbre de syntaxe abstraite
- Problème
de la livraison de l'arbre construit :
recours à
la classe ExpParser
- Fichiers
expparser.h et calc.C
- Le
fichier de spécification du parseur expparser.yy
au complet
- Réalisation
conjointe du lexeur et du parseur en Flex++ et
bisonpp
- Mise
en œuvre
- Principe
- Pratique
- Un
premier exemple
- Un
second exemple
-
Ce cours présente deux outils logiciels qui permettent d'engendrer un
parseur ascendant à partir d'une grammaire CF.
Pour comprendre le détail des préoccupations qui guident l'emploi de
ces outils, il faut se placer dans la perspective d'une application
informatique qui les met en œuvre.
Ce point est développé plus avant au début du cours 2-10.
-
YACC
= Yet Another Compiler Compiler [S.C. Johnson, Bell
Labs, 1975]
est en fait un générateur de parseurs (et pas un compilateur
de compilateurs !).
Sa version d'origine engendrait des parseurs en C.
Il en existe des adaptations pour la plupart des langages de
programmation, par exemple en
Perl.
Nous utiliserons ici sa variante Bison,
adaptée à C++ par Dirk Vermeir de la VUB (Vrije
Universiteit Brussel),
sous le nom de bisonpp, et configurée pour les
systèmes Linux en usage à Nogent par Marie-Anne Moreaux.
Comme nous allons le voir, le parseur engendré par YACC travaille non
pas sur une chaîne de caractères mais sur une séquence de lexèmes,
produite à partir du texte original par un analyseur lexical
qui était à l'origine engendré par Lex (programme que nous avons déjà
rencontré).
Notre dispositif expérimental fera appel à sa variante Flex, aujourd'hui plus répandue,
qui est aussi capable d'engendrer du
C++.
Pour une information complète sur ces outils, voir The
Lex & Yacc Page.
La distinction entre analyse lexicale et analyse syntaxique est
spécifique aux langages des programmation.
Elle recoupe très approximativement la distinction entre morphologie et
syntaxe en grammaire traditionnelle.
-
-
Il s'agit de décrire le système des conventions qui régissent la
syntace concrète d'un langage artificiel.
On met en évidence deux niveaux de conventions :
- Conventions lexicales
(structure des mots) :
- la collection des symboles utilisés (p. ex.
opérateurs) et des mots-clés
(les mots-clés sont des ersatz de symboles)
- forme des constantes (nombres, chaînes) et des
identificateurs (noms de variables, de fonctions, etc)
- statut du blanc, de la tabulation et du saut de
ligne (voyez la syntaxe de Python)
- forme des commentaires
Ces conventions régissent le détail de la chaîne de caractères, y
compris les blancs et les sauts de ligne.
- Conventions syntaxiques
(structure des phrases) :
- structure des expressions (différentes sortes
d'expressions : arithmétiques, booléennes, plus ou moins
parenthésées)
- structure des instructions (catalogue des types
d'instructions, statut du
point-virgule, begin-end, etc)
- procédures, fonctions, déclarations de types,
etc.
Ces conventions sont formulées à un niveau plus abstrait, indépendant
de la nature matérielle des caractères qui composent le texte,
en ne faisant appel qu'au rôle qu'ils jouent.
Un peu comme dans l'écriture courante on emploie des abréviations qui
jouent
le même rôle syntaxique que les formes développées.
L'exemple-type est celui des variables et des constantes dans les
expressions arithmétiques :
- deux noms de variables différents jouent le
même rôle syntaxique, ils sont interchangeables du point de vue de la
structure syntaxique
- de même pour deux constantes entières
différentes :
(x+y)*2
a la même structure
que (y+z)*3
- un nom de variable et une constante jouent
aussi le même rôle du point de vue de la syntaxe, mais pas du point de
vue de la sémantique,
on est donc obligé de les différencier aux deux niveaux lexical et
syntaxique : cette différence est la seule chose qui compte,
et il est légitime de ne pas encombrer la convention syntaxique avec la
description de cette différence, qui relève du niveau lexical.
Dans le même contexte, la question devient
délicate pour les opérateurs :
- en notation complètement parenthésée, les
opérateurs binaires jouent tous le même rôle syntaxique,
on peut donc en faire une seule classe lexicale
- ce n'est plus vrai en notation à
précédences !
Cet exemple illustre le fait que la
distinction entre les deux niveaux n'est pas absolue, et qu'on ne peut
l'appréhender qu'en fonction d'un projet d'ensemble
(pour nous, dans le présent exemple, la formalisation des expressions
arithmétiques).
Cette distinction prend tout son intérêt si on observe que les deux
ordres de conventions n'ont pas les mêmes propriétés
combinatoires.
Les algorithmes correspondants sont de complexité différente...
-
La notion-clef est celle de structure auto-enchâssée (en
anglais self-embedded)
dont le type est fourni par les systèmes de parenthèses :
(()) ()
==> ( (()) () )
==> (( (())
() ))
==>...
et qui se retrouve - au moins en principe - dans les langues naturelles
:
- Le chou que la chèvre mange est délicieux.
- Le chou que la chèvre que le loup guette
mange est délicieux.
- Le chou que la chèvre que le loup que M.
Seguin craignait guette mange est délicieux.
- Le chou que la chèvre que le loup que M.
Seguin que le loup redoutait craignait guette mange...
- Les structures syntaxiques sont auto-enchâssées :
if t0 then
if t1 then
if t2 then av2 else
af2
else af1
else af0
Leur description nécessite des grammaires CF et leur analyse, des
automates à pile
[lien entre ces deux notions : cours n° 2-4].
- Les structures lexicales ne le sont pas :
p. ex. les commentaires sont "à un seul niveau"
/* Ceci /* n'est pas */ un commentaire ! */
On peut les décrire par des expressions régulières et les analyser avec
des automates finis.
-
La différence en matière d'algorithmes se répercute au niveau des
logiciels qui les mettent en œuvre.
On distingue donc le lexeur et le parseur, chacun faisant sa part du
travail.
[ L'idée est de ne pas mélanger les formalismes, et aussi de préserver
l'autonomie du lexeur :
LEX est conçu pour être utilisable sans YACC, en vue d'applications qui
ne requièrent pas une analyse syntaxique. ]
- L'analyse lexicale (effectuée
par le lexeur)
consiste en la reconnaissance de structures lexicales simples
(non-enchâssées)
chacune d'entre elles est identifiée par un objet abstrait appelé lexème
(en anglais token, terme générique désignant un
symbole quelquonque).
À la suite de ce qui a été dit plus haut, les lexèmes sont les porteurs
de l'information précise qui est nécessaire à l'analyse syntaxique (ni
plus, ni moins).
Sur leur nature exacte, voir plus loin.
À partir d'un chaîne de caractères lue de gauche à droite
l'analyse lexicale produit
un flot de lexèmes,
sans changement de dimension : une seule dimension dans les deux cas.
- L'analyse syntaxique
"proprement dite" (par le parseur)
reconnaît des structures de phrases complexes
(auto-enchâssées).
À partir du flot de lexèmes produit par le lexeur elle
construit
l'arbre syntaxique
avec changement de dimension : le flot de lexèmes est unidimensionnel,
l'arbre construit est une structure à deux dimensions
obtenue par décodage.
- Dans nos expériences précédentes, les lexèmes
étaient identifiés aux caractères, l'analyse
lexicale était réduite à la lecture caractère par caractère.
On a entrevu l'utilité d'une phase lexicale pour rendre compte du rôle
commun joué par les caractères x, y, z
(noms
de variables)
et par 1, 2, 3
(noms de nombres, alias
constantes numériques).
-
Répétons qu'il s'agit de langages artificiels, typiquement des langages
de programmation.
L'inventaire des lexèmes de chaque langage est une étape préliminaire à
l'écriture de sa grammaire.
La spécification du lexeur contient cet inventaire sur un mode
opérationnel, c'est-à-dire accompagné
- de la description de la forme de
chaque lexème (donnée par une expression régulière)
- du traitement qui lui est appliqué, en fonction du rôle
qu'il
joue.
Pour pouvoir donner des exemples il nous faut entrer dans l'analyse de
ces rôles.
-
Du point de vue lexical, on distingue trois catégories seulement :
- Lexèmes "singuliers" : désignés par leur nom
seul
comme le dit Agamemnon dans La belle Hélène
: ce nom seul me dispense d'en dire plus long...
- symboles (opérateurs comme '
+
'
et '*
', marqueurs syntaxiques comme les
parenthèses, les accolades, le point-virgule)
- mots-clés (mêmes fonctions : '
if
',
'else
', 'while
', etc)
L'essentiel dans cette affaire est que la connaissance du nom du lexème
suffit pour la suite du calcul.
- Lexèmes "génériques" : désignés par un nom et une
valeur
le nom identifie le lexème, il caractérise son rôle
syntaxique, la valeur est une information
supplémentaire utile pour la suite du calcul.
- identificateurs : le nom est
Ident
(par exemple), la valeur
est la chaîne de caractères
- constantes : le nom donne
le type de la constante (entier, chaîne, booléen), la valeur
est ... la valeur comme entier, chaîne, booléen
- autres : des opérateurs regroupés en une même
classe, avec un système de valeurs ad hoc
(un bon exemple est celui des symboles de relation : '=
',
'<
',
'>
', '>=
'
etc..).
L'exemple typique est celui des nombres (alias constantes numériques) :
peu importe qu'un nombre soit écrit en décimal ou en hexadécimal,
ce qui compte c'est le nombre, donc la valeur associée à la
chaîne décimale ou
hexadécimale.
- Séparateurs
- blancs,
tabulations, sauts de ligne
- commentaires
Ce ne sont pas des lexèmes, en ce
sens qu'ils n'apparaissent pas dans le flot envoyé au parseur,
mais ils jouent un rôle essentiel de délimitation, qu'il faut décrire :
en général, un blanc ne peut pas apparaître à l'intérieur d'un
identificateur, ni un saut de ligne à l'intérieur d'une constante de
chaîne de caractères.
-
Revenons sur la distinction entre symboles et mots-clés.
- Les symboles sont écrits avec
des caractères
particuliers, autres que des lettres et des chiffres.
Grosso modo, ils sont conformes à
l'usage traditionnel en mathématiques et à la ponctuation dans les
langues occidentales.
Ce sont les opérateurs arithmétiques habituels ( '+
',
'*
' '-
' et '/
'),
les symboles de relation ('=
', '<
',
'>
', '>=
'
etc..),
les parenthèses, accolades, crochets, points, virgules et
points-virgules.
Ils peuvent être d'un seul caractère (p.ex. les parenthèses) ou de
plusieurs (en général 2) p. ex. l'inégalité "<>
"ou "!=
".
- Les mots-clés ont été introduits
pour remédier au
manque de caractères spéciaux sur les claviers usuels.
La syntaxe des langages de programmation est en effet grande
consommatrice de symboles, et il a fallu trouver des substituts
(surtout au commencement, alors que la référence était le clavier des
machines à écrire américaines mécaniques !).
Ils jouent exactement le même rôle que des
symboles.
D'un point de vue linguistique, ils s'apparentent aux "mots vides" ou
"mots-outils" des langues naturelles,
ce qui explique leur succès : ils sont commodes à utiliser
(il est plus facile de se rappeler l'emploi du mot 'while
'
que de celui d'un symbole bizarre '$%
' qui
pourrait pourtant jouer exactement le même rôle).
Exemples :
- en Pascal, les parenthèses d'instructions '
begin
'
et 'end
' qui s'écrivent en C '{
'
et '}
'
(en Pascal les accolades servaient à délimiter les commentaires)
- '
if
'
marqueur syntaxique de la conditionnelle
- '
mod
' (en Pascal et en
Perl) opérateur arithmétique (noté '%
' en C)
Leur répertoire est potentiellement infini : c'était bien le but
recherché !
Évidemment, cette commodité a un prix : un mot-clé a exactement la même
apparence qu'un identificateur !
Il faut donc mettre en place un mécanisme permettant d'interdire
l'emploi des mots-clés comme identificateurs,
et de les confiner à leur rôle de marqueurs ou d'opérateurs : c'est là
une des fonctions primordiales de l'analyse lexicale.
-
Bornons-nous aux identificateurs et aux constantes.
Ces éléments servent à nommer les objets du discours (référents) : d'un
point de vue linguistique, ce sont des noms.
[On a autrefois cherché à acclimater la distinction entre noms et
verbes dans la description des langages de programmation (cf. la
description de Cobol)
mais elle n'apporte aucune lumière sur les phénomènes du calcul : en
programmation, rien ne distingue au niveau lexical un verbe d'un nom
d'action.
On a donc abandonné cette tentative.]
- Les identificateurs : ce sont
des noms dont les référents sont arbitraires
(donnés dans le texte du programme, au moyen d'une déclaration ad
hoc,
ou par convention : cas des procédures prédéfinies comme 'print
'
- auquel cas la différence avec les mots-clés devient subtile : affaire
de syntaxe).
En principe (voyez C++), ce sont des chaînes de caractères ASCII ne
contenant que des
chiffres et des lettres, commençant par une lettre.
On y ajoute à présent le soulignement '_
'.
L'emploi de caractères non-ASCII est en général exclu.
Un langage comme Perl introduit une complication supplémentaire avec
ses préfixes '$
', '@
'
et '%
' :
il faut les voir comme des déclarations de types, ne faisant pas partie
du nom :
mutatis mutandis, "my @tab;
"
en Perl est homologue de "int* tab;
" en C++ et
de "int[] tab
" en Java,
qui déclarent l'identificateur "tab
" comme nom
d'une variable de type tableau,
à telle enseigne que l'indexation fera changer de préfixe : on écrira
en Perl $tab[0] = 1;...
De même en PHP avec le préfixe '$
' qui dénonce
un identificateur comme nom de variable.
- Les constantes (nombres,
caractères, chaînes) : ce sont des noms dont les référents sont fixés
par le nom lui-même.
p.ex. les entiers en décimal (ou en hexa. : '0x...
',
ou en octal : '0...
').
La notation des chaînes de caractères varie suivant les langages :
voyez la différence entre Perl, PHP et shell Unix d'une part (qui
distinguent les chaînes entre simple quotes et
entre double quotes)
C++ et Java d'autre part (qui utilisent les simple quotes pour
les caractères et réservent les double quotes pour
les chaînes).
-
- Blanc
Comme le montrent de nombreuses langues naturelles, le statut du blanc
comme séparateur ne va pas de soi.
En programmation, il varie suivant les contextes :
- pour un système d'exploitation muni d'une
interface graphique comme Windows ou MacOS, il est normal d'avoir des
blancs dans des noms de fichier - mais ce n'est pas vrai pour un
système orienté vers la ligne de commande comme Unix !
- en Algol-60 il était prévu d'avoir des blancs
dans les identificateurs, mais cette idée s'est avérée peu utile, elle
a été abandonnée au profit du blanc comme séparateur de base.
- Saut de ligne
(aussi bien CR = ASCII 13 que NL = ASCI 10)
Deux positions s'affrontent : soit on l'assimile au blanc (séparateur),
soit on lui confie un rôle de marqueur syntaxique.
La première position est majoritaire.
La seconde garde des partisans (voyez Python), on en trouve des traces
dans certaines licences en JavaScript (et peut-être en Perl).
- Tabulation
En général assimilée au blanc (même en Python).
À noter que certaines syntaxes archaïques (comme celle des makefiles)
lui donnaient un rôle spécifique.
- Commentaires
Du point de vue lexical, un commentaire n'est pas autre chose qu'un
séparateur.
Comme on sait, ils peuvent prendre des formes très variables...
- Parenthésés (mais non-emboîtés) entre "
/*
"
et "*/
" (C et al.), ou entre accolades (Pascal)
Signalons qu'en Caml les commentaires sont
parenthésés par "(*
" et "*)
"
et que nested comments are correctly handled
ce qui signifie que l'analyseur lexical de ce langage n'est pas un
automate fini !
- Commentaires en fin de ligne annoncés par "//"
(en C++, Java, PHP) ou par "--" (en Ada, Eiffel), ou '
#
'
(en shell Unix et en Perl).
-
Rappelons que le lexeur dispose du catalogue des lexèmes, à raison
d'une expression régulière par classe de lexèmes,
décrivant la forme de chacun des membres de cette classe (les
séparateurs étant assimilés à une classe de lexèmes).
-
- Le lexeur filtre la chaîne de
caractères
en entrée.
- Lorsqu'il a reconnu la chaîne représentative
d'un lexème (conforme à l'une des expressions régulières de son
catalogue),
il envoie au parseur le
lexème en question, à savoir
- son nom
(p.ex."
=
" --> Affect
)
- et sa valeur si c'est
un
lexème
générique (p.ex. "
0
"
--> le nombre 0)
- Lorsqu'il a reconnu un séparateur, il ne
renvoie rien.
- Il filtre toujours la chaîne la plus longue
qui
puisse représenter un
lexème de la classe visée :
p.ex. dans "1230+458;
" il filtre "1230
"
et non "1
", ni "12
"
etc.
Un séparateur, ou des caractères comme "+" ou ";" provoquent l'arrêt du
filtrage
du nombre et le passage au lexème suivant.
-
Chaque fois qu'il reconnaît la chaîne représentative d'un lexème, il
exécute une action, qui est donnée comme une instruction en
C++.
L'instruction en question est associée à
l'expression régulière
dans le catalogue des lexèmes.
L'ensemble des instructions en question compose une fonction nommée yylex()
.
Le parseur reçoit comme information la valeur de cette fonction, dont
le type de retour est int
:
les noms de lexèmes sont en effet codés comme des entiers.
- Pour un lexème singulier, c'est facile : l'action
s'écrit "
return(<le nom>);
"
( return
au sens de la fonction yylex()
).
Exemple illustrant deux manières de nommer les lexèmes
(ces noms sont automatiquement codés comme des entiers par le logiciel)
:
"+"
return
PLUS;
"*"
return
TIMES;
"("
return '(';
")"
return ')';
- Pour un lexème générique, c'est plus compliqué : il
y a 2 informations à
transmettre simultanément, le nom et la valeur.
Le nom étant transmis comme valeur de la fonction yylex()
par "return(<le
nom>);
",
la valeur devra être communiquée par un
autre canal.
- Elle est d'abord calculée à partir de la chaîne
représentative,
qui se trouve
dans une variable standard nommée yytext
, de
type "const char*
".
Ce calcul peut être complexe. Il se borne souvent à une conversion de type comme "atoi
" (character to integer).
- Elle est ensuite placée dans une
variable-tampon (buffer),
nommée
yylval
dans certaines implémentations
et lexval
dans celle que nous utilisons ici.
Attention ! Comme l'instruction "return(<le
nom>);
" met fin à la séquence de calcul,
l'instruction de logement (affectation à lexval
)
doit s'effectuer avant le return
.
Exemple :
[a-z]+
{
lexval.nom = yytext; return IDENT; } // sans conversion : la chaîne elle-même
[0-9]+ {
lexval.val = atoi(yytext); return NUMBER; } // conversion en entier
Voir ci-après la signification de lexval.nom
et
de lexval.val
.
- Pour un séparateur, il n'y a rien à faire : action
(instruction) vide.
Exemples : blanc & tabulation :
[ \t]
;
Commentaires en fin de ligne
"//"[^\n]*"\n" ;
-
Cette variable doit pouvoir contenir des informations de types
différents au sens de C++ (caractères, chaînes, nombres etc).
Pour avoir cette facilité sans renoncer au contrôle de types, on est
amené à lui donner un type union
(hérité de
C).
Un tel type "composé" est composé de différents champs de types
"élémentaires", dans notre exemple nom
de type "const char*
"
et val
de type int
.
Et les affectations à la variable elle-même doivent se faire en
invoquant le champ correspondant au type de la valeur affectée,
donc lexval.nom
pour
une chaîne et lexval.val
pour un entier.
Nous aurons à revenir plus loin sur ce type union
...
-
La spécification du lexeur est contenue
dans un fichier portant
l'extension "
.ll
". Par exemple "explexer.ll
".
Pour rédiger ce fichier il est utile de savoir que
-
Le fichier d'interface correspondant "
explexer.h
"
est supposé donné - il sera mis à jour automatiquement.
Le fichier d'implémentation "explexer.C
" en
revanche est engendré par Flex++ à partir de la spécification "explexer.ll
".
N.B. Le système bisonpp produit des fichiers d'implémentation
qui
portent
l'extension ".C
" au lieu de ".cpp
".
Nous nous conformons donc à cette pratique.
-
3 parties:
- Première partie : entre les marqueurs "
%{
"
et "%}
"
du code C++ définissant le matériel dont
on a besoin pour calculer les valeurs de lexèmes.
Dans notre cas, ce sont des ordres d'inclusion.
- Indications d'options : nous en employons deux
%option
noyywrap
%option yyclass="ExpLexer"
La première signifie qu'on ne veut pas lire plusieurs fichiers à la
suite
! Elle est indispensable...
La seconde fait référence à la classe dont le lexeur
engendré sera instance, comme expliqué ci-dessus.
- Troisième partie : entre les marqueurs "
%%
"
et "%%
"
le catalogue des lexèmes, donné par une batterie de règles de la forme
expression régulière
action
(sur la même ligne)
-
%{
#undef yyFlexLexer
// détail technique
#include "explexer.h"
#include "expparser.h"
%}
%option noyywrap
%option yyclass="ExpLexer"
%%
[ \t] ;
[a-z]+ {
lexval.nom = yytext; return Id;} // la chaîne elle-même
[0-9]+ {
lexval.val = atoi(yytext); return Cte; } //
chaîne
--> entier
[+*]
{
lexval.nom = yytext[0]; return Op;} //
chaîne
--> carctère
"("
return '(';
")"
return ')';
%%
-
-
YACC et ses successurs utilisent un format commode pour écrire les
grammaires :
- Les règles qui ont le même symbole non-terminal en
partie
gauche sont regroupées en une seule unité ;
- Le non-terminal en partie gauche est suivi de ":" ;
- Les parties droites des différentes règles
regroupées
sont
séparées par la barre de disjonction "|" ;
- L'unité se termine par un ";".
On écrit traditionnellement les barres de disjontion alignées
verticalement, le point-virgule final idem.
EPA : Id
| Cte
| '('EPA Op EPA ')'
;
-
Les noms de lexèmes qui ne sont pas des caractères désignés comme tels
doivent être déclarés auparavant, sous la forme
%token Id Cte Op
-
On ajoute une première règle dont le rôle est de lancer l'analyse, donc
d'individualiser la dernière réduction.
Le non-terminal de cette règle ne doit pas réapparaître ailleurs.
Ici par exemple on ajoutera la règle :
prog : EPA;
-
En outre, à chaque règle on associe une instruction en C++ qui
sera
exécutée chaque fois qu'une réduction aura lieu
au cours de l'analyse ascendante (au sens du processus shift/reduce
vu au cours 2-8).
Cette instruction représente l'action associée à la
réduction.
Elle s'écrit (entre accolades) entre la partie droite de la règle et le
séparateur "|
" ou ";
".
-
%token Id Cte Op
%%
prog :
EPA {cout << endl;}
;
EPA : Id {cout << "Id";}
| Cte {cout
<< "Cte";}
| '('EPA Op EPA
')' {cout << "Op";}
;
%%
Pour cet exemple, le lexeur ne connaît que cinq lexèmes singuliers, il
s'écrit
[xyz]
return Id;
[123]
return Cte;
[+*]
return Op;
"("
return '(';
")"
return ')';
Moyennant quoi la lecture de la chaîne ((x+y)*((x+(y*(z+2)))*(y+3)))
provoque l'impression d'une forme dégradée de notation postfixée :
IdIdOpIdIdIdCteOpOpOpIdCteOpOpOp
Pas très intéressant !
On mesure ici l'intérêt des lexèmes génériques et de l'information
transmise !
-
-
Suivant le principe de l'analyse ascendante, au moment où la réduction
a lieu, le membre droit de la règle se trouve
au sommet de la pile.
L'information qui se trouve ainsi localisée dans la pile est utilisable
par l'instruction C++ qui effectue une réduction.
Cette instruction "a accès à la pile" par le mécanisme suivant
:
Les valeurs associées aux différents éléments du
membre droit (lexèmes ou symboles non-terminaux) sont désignées par "$1
",
"$2
", "$3
"... dans
l'ordre de l'écriture du membre droit (inverse de celui de la pile) :
par exemple
'(' EPA Op EPA ')'
$1 $2 $3 $4 $5
-
On peut trouver dans la pile
- soit des informations attachées aux lexèmes (qui y
sont
déposés par le lexeur ),
- soit des informations produites par les réductions,
et
associées aux symboles non-terminaux.
Par défaut, ces informations sont des entiers codant les noms de
lexèmes et de non-terminaux.
Si on veut davantage, il faut avant tout déclarer les types des
informations souhaitées.
Comme dans le cas de la variable-tampon lexval
,
cette déclaration se fera sous la forme d'un type union
,
qui devra figurer dans la spécification du parseur.
Il faudra en outre associer des types élémentaires aux symboles de la
pile au moyen des champs de cette union
, sous
la forme
%type <le_champ> les symboles
Comme ladite déclaration contient celle qui vise lexval
, elle
vaut aussi pour le lexeur.
-
%union {
char nom;
}
%token Id Cte Op
%type <nom> Id Cte Op
%%
prog : EPA {cout
<< endl;}
;
EPA : Id {cout << $1;}
| Cte {cout
<< $1;}
| '('EPA Op EPA
')' {cout << $3;}
;
%%
Pour cet exemple, le lexeur connaît trois lexèmes génériques, et
deux lexèmes singuliers :
il
s'écrit
[xyz]
{lexval.nom = yytext[0]; return Id;}
[123]
{lexval.nom = yytext[0]; return Cte;}
[+*]
{lexval.nom = yytext[0]; return Op;}
"("
return '(';
")"
return ')';
Moyennant quoi la lecture de la chaîne ((x+y)*((x+(y*(z+2)))*(y+3)))
provoque l'impression de la notation postfixée :
xy+xyz2+*+y3+**
Sans
surprise - mais c'est déjà mieux !
-
Les choses deviennent vraiment intéressantes lorsqu'on accompagne les
réductions par des actions d'empilement de résultats intermédiaires.
Un tel empilement s'écrit sous la forme d'une affectation à la
pseudo-variable "$$
" qui représente le sommet
de la pile.
Bien évidemment, le type de la valeur affectée va devoir être déclaré !
Cette déclaration apparaît sous deux aspects :
- un champ supplémentaire dans le type union
- une déclaration de type pour le symbole
non-terminal, but de la réduction.
-
(cf. cours 2-8)
%union {
int val;
}
%token Id Cte Op
%type <val> EPA
%%
prog : EPA {cout
<< $1 << endl;}
;
EPA : Id { $$ = 0;}
| Cte { $$ = 0;}
| '('EPA Op EPA
')' { $$ = 1+max($2, $4);}
;
%%
avec le même lexeur que pour l'exemple
rudimentaire.
Moyennant quoi la lecture de la chaîne ((x+y)*((x+(y*(z+2)))*(y+3)))
provoque l'impression de la profondeur :
5
-
On doit disposer d'une classe d'arbres adéquate pour le but visé par
l'application.
Dans un premier temps, nous reprendrons celle qui nous a
déjà servi : ArbExp
(dont les étiquettes sont des caractères).
La construction proprement dite se fera par des actions sémantiques du
même genre que celles qui effectuent le calcul de la profondeur,
en travaillant sur le domaine des arbres au lieu de celui des entiers,
comme expliqué au
cours 2-8.
-
Mais
cet arbre devra être exploité par l'application ! Il nous faut donc
trouver un moyen de le communiquer à l'extérieur du parseur.
(Dans tous les exemples précédents, l'effet observé était produit
directement par l'exécution du parseur.)
La chose ne va pas de soi, car le parseur est bien réalisé comme une
fonction, nommée yyparse()
, mais cette
fonction renvoie un entier,
interprété comme un signal de bonne fin, et il n'est pas possible de la
transformer en une fonction dont la valeur serait l'arbre construit.
Il nous faut donc passer par une variable-tampon où la dernière
réduction logera l'arbre construit.
Conformément
aux bons principes de la programmation par objets, le système bisonpp
engendre le parseur comme une instance d'une classe
que l'utilisateur peut perfectionner à sa guise. Dans les exemples ici
proposés, cette classe s'appelle ExpParser
(ce nom est sans importance, mais comme il apparaît à
plusieurs reprises, il vaut mieux s'entendre).
La fonction yyparse()
apparaît comme
une méthode de la classe Expparser
.
Dans ces conditions, il suffit d'ajouter un attribut public
de type ArbExp*
à la classe ExpParser
:
cet attribut sera accessible à la fois depuis yyparse()
(pour
la dernière réduction)
et depuis le code de l'application
puisqu'il est public.
-
Rappel : Le système bisonpp produit des fichiers
d'implémentation qui
portent
l'extension "
.C
" au lieu de ".cpp
".
Nous nous conformons donc à cette pratique.
Voici le code de l'interface expparser.h
: le
fichier d'implémentation expparser.C
est engendré automatiquement par bisonpp.
#ifndef
EXPPARSER_H
#define EXPPARSER_H
#include <iostream>
#include "explexer.h"
#include "../../ArbExp.h" // la classe
des arbres
class ExpParser {
private:
ExpLexer
_lexer;
int
yynerrs;
int
yychar;
YYSTYPE
yylval;
void
yyerror(const char* msg) { }
int
yylex() { return _lexer.yylex(yylval); }
public:
ExpParser(std::istream& is):
_lexer(&is) {} // constructeur
int
yyparse(); // la fonction d'analyse
ArbExp*
arbre; // la dernière réduction y loge l'arbre construit
};
#endif
et voici sa mise en œuvre, dans une application minimale qui reproduit
le comportement de nos parseurs précédents (cours 2-7 et 2-8)
/*
Mise en œuvre élémentaire d'un parseur ascendant quelconque
produisant des ArbExp
*/
#include <iostream>
#include <sstream>
#include "expparser.h" // contient '#include "ArbExp.h"'
using namespace std;
int main() {
string lu;
getline(cin, lu);
stringstream inlu(lu, ios_base::in);
ExpParser parser(inlu);
int n = parser.yyparse(); // valeur signal, normalement 0
if( n ){ // traitement d'erreur sommaire
cerr << "syntax error in \"" << lu << "\"" << endl;
return 1; // et c'est fini
}
// si n == 0, OK
ArbExp* arb = parser.arbre; // comme annoncé
// et l'aplication se poursuit en traitant l'arbre obtenu
arb->printPref();
cout << endl;
arbr>printPost();
cout << endl;
return 0;
}// main
-
C'est à partir ce ce fichier que bisonpp engendre le
fichier
expparser.C
qui
implémente le parseur.
Comme "explexer.ll
", il est formé de trois
parties,
- Une partie "administrative" entre "
%{
"
et "%}
" ;
- Une partie centrale qui comprend, chacun précédé
par "
%
", dans l'ordre
- la déclaration du type des éléments de la pile
(type
union
)
- la liste des lexèmes (tokens)
- les déclarations de types des lexèmes et des
non-terminaux (donnés sous la forme des champs de l'
union
)
- La grammaire augmentée de son axiome postiche, avec ses
actions sémantiques, entre "
%%
" et "%%
".
%{ /* Un parseur ascendant équivalent à AnAsc/CP/acp1Arb.cpp et à acp2Arb.cpp.
Notation complètement parenthésée.
Les variables et les constantes et les opérateurs sont des caractères.
On produit des ArbExp.
*/
#include <iostream>
#include "explexer.h"
#include "expparser.h"
#include "../../ArbExp.h"
%}
%union {
char nom; ArbExp* expar;
}
%token Id Cte Op
%type <nom> Id Cte Op
%type <expar> EPA
%%
prog : EPA { arbre = $1 ; } // la derniere reduction
;
EPA : Id {$$ = new ArbExp($1);}
| Cte {$$ = new ArbExp($1);}
| '('EPA Op EPA ')' {$$ = new ArbExp($3, $2, $4);}
;
%%
Le lexeur est le même que celui de l'exemple "un peu moins
rudimentaire" : les valeurs des trois lexèmes génériques doivent
être
des caractères !
Moyennant quoi la lecture de la chaîne ((x+y)*((x+(y*(z+2)))*(y+3)))
provoque la construction de l'arbre et ses deux impressions polonaises :
*+xy*+x*y+z2+y3
xy+xyz2+*+y3+**
-
Exercice
faites fonctionner cet exemple lorsque vous aurez acquis la technique de réalisation ci-après,
avec l'instrumentation décrite au cours 2-10.
- l'application
calc.C
- le lexeur
explexer.ll
- le parseur
expparser.cp.yy
(attention, il faudra le renommer expparser.yy
pour le faire fonctionner avec les instruments du cours !).
-
La génération de l'exécutable de l'application, qui contient le lexeur
et le parseur, est une tâche complexe.
Cette tâche est rendue encore plus compliquée par une série de détails
techniques dans lesquels nous n'entrerons pas.
Le programmeur doit founir trois fichiers :
- La spécification du lexeur, dans un fichier portant
l'extension "
.ll
". Par exemple "explexer.ll
".
- La spécification du parseur, dans un fichier portant
l'extension "
.yy
". Par exemple "expparser.yy
".
- Le code C++ de l'application, ici appelé "
calc.C
".
Les fichiers d'interface des classes ExpLexer
et ExpParser
sont donnés a priori.
La génération est effectuée par trois commandes rassemblées
dans un fichier makefile
.
- Génération du lexeur ("
explexer.C
")
à partir de la spécification "explexer.ll
"
par la commande flex++
- Génération du parseur ("
expparser.C
")
à partir de la spécification "expparser.yy
"
par la commande bisonpp
- Compilation de l'ensemble avec le fichier "
calc.C
"
pour produire l'exécutable
par la commande g++
.
Vu la complication byzantine du fonctionnement de Flex++ &
bisonpp dans notre environnement,
je vous propose la démarche suivante pour conduire vos expériences : voir le cours 2-10.
-
Les expressions arithmétiques en syntaxe traditionnelle à deux niveaux
de précédences,
comme celles que nous avons traitées au cours 2-8.
Les arbres d'expressions sont instances de la classe ArbExp
,
dont les étiquettes sont des caractères.
Le lexeur doit donc produire des caractères, comme celui de l'exemple
un peu moins rudimentaire.
Le code de l'application a été donné ci-dessus (fichier calc.C
), il ne
change pas.
Voici les deux fichiers de spécifications du parseur et du lexeu:
expparser.prec.yy
(à renommer expparser.yy
pour le faire fonctionner avec les instruments du cours)
%{ /* Un parseur ascendant équivalent à AnAsc/Prec/aprec2Arb.cpp.
Notation à précédences traditionnelle à deux niveaux.
Les variables et les constantes et les opérateurs sont des caractères.
On produit des ArbExp.
*/
#include <iostream>
#include "explexer.h"
#include "expparser.h"
#include "../ArbExp.h"
%}
%union {
ArbExp* expar; char nom;
}
%token OpAdd OpMul Id Cte
%type <nom> OpAdd OpMul Id Cte
%type <expar> EXPAR TERME FACTEUR
%%
prog : EXPAR { arbre = $1 ; } ;
EXPAR : EXPAR OpAdd TERME { $$ = new ArbExp($2, $1, $3); }
| TERME { $$ = $1; }
;
TERME : TERME OpMul FACTEUR { $$ = new ArbExp($2, $1, $3); }
| FACTEUR { $$ = $1; }
;
FACTEUR : '(' EXPAR ')' { $$ = $2; }
| Cte { $$ = new ArbExp($1); }
| Id { $$ = new ArbExp($1); }
;
%%
explexer.ll
On prendra garde à la place du commentaire initial, en deuxième ligne
contrairement à "expparser.yy
" où il apparaît
en première ligne.
Une des bizarreries de Flex (héritée de Lex) est qu'il n'accepte pas de
commentaire sur la même ligne que la balise "%{
".
%{
/*
Fichier "explexer.ll".
un lexeur conforme aux analyseurs des cours 2-7 & 2-8
Les variables sont les lettres x, y ou z.
Les nombres ont 1, 2 ou 3
*/
#undef yyFlexLexer // suppression de la définition de FlexLexer pour éviter la redéfinition
#include "explexer.h"
#include "expparser.h"
%}
%option noyywrap
%option yyclass="ExpLexer"
%%
[xyz] { lexval.nom = yytext[0];
return Id;
}
[123] { lexval.nom = yytext[0];
return Cte;
}
[+*] { lexval.nom = yytext[0];
return Op;
}
"(" return '(';
")" return ')';
. return 0; // dans tous les autres cas, erreur
%%
Exécution :
jfp% ./calc
x-y/(z+3-x*y)-z/2
--x/y-+z3*xy/z2
xyz3+xy*-/-z2/-
-
Les expressions arithmétiques en syntaxe traditionnelle à trois niveaux
de précédences, comme celles que nous avons traitées au
cours 2-5.
On veut en outre que les noms de variables soient des chaînes de
caractères, et les constantes des nombres décimaux.
Et on souhaite pouvoir insérer des blancs et des tabulations ad
libitum.
Les arbres d'expressions sont à présent instances de la classe Exp
Arb
,
dont les étiquettes sont des chaînes, et où, en conséquence,
les procédures d'impression polonaises séparent les étiquettes
par des espaces. [fichiers Exp
Arb.h
et Exp
Arb.cpp
].
Le lexeur va produire tantôt des caractères (pour les opérateurs),
tantôt des entiers, tantôt des chaînes.
Le fait que les étiquettes de nos arbres soient exclusivement des chaînes (et pas des entiers, par exemple), va nous obliger dans le parseur à
convertir en chaînes les entiers livrés par le lexeur.
Nous maintenons cette complication (au lieu d'adapter le lexeur en lui faisant produire des chaînes pour les nombres)
de manière à pouvoir réutiliser le lexeur tel quel, en ne modifiant que le parseur,
si on nous procure un jour des arbres de syntaxe abstraite un peu plus perfectionnés.
Le code de l'application est celui du premier exemple,
il ne change que
par le
nom de la classe d'arbres invoquée (ExpArb
au lieu de ArbExp
). [fichier calc.C
]
Voici les deux fichiers de spécifications :
expparser.yy
%{ /* Notation à précédences traditionnelle
à trois niveaux.
On produit des ExpArb.
*/
#include <sstream> // pour
la conversion d'entier en chaîne
#include "explexer.h"
#include "expparser.h"
#include "../../ExpArb.h" // étiquettes
string
et non plus char
%}
%union {
ExpArb* expar; const char*
nom; int val; char op;
}
// Attention ! Pour le champ "nom" il faut "const char*" et pas
"string" !
%token OpAdd OpMul
OpExp Id Cte
%type
<op> OpAdd OpMul OpExp
%type
<nom> Id
%type
<val> Cte
%type
<expar> EXPAR TERME FACTEUR PRIMAIRE
%%
prog : EXPAR { arbre = $1 ; } ;
EXPAR :
EXPAR OpAdd TERME { $$ = new ExpArb($2, $1, $3); }
| TERME { $$ = $1; }
;
TERME : TERME OpMul
FACTEUR { $$ = new ExpArb($2, $1, $3); }
| FACTEUR { $$ = $1; }
;
FACTEUR : PRIMAIRE OpExp FACTEUR { $$ =
new ExpArb($2, $1, $3); } // OpExp exigé par le typage : '^' impossible
| PRIMAIRE { $$ = $1; }
;
PRIMAIRE : '(' EXPAR ')' { $$ = $2; }
| Cte { ostringstream oss; oss
<< $1; $$ = new ExpArb(oss.str()); } // conversion
laborieuse
| Id { $$ = new ExpArb($1); }
;
%%
explexer.ll
%{
/* un lexeur normal pour des expresions
arithmétiques
*/
#undef yyFlexLexer
#include
<iostream>
#include
<sstream>
// pour
la conversion de
chaîne
en
entier
#include "explexer.h"
#include "expparser.h"
%}
%option noyywrap
%option yyclass="ExpLexer"
%%
[ \t] ;
[a-z][a-z0-9]* {
lexval.nom = yytext;
return Id;
}
[0-9]+ { stringstream
ss(yytext); int i; ss >> i; lexval.val = i;
// conversion
laborieuse
return Cte;
}
[+-] {
lexval.op = yytext[0];
return OpAdd;
}
[*/] {
lexval.op = yytext[0];
return OpMul;
}
"^"
{ lexval.op = yytext[0];
return OpExp;
}
"("
return '(';
")"
return ')';
%%
Exécution :
jfp% ./calc
xx + y2/2^k0^( 3 * pa + 10 )
+ xx / y2 ^ 2 ^ k0 + *
3 pa
10
xx y2 2 k0 3
pa * 10 + ^
^ / +
Pour la pratique de la mise en œuvre, voir le cours 2-10.