Interprétation et Compilation des Langages
Interprétation et Compilation des Langages
Michel Meynard
14 janvier 2016
ii
Table des matières
Avant-propos v
1 Introduction 1
1.1 Objectifs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Bibliographie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.3 Rappels théoriques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.3.1 Familles de grammaires et de langages : hiérarchie de Chomsky . . . . . . . . . . . . . . . . . . 1
1.3.2 Langages réguliers : propriétés et caractérisations . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.3.3 Langages algébriques : propriétés et caractérisations . . . . . . . . . . . . . . . . . . . . . . . . 2
1.4 Types de traducteurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.5 Modèle classique de compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.6 Remarques . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.7 Vocabulaire des langages de programmation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2 Analyse lexicale 5
2.1 Reconnaissance d’un mot par un AFD . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.2 Implémentation des Automates Finis Déterministes AFD . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.3 Analyseur lexical . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.4 Implémentation des analyseurs lexicaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.5 Un langage et un outil pour l’analyse lexicale : (f)lex . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.5.1 Un exemple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.5.2 Syntaxe et sémantique des sources Flex . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.5.3 La commande flex . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.5.4 Actions C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.5.5 Liaison avec un analyseur syntaxique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.6 Algorithmique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.6.1 Traduction des expressions régulières . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.6.2 Déterminisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.6.3 Minimisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
3 Analyse syntaxique 19
3.1 Analyse descendante récursive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
3.2 Analyse descendante par automate à pile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.2.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.2.2 Fonctionnement de l’automate à pile en analyse descendante . . . . . . . . . . . . . . . . . . . 21
3.2.3 Algorithmique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.2.4 Construction de la table d’analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.2.5 Grammaires LL(1) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
3.2.6 Conclusion sur l’analyse descendante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
3.3 Un langage et un outil pour l’analyse syntaxique : yacc . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.3.1 Un exemple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.3.2 Syntaxe et sémantique des sources yacc . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
3.3.3 Un exemple complet : une calculette . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
3.3.4 Bison (version 2.3) et analyseur C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
3.4 Analyse ascendante par automate à pile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
3.4.1 Fonctionnement de l’automate à pile en analyse ascendante LR . . . . . . . . . . . . . . . . . . 42
3.4.2 Algorithmique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
3.5 Les conflits et leur résolution par yacc . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
iii
iv TABLE DES MATIÈRES
4 Analyse sémantique 49
4.1 Table des symboles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
4.1.1 Généralités . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
4.1.2 Implémentation d’une table des symboles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
4.2 Gestion des erreurs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.3 Arbre abstrait . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.4 Traduction dirigée par la syntaxe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.4.1 Grammaires attribuées . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
4.4.2 Méthode de transformation des grammaires L-attribuées . . . . . . . . . . . . . . . . . . . . . . 54
5 Génération de code 57
5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
5.2 Machine virtuelle à pile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
5.3 Développement d’un compilateur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
Index 63
Bibliographie 64
Ce cours s’inscrit dans un enseignement d’informatique, discipline scientifique dispensée à la faculté des sciences de
l’université de Montpellier. L’évolution des connaissances scientifiques dans l’histoire depuis la Grèce antique jusqu’à
nos jours a souvent remis en cause des connaissances plus anciennes ou des dogmes religieux. Au IVe siècle avant
notre ère, le grec Anaxagore de Clazomènes est le premier savant de l’histoire à être accusé d’impiété par les autorités
religieuses de l’époque ["Bruno et Galilée au regard de l’infini" de Jean-Pierre Luminet]. Il ne doit sa liberté qu’à
son amitié avec Périclès. Plus tard, Giordano Bruno (1548-1600) invente la cosmologie infinitiste mais également le
principe d’inertie. Ses idées et ses écrits contraires à la doctrine chrétienne le conduisent à être incarcéré au total
pendant huit ans dans les geôles de l’Inquisition. Après de multiples procès, le 16 Février de l’an de grâce 1600,
Giordano BRUNO est torturé et brûlé vif, par l’inquisition catholique, à Rome, sur le Campo dei Fiori, pour avoir
refusé d’abjurer ses idées. Plus tard, Galilée, Kepler auront également des problèmes juridiques liés à l’expression de
leurs idées scientifiques révolutionnaires.
En France, le siècle des lumières puis la révolution française de 1789 ont permis de donner la liberté d’expression aux
scientifiques (et aux autres) afin que leurs travaux de recherche puissent être publiés, discutés, réfutés ou approuvés.
La loi de séparation des Églises et de l’État a été adoptée le 9 décembre 1905 à l’initiative du député républicain-
socialiste Aristide Briand. Elle prend parti en faveur d’une laïcité sans excès. Elle est avant tout un acte fondateur
dans l’affrontement violent qui a opposé deux conceptions sur la place des Églises dans la société française pendant
presque vingt-cinq ans.
La liberté de pensée et d’expression constituent donc les fondements d’un enseignement universitaire de qualité.
D’autres part, les scientifiques étant des citoyens comme les autres, il convient de rappeler quelques lois françaises qui
nous gouvernent.
v
vi AVANT-PROPOS
Chapitre 1
Introduction
1.1 Objectifs
— Mise en oeuvre de la théorie des langages formels.
— Compréhension des techniques de compilation.
— Utilisation d’outils de génération de code (flex, bison).
— Utilité des traducteurs : compilateurs, interpréteurs, convertisseurs de format (rtfToLatex, LaTeXToHtml, post-
script To . . .).
— Réalisation d’un projet : compilateur d’un langage à objets “Sool”.
1.2 Bibliographie
Vous trouverez en fin d’ouvrage, un certain nombre de références de livres vous permettant d’aller plus loin dans
l’étude des compilateurs et des langages de programmation. La bible de ces ouvrages [1] est extrêmement complet
et détaille les techniques algorithmiques à appliquer aux langages. Tout au contraire, l’ouvrage [2] est une étude
mathématique des langages formels. En ce qui concerne le langage Java, le livre de référence [3] décrit le langage et sa
bibliothèque fournie tandis que [4] décrit le byte-code. Quant à C++, les livres [5, 6] décrivent le modèle objet de ce
langage. Enfin, ne pas oublier le livre [7] qui est un manuel technique de ces deux outils.
Exemple 1
le P garçon → le petit garçon ; la P N → la petite N ; N → fille.
Type 2 toute règle r de R est de la forme : r = X → α avec α ∈ V ∗ ; X ∈ VN .
Ces grammaires sont dites algébriques, ou indépendantes du contexte (“context-free”), ou grammaires de
Chomsky, ou C-grammaires.
Exemple 2
P → (P )|ε|P P : une grammaire de parenthèses.
Type 3 toute règle r de R est de la forme : r = X → α avec α ∈ VT VN ∪ VT ∪ {ε} ; X ∈ VN ;
Ces grammaires sont dites régulières, ou rationnelles, ou grammaires de Kleene, ou K-grammaires.
Exemple 3
P → 0|1E|2E| . . . |9E ; E → 0E| . . . |9E|ε : une grammaire régulière d’indices.
1
2 CHAPITRE 1. INTRODUCTION
Théorème 1 On note Li l’ensemble des langages engendrés par les grammaires de type i. On a alors l’inclusion
stricte : L3 ⊂ L2 ⊂ L1 ⊂ L0 .
Théorème 3 (Théorème de Kleene) La famille des langages réguliers L3 est la plus petite famille de langages qui
contient les langages finis et qui est fermée pour les opérations réunion, produit et étoile.
Théorème 4 (la pompe, version 2a) Soit L, un langage régulier infini sur V. Alors, ∃k ∈ N − {0} tel que ∀m ∈
L, |m| ≥ k, ∃x, u, y ∈ V ∗ tel que u 6= ε, m = xuy, |xu| ≤ k et ∀n ∈ N, xun y ∈ L.
Théorème 5 Le langage inverse, complémentaire d’un langage régulier est régulier. L’intersection de deux langages
réguliers est régulier.
Théorème 6 L’ensemble des dérivations gauches d’une grammaire algébrique G = (VT , VN , R, S) est équipotent à
A(G).
Définition 2 Une grammaire G = (VT , VN , R, S) est ambiguë si et seulement s’il existe deux dérivations gauches
distinctes partant de S et aboutissant au même mot terminal m.
Théorème 8 (d’Ogden) Soit L un langage algébrique infini sur V. Alors, ∃k ∈ N − {0} tel que ∀m ∈ L, |m| >
k, ∃x, u, y, v, z ∈ V ∗ tel que uv 6= ε, m = xuyvz, |uyv| ≤ k et ∀n ∈ N, xun yv n z ∈ L.
Théorème 9 La famille des langages algébriques L2 est fermée pour l’union, la concaténation, l’opération *.
Théorème 10 La famille des langages algébriques L2 n’est pas fermée pour l’intersection ni la complémentation.
1.6 Remarques
— L’analyse lexicale est souvent réalisée “à la demande” de l’analyse syntaxique, jeton par jeton. Ainsi la décompo-
sition en phase (analyse lexicale, syntaxique, sémantique, . . .) n’engendre pas forcément la même décomposition
en “passes”, une passe correspondant à la lecture séquentielle du résultat de la phase précédente. Les problèmes
de “référence en avant” (“forward reference”) pose tout de même des problèmes à la compilation en une seule
passe. Il faut pouvoir laisser des “blancs” qu’on pourra reprendre plus tard quand on connaîtra la valeur de
cette référence.
— Le compilateur est souvent décomposé en une partie “frontale” indépendante de la plateforme de développement,
et une partie “finale” dépendante de la plateforme de développement. Ainsi, l’écriture d’un compilateur du même
langage source pour une autre plateforme est moins couteuse.
opérateur il permet de construire une expression complexe à partir d’expressions de base (littéraux, identifica-
teurs). Exemples : +, -, /, *, ++, [], ()
bloc suite d’instructions généralement entouré d’accolades {...}.
définition de type, de variable, de fonction, de classe : elle permet de spécifier un objet de programmation.
Chapitre 2
Analyse lexicale
Avant d’aborder l’analyse lexicale, rappelons les résultats sur les Automates d’états Finis Déterministes (AFD).
Exemple 4
Soit l’AFD suivant :
a b c
EINIT EA EAB EABC
b
d
EB EBD
5
6 CHAPITRE 2. ANALYSE LEXICALE
/**@file afd.h
*@author Michel Meynard
*@brief Définition d’un AFD
*/
#define EINIT 0
#define EA 1
#define EAB 2
#define EABC 3
#define EB 4
#define EBD 5
#define NBETAT 6
/**@file accepter.c
*@author Michel Meynard
*@brief Définition de la fon accepter
*/
#include <stdio.h>
#include "defafd.h" /* définition de l’automate */
> accepter
Saisissez un mot matchant a(b+c)?|bd suivi de EOF (CTRL-D) SVP : abbbc
Mot reconnu !
2.3. ANALYSEUR LEXICAL 7
> accepter
Saisissez un mot matchant a(b+c)?|bd suivi de EOF (CTRL-D) SVP : abd
Mot non reconnu !
Exemple 5
Soit l’AFD de l’exemple 4. On transforme la définition de l’automate pour ajouter la définition des jetons dans un
tableau entier JETON remplaçant le tableau FINAL :
Remarquons, que les lexèmes “bd” seront filtrés car le jeton correspondant est négatif ! Nous représentons la fonction
d’analyse lexicale int analex() dans le fichier analex.h suivant :
/**@file analex.h
*@author Michel Meynard
*@brief Définition de la fon analex
*/
char lexeme[1024]; /* lexème courant de taille maxi 1024 */
else {
ungetc(c,stdin); /* rejeter le car non utilisé */
for(i=strlen(lexeme)-1;i>=1;i--)
ungetc(lexeme[i],stdin); /* rejeter les car en trop */
return lexeme[0];
}
}
/**@file analex.c
*@author Michel Meynard
*@brief Prog principal appelant itérativement analex()
*/
#include <stdio.h>
#include <string.h>
#include "afd.h" /* Définition de l’AFD et des JETONS */
#include "analex.h" /* Définition de la fon : int analex() */
int main(){
int j; /* jeton retourné par analex() */
char *invite="Saisissez un(des) mot(s) matchant a(b+c)?|bd suivi de EOF (CTRL-D) SVP : ";
creerAfd(); /* Construction de l’AFD à jeton */
printf("%s",invite); /* prompt */
while((j=analex())!=0){ /* analyser tq pas jeton 0 */
printf("\nRésultat : Jeton = %d ; Lexeme = %s\n%s",j,lexeme,invite);
}
return 0;
}
Saisissez un(des) mot(s) matchant a(b+c)?|bd suivi de EOF (CTRL-D) SVP : abdbdabbc
Résultat : Jeton = 300 ; Lexeme = a
Résultat : Jeton = 301 ; Lexeme = abbc
Saisissez un(des) mot(s) matchant a(b+c)?|bd suivi de EOF (CTRL-D) SVP : 0ab
Résultat : Jeton = 48 ; Lexeme = 0
Résultat : Jeton = 300 ; Lexeme = a
Résultat : Jeton = 98 ; Lexeme = b
Résultat : Jeton = 10 ; Lexeme =
Remarquons que sur l’entrée standard Unix le CTRL-D tapé en début de ligne génère un EOF, mais après une
chaîne de caractères, le CTRL-D (parfois doublé à cause des ungetc) génère un vidage (flush) du tampon d’entrée sans
caractère supplémentaire à la différence du ENTREE qui envoie un retour à la ligne (’\n’ codé par 10).
Pour conclure, avec un langage réel de taille importante, il devient difficile de construire manuellement l’AFD
sans se tromper (plusieurs centaines de transitions). De plus, l’évolution permanente de la grammaire d’un langage
en cours de conception rend nécessaire l’utilisation d’un outil informatique pour modéliser le langage lexical à l’aide
d’expressions régulières. L’outil aura comme mission de transformer ces expressions en AFD à jeton et de fournir une
fonction d’analyse lexicale.
2.5.1 Un exemple
Exemple 6
Analyseur lexical de l’exemple 4 réécrit en lex :
2.5. UN LANGAGE ET UN OUTIL POUR L’ANALYSE LEXICALE : (F)LEX 11
Instruction(s) C
La partie droite de chaque règle correspond à une suite de longueur quelconque d’instructions C. Le texte inclu entre
accolades sera recopié intégralement dans lex.yy.c sans aucune analyse ni modification. Il doit donc correspondre à
un source C correct.
Les instructions C peuvent faire appel à des fonctions prédéfinies par lex ou définies par l’utilisateur dans la
troisième partie du source lex. En particulier, avec flex, on peut ne pas utiliser la librairie flex libfl.a à condition
de définir la fonction principale main() ainsi que la fonction int yywrap(). Par exemple, pour éviter l’édition de liens
avec la librairie flex, on pourra simplement écrire dans la troisième partie :
int yywrap() {return 1;} /* pas d’enchaînement sur un autre fichier */
main() {while (yylex()!=0) {} } /* boucle sans rien faire jusqu’à eof */
Les instructions C peuvent référerencer une variable :
— soit prédéfinie par lex : la chaîne char* yytext de longueur int yyleng correspond au mot reconnu dans le
texte à analyser (lexème) ;
— soit définie en partie définitions : dans ce cas, la variable est globale ;
— soit définie juste après l’accolade : dans ce cas, la variable est locale à la règle.
Exemple 7
Le source lex suivant illustre l’utilisation des variables :
%{ int glob=0; %}
%%
-?[1-9]+ {int loc=5; glob++;loc++;
printf("%d ème entier de taille %d; loc= %d",glob,yyleng,loc);
}
Une exécution de ce programme donne :
12
1 ème entier de taille 2; loc= 6
123
2 ème entier de taille 3; loc= 6
-1
3 ème entier de taille 2; loc= 6
2.5. UN LANGAGE ET UN OUTIL POUR L’ANALYSE LEXICALE : (F)LEX 13
Variables prédéfinies
yytext chaîne de car (char *) contenant le lexème en cours de reconnaissance ;
yyleng longueur (int) de yytext ;
yyin flot d’entrée des caractères de type FILE* (par défaut stdin) ; On peut rediriger le flot d’entrée sur le premier
argument du main en faisant : yyin=fopen(argv[1],"r");
yyout sortie standard de type FILE*. Pour y afficher, faire : fprintf(yyout, "...");
Fonctions prédéfinies
int yylex() lit un lexème depuis le flot d’entrée et retourne le jeton associé. Retourne le jeton 0 pour finir.
int input() lecture d’un caractère depuis le flot d’entrée (yyinput en C++) ; input() équivaut à fgetc(yyin) ;
void unput(int) retour dans le flot d’entrée d’un car ; unput(c) équivaut à ungetc(c,yyin) ;
int yywrap() lorsque l’analyseur yylex() arrive en fin de fichier (EOF), il appelle yywrap(). Si yywrap retourne
1 (par défaut) alors yylex() retourne 0 (fin d’analyse). Si on voulait enchaîner sur un autre fichier, il faut
redéfinir dans la partie “définitions” du source lex, la fonction yywrap() afin qu’elle fasse pointer yyin sur le
nouveau fichier puis retourne 0 ;
yymore() concatène dans yytext le prochain lexème avec celui en cours ;
yyless(int n) replace le lexème reconnu yytext dans le flot d’entrée à l’exception de ses n premiers caractères ;
ECHO affiche yytext ; ECHO équivaut à fprintf(yyout,yytext) ;
REJECT rejette le lexème reconnu dans le flot d’entrée et s’interdit de reconnaitre la règle courante au prochain
essai (appel de yylex()).
BEGIN(etat) positionne l’automate dans la condition de départ etat. Cet état doit avoir été défini dans la
première partie grâce à %Start etat ou à %x etat. BEGIN(0) permet de revenir à l’état normal.
int main() par défaut, la librairie de lex (libl.a) ou de flex (libfl.a) définissent une fonction pricipale qui
appelle yylex() jusqu’à ce que celle-ci retourne 0.
Ambiguités de correspondance
Règle de la plus longue correspondance (match) si un préfixe (début de chaîne) correspond à plusieurs ex-
pressions régulières possibles, lex choisira l’expression régulière correspondant à la plus longue extension. Par
exemple, avec les règles suivantes :
end {return 300;}
[a-z]+ {return 301;}
Le mot endemique se verra appliquer la seconde règle (identificateur) et yylex() retournera 301.
Attention aux opérateurs contextuels en avant qui comptabilisent les caractères en avant : par exemple, l’ex-
pression régulière a$ sera préféré à l’expression a pout tout a en fin de ligne.
Règle du premier trouvé si la longueur de correspondance est égale pour plusieurs règles, alors c’est la pre-
mière dans la liste qui est déclenchée. Dans l’exemple précédent, le mot end déclenchera le retour de 300. Par
conséquent, pour un langage donné, il faut toujours placer les règles concernant les mots-clés au début.
Attention aux opérateurs contextuels qui provoquent parfois des “erreurs” ! En effet, l’utilisation des 2 règles suivantes
provoque un conflit gagné par la première règle (à l’encontre de la règle du plus long lexème) :
En inversant l’ordre de ces deux règles, tout se passe cependant comme prévu. En fait, les opérateurs contextuels de
suffixe ($, /) sont consommés après le lexème et c’est ce mot qui doit être considéré comme le plus long possible.
Ensuite, le suffixe sera rejeté dans yyin.
Partie définitions
Il existe différentes sortes de définitions :
Définitions C toute ligne de la partie définitions débutant par un espace ou une tabulation est recopiée au
début du source C généré par lex. Ces lignes seront donc externes à toute fonction C du code correspondant
à l’automate. Il en va exactement de même pour tout ce qui est inclus entre %{ et %} seuls et en début de
ligne, ces délimiteurs étant détruits dans lex.yy.c. A part les variables globales, cette partie permet d’inclure
des macros #include #define, des typedef, . . ..
Abbréviation de modèle certaines parties de modèles revenant fréquemment dans les règles, on peut en définir
des alias selon la syntaxe suivante : nomAlias séparateur(s) modèle. Par exemple :
14 CHAPITRE 2. ANALYSE LEXICALE
chiffre ([0-9])
minuscule ([a-z])
exposant ([DEde][-+]?{chiffre}+)
Dans cet exemple, chiffre désigne l’alias de [0-9]. Ces alias seront principalement utilisés dans les expressions
régulières en les entourant d’accolades. Le parenthésage sera utilisé systématiquement pour éviter des problèmes
liés aux priorités.
Start Condition permet de conditionner la reconnaissance de certaines expressions régulières selon la condition
dans lequel l’analyseur se trouve. Par exemple, %x DANSCHAINE DANSCOMMENT définit deux conditions exclusives.
Celles-ci pourront être utilisés en préfixe des expressions régulières comme dans l’exemple suivant :
["] {BEGIN(DANSCHAINE);}
<DANSCHAINE>[^"]+ {yylval.s=strdup(yytext);}
<DANSCHAINE>["] {BEGIN(INITIAL); return LITCHAINE;}
Au départ la condition initiale s’appelle INITIAL et vaut 0. Lorsque flex reconnaît le guillemet, il passse dans
la condition DANSCHAINE où il va reconnaître l’intérieur de la chaîne. Après avoir reconnu le guillemet final,
il retournera le jeton de littéral chaîne.
La définition des états peut également se faire par %s s1 s2 (inclusif). La différence entre les conditions
inclusives et exclusives réside dans le fait que dans le cas inclusif, les règles préfixées de condition sont prioritaires
mais les autres règles (sans conditions) seront utilisées s’il n’y a pas de correspondance possible ! Il est souvent
préférable d’utiliser l’exclusivité %x s1 s2.
Options flex commençant toujours par le mot %option telles que %option noyywrap : un seul fichier, %option yylineno :
numéro de ligne, ...
Troisième partie
Cette partie permet d’écrire des fonctions C utilisées dans les parties droites des règles. On peut également redéfinir
les fonctions main(), yywrap(), input(), unput(char), ... afin de surcharger leur version flex. Ces fonctions
peuvent également être redéfinies dans un fichier inclus.
makefile
Voici la partie du makefile correspondant à la génération d’applications à partir de source flex d’extension .l.
Si l’on veut utiliser flex sans sa bibliothèque (extension .fl), il suffit de définir les fonctions int main() et int
yywrap().
.SUFFIXES:.fl
CC=gcc
CFLAGS=-g
LEX=flex
LEXLIBRARY=-lfl
.l: # avec la librairie LEX
@echo debut $(LEX)-compil : $<
$(LEX) $<
@echo debut compil c avec edition de liens de lex.yy.c
$(CC) $(CFLAGS) -o $* lex.yy.c $(LEXLIBRARY)
@echo fin $(LEX)-compil de : $<
@echo Vous pouvez executer : $*
.fl: # sans librairie (seulement flex) -> main et yywrap
@echo debut flex-compil : $<
flex $<
@echo debut compil c avec edition de liens de lex.yy.c
2.6. ALGORITHMIQUE 15
2.6 Algorithmique
Nous allons étudier les différents algorithmes utilisés par Flex pour construire “l’automate” déterministe codé en
C.
2.6.2 Déterminisation
On va écrire l’algorithme 5 de déterminisation d’un AFN N = (V, E, D, A, T ) ; l’idée consiste à fusionner l’ensemble
des états où l’AFN peut être à un “instant” donné en un seul état de l’AFD D = (V, DE, {d}, DA, DT ). Pour cela,
un état de DE sera modélisé dans l’algo. par un ensemble d’états de E. Il reste à la fin de l’algorithme 5 à numéroter
ces ensembles. L’Epsilon fermeture d’un ensemble d’états consiste à effectuer la fermeture réflexo-transitive par des
epsilon transitions depuis ces états.
A tout chemin menant d’un état initial à un état final de N, donc à tout mot de L(N), correspond un chemin de
d à un état final dans D. De plus, pour un chemin menant à un état final, l’état {. . . en+1 . . .} est final (Voir dans
l’algorithme 5 : DA = {Y ∈ DE/Y ∩ A 6= ∅}).
Remarquons que cette déterminisation permet de supprimer tous les chemins inaccessibles.
2.6. ALGORITHMIQUE 17
DA = {Y ∈ DE/Y ∩ A 6= ∅} // les états finaux de B sont ceux qui contiennent au moins un état final de N ;
numéroter les états de DE et substituer ces numéros dans DE,DA,DT ;
Exemple 8
Déterminisons l’AFN N suivant : N = {{a, b}, {1..4}, {1, 2}, {3, 4}, {1a3, 1a4, 2a3, 2b4}}
traçons l’algorithme :
DE = {{1, 2}∗};
x = a; X = {3, 4}; DE = {{1, 2}∗, {3, 4}}; DT = {({1, 2}a{3, 4})}
x = b; X = {4}; DE = {{1, 2}∗, {3, 4}, {4}}; DT = {({1, 2}a{3, 4}), ({1, 2}b{4})}
DE = {{1, 2}∗, {3, 4}∗, {4}};
x = a puis b; X = ∅
DE = {{1, 2}∗, {3, 4}∗, {4}∗};
x = a puis b; X = ∅
DA = {{3, 4}, {4}}
numérotation : {1, 2} → 1; {3, 4} → 2; {4} → 3; D = {{a, b}, {1..3}, {1}, {2, 3}, {1a2, 1b3}}.
2.6.3 Minimisation
Rappelons que la forme canonique d’un langage régulier est son AFD minimal. Etudions l’algorithme 6 de mini-
misation d’un AFD B = (V, E, {d}, A, T ). On suppose en entrée un AFD complet en ajoutant si nécessaire un état
puits. On va construire incrémentalement une suite de partitions Pi , composées de classes d’états. On dit que 2 états
i, j d’une même classe C sont distinguables par un symbole x ∈ V ssi la reconnaissance de x n’aboutit pas pour ces
deux états à la même classe de la partition courante. On va partitionner les états de l’automate en classes d’états
distinguables les unes par rapport aux autres puis ces classes représenteront les états du nouvel AFD Minimal M.
Remarquons qu’un état d’arrivée de M ne contient que des états d’arrivée de B à cause de la partition initiale.
Exemple 9
Soit un AFD complet :
B = ({a, b}, [1, 6], {1}, {3, 4, 5}, {1a2, 1b3, 2a2, 2b3, 3a4, 3b6, 4a5, 4b6, 5a5, 5b6, 6a6, 6b6})
On obtient la partition initiale : P0 = {{3, 4, 5}, {1, 2, 6}}. La classe {3, 4, 5} n’est pas distinguable ni par a (classe
{3, 4, 5}), ni par b (classe {1, 2, 6}). Par contre, la classe {1, 2, 6} se distingue sur b. Par conséquent :
Il ne reste plus qu’à supprimer la classe {6} qui est un puits non final pour obtenir l’AFD minimal :
Exercice 1 Soit l’expression régulière (a|bc)∗. Calculer l’AFDM correspondant en passant par la construction de
Thompson.
Chapitre 3
Analyse syntaxique
L’analyse syntaxique du programme source doit vérifier que celui-ci est bien un mot du langage de programmation.
Pour cela, la grammaire du langage est utilisée. Cette grammaire G = (VT , VN , R, S) est algébrique (insensible au
contexte). Toutes les règles de R sont donc de la forme : X → α avec X ∈ VN et α ∈ (VT ∪ VN )∗ . De plus, G doit être
non ambigüe afin d’éviter différentes sémantiques pour un même programme. Ainsi, il existe une unique dérivation
gauche depuis l’axiome S de la grammaire et conduisant au programme. C’est-à-dire qu’il existe un unique arbre de
dérivation dont la frontière soit le programme. Cette analyse peut se faire selon deux approches :
— l’analyse syntaxique descendante consiste à étudier l’unique dérivation gauche possible en partant de l’axiome
et en allant vers le programme. L’arbre de dérivation est construit (ou pas) depuis la racine S vers les feuilles.
— L’analyse syntaxique ascendante consiste, au contraire, à partir du programme et à remonter vers l’axiome S.
L’arbre de dérivation est construit (ou pas) depuis les feuilles vers la racine S.
De plus, la phase d’analyse syntaxique peut générer selon les cas :
— un résultat booléen indiquant la correction syntaxique. C’est le cas des vérificateurs syntaxiques tels que lint,
qui est un vérificateur pour le C.
— Un arbre syntaxique représentant le programme. Celui-ci est soit un arbre de dérivation (arbre complet), soit
un arbre abstrait (arbre simplifié). Cet arbre servira ensuite pour l’analyse sémantique puis la synthèse de la
cible.
— Le programme cible directement compilé par la phase d’analyse syntaxique. On parle de traduction dirigé par
la syntaxe. Cette traduction utilise fréquemment des grammaires attribuées.
— Le résultat de l’évaluation du programme source. C’est le cas des interpréteurs de programme et des évaluateurs
d’expressions (calculette).
Exemple 10
Soit la grammaire d’expressions arithmétiques GE = ({0, 1, . . . , 9, +, ∗, (, )}, {E}, R, E) avec les règles de R suivantes :
E → E + E|E ∗ E|(E)|0|1| . . . |9
Cette grammaire GE étant ambigüe, on écrit une grammaire équivalente non ambigüe selon le schéma Expression
Terme Facteur (ou ETF) :
— une expression est quelconque, par exemple 1+2*3+4 ;
— un terme est un élément d’une somme : dans l’exemple précédent, 1, 2*3 et 4 sont trois termes ;
19
20 CHAPITRE 3. ANALYSE SYNTAXIQUE
— un facteur est un élément d’un produit : dans l’exemple précédent, 2 et 3 sont des facteurs du produit 2*3.
GET F = ({0, 1, . . . , 9, +, ∗, (, )}, {E, T, F }, R, E) avec les règles de R suivantes :
E → E + T |T
T → T ∗ F |F
F → (E)|0|1| . . . |9
Cette grammaire GET F n’est pas ambiguë : pour un même niveau de parenthésage, les opérateurs + doivent étre
tous générés avant de générer un opérateur *. GET F étant récursive à gauche, on écrit une grammaire équivalente non
récursive à gauche GEN R = ({0, 1, . . . , 9, +, ∗, (, )}, {E, R, T, S, F }, X, E) avec les règles de X suivantes :
E → TR
R → +T R|ε
T → FS
S → ∗F S|ε
F → (E)|0|1| . . . |9
Enfin, il reste à écrire un vérificateur (reconnaisseur) syntaxique récursif utilisant un jeton de prédiction. Le
programme C suivant effectue cette vérification syntaxique en calquant la structure de ses fonctions sur la grammaire
GEN R .
void E(void){
T(); /* regle : E->TR */
R();
}
void R(void){
if (jeton==’+’) { /* regle : R->+TR */
AVANCER
T();
R();
}
else ; /* regle : R->epsilon */
}
void T(void){
F();
S(); /* regle : T->FS */
}
void S(void){
3.2. ANALYSE DESCENDANTE PAR AUTOMATE À PILE 21
>analdesc
1+2*3+(4+(5*(2+(1)+2)*3))<Ctrl>-<D>
Mot reconnu
>analdesc
1+2*4)+5<Ctrl>-<D>
Mot non reconnu : erreur de syntaxe au caractère numéro 6
Exercice 2 Ecrire un vérificateur syntaxique pour le langage de Dyck à un couple de parenthèses : S → SS|aSb|ε
Exemple 11
Un exemple simple de fonctionnement d’une analyse descendante à l’aide d’un automate à pile consiste à étudier
une grammaire de Dyck à un couple de parenthèses. Soit la grammaire GD = ({a, b}, {S}, R, S) avec les règles de R
suivantes :
S → aSbS|ε
indice 1a 1a 2b 2b 3a 3a 4a 4a 5b 5b 6a 6a 7b 7b 8b 8b 9$ 9$
a a
S S S S
a a b b b b b b
S S S S S S S S S S S S
b b b b b b b b b b b b b b
S S S S S S S S S S S S S S S S S
$ $ $ $ $ $ $ $ $ $ $ $ $ $ $ $ $ $
Remarquons encore une fois que les empilements de partie droite de règle se font à l’envers, c’est-à-dire de droite à
gauche.
3.2.3 Algorithmique
La grammaire doit posséder certaines propriétés de forme de ses règles afin de permettre l’analyse descendante.
Nous allons examiner les différentes transformations de règles susceptibles de mettre une grammaire G = (VT , VN , R, S)
quelconque en “bonne forme”, c’est-à-dire non récursive à gauche, non ambiguë et factorisée ! Attention, la désambi-
guation d’une grammaire étant non décidable, celle-ci devra être réalisée par une méthode ad hoc.
3.2. ANALYSE DESCENDANTE PAR AUTOMATE À PILE 23
On calcule les fermetures transitives des substituables : F T S(X1 ) = {X1 , X2 , X3 }, F T S(X2 ) = {X1 , X2 , X3 },
F T S(X3 ) = ∅. On obtient donc un nouvel ensemble de règles sans cycle RSC :
X1 → a|b|X3
X2 → a|b|X3
X3 → bX1 |X2 a
Remarquons que X1 et X2 peuvent être remplacés par X1 qui les représente tous deux. Ce qui donne :
X1 → a|b|X3
X3 → bX1 |X1 a
L’algorithme 9 crée un nouveau symbole RX (Reste de X), pour remplacer la récursivité à gauche par une récursivité
à droite sur RX . Remarquons que RX possède une ε-production donc est effaçable. La correction de l’algorithme, c’est-
à-dire l’équivalence des deux ensembles de productions P et P’, se démontre par une double récurrence sur i et j.
Exemple 14
Soit la grammaire d’expressions arithmétiques GE = ({0, 1, . . . , 9, +, ∗, (, )}, {E}, P, E) avec les règles de P suivantes :
E → E + E|E ∗ E|(E)|0|1| . . . |9
3.2. ANALYSE DESCENDANTE PAR AUTOMATE À PILE 25
Après application de l’algorithme 9, on obtient la grammaire suivante : GEN RI = ({0, 1, . . . , 9, +, ∗, (, )}, {E, RE }, P 0 , E)
avec les règles de P 0 suivantes :
Remarquons que GEN RI n’est plus récursive à gauche, mais elle reste ambiguë.
Exercice 3 Soit la grammaire GET F = ({0, 1, . . . , 9, +, ∗, (, )}, {E, T, F }, R, E) avec les règles de R suivantes :
E → E + T |T
T → T ∗ F |F
F → (E)|0|1| . . . |9
Dans certains cas, la suppression de la récursivité à gauche immédiate ne suffit pas car il peut subsister des
récursivités plus complexes : dans les productions X1 → X2 a|a, X2 → X1 b|b il n’y pas de récursivité à gauche
immédiate mais il y a de la récursivité à gauche !
La preuve de la correction de l’algorithme tient en ce qu’à la fin, il est impossible d’avoir une production de la
forme Xi → Xj α telle que i ≥ j.
Remarquons qu’il est toujours possible mais pas toujours nécessaire, en analyse descendante, de transformer la
grammaire initiale de la façon suivante :
1. suppression des ε-productions,
2. suppression des cycles,
3. suppression des récursivités à gauche immédiates,
4. suppression des récursivités à gauche.
La seule propriété à respecter est la non récursivité à gauche. Le moyen par lequel on obtient cette propriété
est indifférent. Remarquons qu’après la dérécursivation, on obtient souvent des grammaires ayant des ε-productions.
Ainsi, dans l’exemple 10, la grammaire GEN R est non récursive à gauche et contient des ε-productions. Ceci n’est pas
génant. En effet, ces productions ne peuvent en aucun cas impliquer une récursivité à gauche d’un non terminal.
Exemple 15
Soit la grammaire G = ({a, b, d}, {X1 , X2 , X3 }, P, X1 ) avec les règles de P suivantes :
X1 → X2 a|d
X2 → X3 a|X1 b
X3 → X1 a
26 CHAPITRE 3. ANALYSE SYNTAXIQUE
Après application de l’algorithme 10, on obtient la grammaire suivante G0 = ({a, b, d}, {X1 , X2 , R2 , X3 , R3 }, P 0 , X1 )
avec les règles de P 0 suivantes :
X1 → X2 a|d
X2 → X3 aR2 |dbR2
R2 → ε|abR2
X3 → dbR2 aaR3 |daR3
R3 → ε|aR2 aaR3
Factorisation à gauche
Si plusieurs parties droites de X-productions ont même préfixe, la prédiction de la règle à choisir est retardée
jusqu’à ce qu’un jeton permette de déterminer la “bonne” règle. Il faudra donc pouvoir lire plusieurs jetons en avance.
La factorisation des parties droites est destinée à réduire à 1 ce nombre de jetons de prévision.
Exemple 16
Soit la grammaire du “if then else” G = ({i, t, e, a, b}, {S, E}, R, S) avec les règles de R suivantes :
S → iEtS|iEtSeS|a
E → b
Après application de l’algorithme 11, on obtient la grammaire : GF = ({i, t, e, a, b}, {S, S 0 , E}, RF , S) avec les règles
de RF suivantes :
S → iEtSS 0 |a
S0 → ε|eS
E → b
Remarquons que cette grammaire factorisée reste ambiguë, ce qui posera problème à l’analyse.
Premiers
La fonction premiers est nécessaire à la construction de la table d’analyse qu’utilise l’automate à pile. Elle retourne
un ensemble de terminaux (jetons). premiers suppose une grammaire non récursive à gauche mais pouvant admettre
des ε-productions.
La fonction premiers(α) retourne l’ensemble des terminaux qui débute un mot dérivant de α. Si α est effaçable
alors ε fait partie de ses premiers. Pour calculer premiers(α), il faut commencer par calculer premiers(X ), quel
que soit X un symbole de V. L’algorithme 12 réalise cette fonction.
L’algorithme 12 est trivial pour les terminaux. Pour les non terminaux, il consiste à accumuler les premiers(Yi )
tant que Yi−1 est effaçable. ε n’est ajouté que dans le cas ou une partie droite de production est entièrement effaçable.
Exemple 17
Soit la grammaire non récursive à gauche GEN R = ({0, 1, . . . , 9, +, ∗, (, )}, {E, R, T, S, F }, X, E) avec les règles de X
suivantes :
E → TR
3.2. ANALYSE DESCENDANTE PAR AUTOMATE À PILE 27
Algorithme 12 : premiers(X)
Données : X ∈ V un symbole de VT ∪ VN , et une grammaire non récursive à gauche G = (VT , VN , R, S)
Résultat : Resultat ⊆ VT ∪ {ε} un ensemble de terminaux
si X ∈ VT alors
retourner {X}
sinon
Resultat = ∅ // initialisation
pour chaque production X → Y1 Y2 . . . Yk α telle que Yi ∈ VN et α ∈ {ε} ∪ VT • V ∗ faire
si k = 0 et α = ε alors
Resultat = Resultat ∪ {ε} // ε-production
sinon
si k = 0 alors
Resultat = Resultat ∪ {α[1]}
sinon
Resultat = Resultat ∪ (premiers(Y1 ) − {ε}) // non réc. gauche
i=1
tant que i ≤ k et Yi est effaçable faire
i=i+1
Resultat = Resultat ∪ (premiers(Yi ) − {ε}) // non réc. gauche
si i = k + 1 alors
si |α| = 0 alors
Resultat = Resultat ∪ {ε} // tous les Yi s’effacent
sinon
Resultat = Resultat ∪ {α[1]}
retourner Resultat
R → +T R|ε
T → FS
S → ∗F S|ε
F → (E)|0|1| . . . |9
On obtient par l’application de l’algorithme 12 :
premiers(F ) = {(, 0, 1, . . . , 9}
premiers(S ) = {∗, ε}
premiers(T ) = premiers(F )
premiers(R) = {+, ε}
premiers(E ) = premiers(F )
Remarquons la récursivité de l’algorithme et la terminaison de celui-ci uniquement si la grammaire est non récursive
à gauche. Cette propriété reste fondamentale pour le calcul des premiers(α) qui fait appel aux premiers(X).
L’algorithme 13 calcule justement ces premiers(α).
Suivants
L’algorithme 14 est nécessaire à la construction de la table d’analyse qu’utilise l’automate à pile. Il utilise une gram-
maire G et calcule un tableau d’ensembles de terminaux, et éventuellement $ le symbole de fin d’entrée. Chaque case du
tableau est associé à un non terminal de G. Son contenu est l’ensemble des terminaux pouvant suivre immédiatement
∗
ce symbole non terminal Xi de G dans un mot dérivant de l’axiome : T abSuivants[Xi ] = {x ∈ VT ∪ {$}/S ⇒ αXi xβ}.
L’algorithme 14 calcule ce tableau T abSuivants[Xi ].
Exemple 18
Soit la grammaire non récursive à gauche GEN R de l’exemple 17. On obtient par l’application de l’algorithme 14 :
T abSuivants[E] = {$, )}
T abSuivants[T ] = {+, $, )}
T abSuivants[R] = {$, )}
T abSuivants[F ] = {∗, +, $, )}
T abSuivants[S] = {+, $, )}
28 CHAPITRE 3. ANALYSE SYNTAXIQUE
Algorithme 13 : premiers(α)
Données : α = Y1 Y2 . . . Yk avec Yi ∈ V , ainsi qu’une grammaire non récursive à gauche G = (VT , VN , R, S)
Résultat : Resultat ⊆ VT ∪ {ε} un ensemble de terminaux
si α = ε alors
retourner {ε}
sinon
Resultat = ∅ // initialisation
Resultat = Resultat ∪ (premiers(Y1 ) − {ε})
i=1
tant que i ≤ k et ε ∈ premiers(Yi ) faire
i=i+1
Resultat = Resultat ∪ (premiers(Yi )−{ε}) // non réc. gauche
si i = k + 1 alors
Resultat = Resultat ∪ {ε} // tous les Yi s’effacent
retourner Resultat
Algorithme 14 : Suivants
Données : G = (VT , VN = {X1 , X2 , . . . , Xn }, R, X1 ), une grammaire
Résultat : un tableau T abSuivants[Xi ] d’ensembles de terminaux {x1 , x2 , . . . , xm } ⊆ (VT ∪ {$})
T abSuivants[X1 ] = {$} // initialisation pour l’axiome
pour i=2 à n faire
T abSuivants[Xi ] = ∅ // initialisation
répéter
stable=vrai // booléen testant la stabilité du tableau
pour chaque production Y → γ de R faire
pour chaque non terminal X de γ : Y → αXβ avec γ = αXβ faire
si β = ε alors
si T abSuivants[Y ] 6⊆ T abSuivants[X] alors
stable=faux
T abSuivants[X] = T abSuivants[X] ∪ T abSuivants[Y ]
sinon
si premiers(β) − {ε} 6⊆ T abSuivants[X] alors
stable=faux
T abSuivants[X] = T abSuivants[X] ∪ (premiers(β) − {ε})
si ε ∈ premiers(β) // β est effaçable alors
si T abSuivants[Y ] 6⊆ T abSuivants[X] alors
stable=faux
T abSuivants[X] = T abSuivants[X] ∪ T abSuivants[Y ]
jusqu’à stable;
3.2. ANALYSE DESCENDANTE PAR AUTOMATE À PILE 29
Exemple 19
Reprenons l’exemple 11 de la grammaire de Dyck à un couple de parenthèses. Soit la grammaire GD = ({a, b}, {S}, R =
{S → aSbS|ε}, S). La première règle S → aSbS ne pose aucun problème car premiers(aSbS ) = a, donc M [S, a] =
S → aSbS. Quant à la seconde production S → ε, elle génère le calcul de T abSuivants[S] = {b, $}. On obtient donc
la table d’analyse suivante :
a b $
S S → aSbS S→ε S→ε
Exemple 20
Soit la grammaire non récursive à gauche GEN R = ({0, 1, . . . , 9, +, ∗, (, )}, {E, R, T, S, F }, X, E) avec les règles de X
suivantes :
E → TR
R → +T R|ε
T → FS
S → ∗F S|ε
F → (E)|0|1| . . . |9
Il nous faut rappeler les premiers() des non terminaux débutant des parties droites :
premiers(F ) = {(, 0, 1, . . . , 9}
premiers(T ) = premiers(F )
Il nous faut également rappeler les suivants des non terminaux effaçables :
T abSuivants[R] = {$, )}
T abSuivants[S] = {+, $, )}
0|1| . . . |9 ( ) + ∗ $
E E → TR E → TR
R R → ε R → +T R R→ε
T T → FS T → FS
S S→ε S→ε S → ∗F S S→ε
F F → 0|1| . . . |9 F → (E)
30 CHAPITRE 3. ANALYSE SYNTAXIQUE
Si on choisit de placer un ensemble de productions et pas seulement une production, dans l’algorithme 15, c’est
pour permettre à l’utilisateur de désambiguer l’analyse de certaines grammaires ambiguës en choisissant la règle à
appliquer parmi celles qui sont proposées. L’exemple suivant illustre ce problème.
Exemple 21
Soit la grammaire du “if then else” après factorisation : GIF = ({i, t, e, a, b}, {S, S 0 , E}, RIF , S) avec les règles de RIF
suivantes :
S → iEtSS 0 |a
S0 → ε|eS
E → b
premiers(S ) = {i, a}
premiers(S’ ) = {e, ε}
premiers(E ) = {b}
Il nous faut également rappeler les suivants des non terminaux effaçables :
T abSuivants[S] = {e, $}
0
T abSuivants[S ] = {e, $}
T abSuivants[E] = {t}
Dans cette table, l’entrée M [S 0 , e] contient deux productions possibles. Il faut, dans ce cas, choisir de conserver la
production S 0 → eS pour deux raisons. D’abord, parce qu’en l’absence de cette production, la partie “else” ne serait
jamais reconnu ! Ensuite, parce que que l’ambiguïté de la grammaire (à quel “if” associer le “else”) est ainsi supprimée
dans l’analyseur. En effet, la partie “else” sera toujours associée syntaxiquement au “if” le plus proche, ce qui correspond
à la sémantique choisie par tous les langages de programmation.
Théorème 11 Aucune grammaire ambiguë et aucune grammaire récursive à gauche n’est LL(1).
Théorème 12 Une grammaire G est LL(1) si et seulement si les conditions suivantes sont respectées. Quelle que soit
X → α|β, deux productions de G :
— il n’existe pas deux dérivations de α et β ayant un préfixe commun terminal ;
— une partie droite seulement, α ou bien β, peut s’effacer ;
— si α peut s’effacer, alors β ne dérive pas en un mot ayant un préfixe commun terminal avec suivants(X).
21 génère une table d’analyse ayant un conflit. On peut déterminiser cette table en réussissant à reconnaître le même
langage. Malheureusement, ce problème du choix est indécidable et nécessite donc une réflexion ad hoc. Dans l’exemple
précédent de G2 , le choix de l’une ou de l’autre des productions de S à privilégier aboutit à un langage reconnu réduit
de moitié !
D’un point de vue plus pratique, le problème principal des grammaires LL(1) résulte dans le fait qu’elles sont
souvent obtenues par de multiples transformations qui les rendent difficilement lisibles pour le concepteur du langage.
Aussi, les actions sémantiques qu’il faut associer à ces règles deviennent difficiles à mettre en oeuvre.
3.3.1 Un exemple
Soit la grammaire ambiguë d’expressions booléennes GB == ({0, 1, &, |, !, (, )}, {E}, R, E) avec les règles de R
suivantes :
E → (E)|E 0 |0 E|E&E|!E|0|1
On va construire un vérificateur syntaxique, en utilisant yacc, reconnaissant les mots du langage généré par cette
grammaire.
Exemple 22
Voici le source yacc obtenu :
%{ /* veriflog.y */
#include <stdio.h>
int yylex(void); void yyerror(char *s);
%}
%%
expr : ’(’ expr ’)’
{}
| expr ’|’ expr
{}
| expr ’&’ expr
{}
| ’!’ expr
{}
| ’0’
{}
| ’1’
{}
;
%% /* debut des fonctions C */
int yylex(void) { /* analyseur lexical filtrant les blancs */
int c;
while(((c=getchar())==’ ’) || (c==’\t’))
;
return (c);
}
void yyerror(char *s) { /* appelée par yyparse sur erreur de syntaxe */
fprintf(stderr,"%s\n",s);
}
int main(void){ /* fonction principale */
if (!yyparse()) /* appel à l’analyseur généré par yacc */
printf("\nExpression reconnue\n");
32 CHAPITRE 3. ANALYSE SYNTAXIQUE
else
printf("\nExpression non reconnue\n");
return 0;
}
Après compilation bison, bison -y veriflog.y, puis compilation C et éditions de liens gcc -o veriflog y.tab.c,
il ne reste plus qu’à lancer l’exécutable veriflog obtenu :
1&0|((0)|0|1)
Expression reconnue
1&0|((0)|0|1|a)parse error
L’analyseur syntaxique généré tente, de reconnaitre un mot du langage défini par la grammaire. Il exécute les
instructions correspondantes à chaque règle reconnue. Dans cette exemple, il n’y a aucune action associée aux règles.
L’analyseur termine sur la fin de fichier (EOF) de l’entrée standard (CTRL-D pour le terminal).
Au cœur du source C y.tab.c généré par bison, la fonction C : int yyparse() d’analyse syntaxique permet de
retourner la valeur 1 en cas d’erreur syntaxique, 0 sinon. La fonction principale : int main() appelle yyparse() qui
va appeler yylex() itérativement au fur et à mesure de la reconnaissance des règles de grammaires.
En cas d’erreur de syntaxe, yyparse() fait appel à yyerror(char *) pour informer l’utilisateur puis yyparse()
retourne 1.
L’option -y de bison permet de générer un fichier nommé y.tab.c, comme en yacc. Sans cette option, le fichier
généré se nommerait veriflog.tab.c.
Dans l’exemple précédent, on remplace les jetons non nommés ’0’ et ’1’ par ZERO et UN.
%token UN ZERO
%%
...
| ZERO
{}
| UN
{}
;
%% /* debut des fonctions C */
int yylex() { /* analyseur lexical filtrant les blancs */
int c;
while(((c=getchar())==’ ’) || (c==’\t’))
;
if (c==’0’)
return ZERO;
else
if (c==’1’)
return UN;
else
return (c);
}
Si l’on regarde le fichier y.tab.h après la commande bison -yd ..., on observe :
#define UN 258
#define ZERO 259
Rappelons que yylex() généré par lex retourne 0 en fin de fichier. Les caractères ascii ont un numéro de jeton égal
à leur code ascii ! Enfin, un jeton spécial error est réservé pour la gestion des erreurs.
Les symboles non terminaux sont conventionnellement écrits en minuscules (expr, statement, . . .).
list : /* epsilon-production */
| list ’,’ stat
;
Les différentes productions pourraient cependant être écrites séparément (l : ; l :l ’,’ s ;). La récursivité à gauche et à
droite est permise dans les règles yacc, cependant il est fortement recommandé d’écrire des grammaires récursives à
gauche pour optimiser le fonctionnement de l’analyseur.
typedef union {
int typeEntier;
float typeFlottant;
} YYSTYPE;
La variable globale yylval est l’attribut que yylex() peut affecter aux jetons. Ainsi, par exemple, toutes les
littéraux entiers seront associés au même jeton LITINT mais auront une valeur sémantique yylval.typeEntier dif-
férente correspondant à leur valeur. De même pour les littéraux flottants qui correspondront au jeton LITFLOT mais
qui différeront sur yylval.typeFlottant. La déclaration de yylval dans y.tab.h est de la forme : extern YYSTYPE
yylval;.
Actions
N’importe quelle instruction C peut apparaître dans un bloc d’actions. De plus, yacc admet des ations spécifiques
permettant d’utiliser les attributs. L’attribut associé à la partie gauche de la règle de production courante est nommé
$$, tandis que l’attribut du nième élément de la partie droite est nommé $n.
Exemple 23
Un exemple d’utilisation de ces attributs est l’amélioration du vérificateur de GB en un interpréteur d’expression
booléenne :
%{ /* interlog.y */
#include <stdio.h>
#define YYSTYPE int /* inutile */
int yylex(void); void yyerror(char *s);
%}
%%
ligne : expr ’\n’ {printf("\nRésultat : %d\n",$1);}
;
expr : ’(’ expr ’)’ {$$ = $2;}
| expr ’|’ expr {$$ = $1 || $3;}
| expr ’&’ expr {$$ = $1 && $3;}
| ’!’ expr {$$ = ! $2;}
| ’0’ {$$ = 0;}
| ’1’ {$$ = 1;}
;
%%
int yylex(void) {
int c;
while(((c=getchar())==’ ’) || (c==’\t’))
;
return c;
}
void yyerror(char *s) {
fprintf(stderr,"%s\n",s);
}
int main(void){
printf("Veuillez entrer une expression booléenne S.V.P. : ");
return yyparse();
}
Un exemple d’utilisation de cet interprète :
0|!0&1
Résultat : 1
!1&0
Résultat : 1
Le dernier résultat n’est pas cohérent en logique mais est le résultat de la non définition de priorité d’opérateur dans
notre source bison.
des symboles les précédant et avant la reconnaissance des symboles suivants. Attention, un bloc d’action intermédiaire
est comptabilisé comme un autre symbole dans la numérotation des attributs $$n. En effet, un bloc intermédiaire est
lui-même associé à un attribut $n correspondant à sa position dans la partie droite. A l’intérieur du bloc intermédiaire,
la valeur de l’attribut associé à ce bloc peut être défini en affectant l’attribut $$. Attention, $$ référence l’attribut
de bloc et non pas l’attribut de la partie gauche de règle ! Ce dernier ne peut être défini que par une action de fin de
règle. Le type d’un bloc intermédiaire ne peut qu’être explicitement donné lors de son utilisation par : $<typeBloc>$
ou $<typeBloc>n. Le typeBloc pouvant être n’importe lequel des types définis par YYSTYPE. Prenons l’exemple du
langage C, dans lequel un bloc d’instructions est composé de déclarations (optionnelles) suivies d’instructions, le tout
entre accolades :
Dans cet exemple, le symbole non terminal decls a un attribut référencé par $3.
Actions prédéfinies
$$ attribut du non terminal en partie gauche de règle ;
$n attribut associé au n ième composant de la partie droite ;
$<typeAutre>n permet de spécifier un autre type que le type par défaut du n ième composant ;
YYABORT retourne immédiatement de yyparse avec un résultat 1 (erreur) ;
YYACCEPT retourne immédiatement de yyparse avec un résultat nul 0 ;
YYBACKUP(jeton, valeurAttribut) dépile un jeton de l’automate . . .
yychar variable entière contenant le jeton de prévision courant ;
YYEMPTY valeur stockée dans yychar quand il n’existe pas de jeton de prévision ;
YYERROR provoque une erreur de syntaxe immédiate ;
YYRECOVERING variable valant 1 si on est dans une récupération d’erreur, 0 sinon ;
yyclearin supprime le le jeton de prévision courant ;
yyerrok force le retour de la récupération d’erreur vers l’état normal de l’analyseur syntaxique. Il faut être sur
d’être à un bon “endroit” du flot de jeton pour appeler cette fonction. Dans les interpréteurs ligne à ligne, un
bon endroit se situe après le retour ligne.
La partie déclaration
Le type YYSTYPE des attributs doit être défini par la déclaration %union :
%union {
int typeEntier;
float typeFlottant;
}
Les jetons nommés doivent être déclarés dans cette section ainsi que le type de leur attribut par une déclaration du
genre : %token <typeFlottant> LITTERALFLOTTANT. Il est inutile de spécifier le code numérique du jeton, car yacc
s’en charge, ce qui évite des erreurs de conflits.
En cas de types multiples des attributs, les symboles non terminaux doivent être tous typés par une déclaration :
%type <typeFlottant> nonterminal1 nonterminal2 ....
Par défaut, l’axiome de la grammaire est le premier non terminal rencontré dans la partie des règles. On peut
définir explicitement l’axiome par la déclaration : %start nonterminal.
La priorité des opérateurs, les uns par rapport aux autres, peut être définie simplement par l’ordre des définitions
des associativités des opérateurs, du moins prioritaire au plus prioritaire. Enfin, une priorité différente de celle de
l’opérateur en cours de reconnaissance peut être affectée à une partie droite de règle en ajoutant %prec JETONVIRTUEL
à la fin de la règle. Ainsi, l’opérateur obtiendra, pour cette règle la priorité (précédence) du JETONVIRTUEL qui aura
du être déclaré.
Exemple 24
%nonassoc ’<’ ’>’ EGAL DIFFERENT SUPEGAL INFEGAL
%left ’+’ ’-’
%left ’*’ ’/’
%right MOINSUNAIRE
%right ’^’
...
expr : ...
| expr ’-’ expr {/* priorité normale du moins binaire */}
| ’-’ expr %prec MOINSUNAIRE {/* priorité spéciale du moins unaire */}
Ce type de précédence variable pour le même jeton lexical est nécessaire lorsqu’un opérateur est utilisé dans des
emplois différents. On peut prendre comme autre exemple l’opérateur * du C++, utilisé pour la multiplication et le
déréférencement d’un pointeur : *ptrInt * 2.
L’automate à pile choisit l’opération Shift ou Reduce en comparant la priorité de la règle courante avec celle du
jeton de prévision. Si le jeton est plus prioritaire alors un Shift est effectué, sinon un Reduce est effectué. La priorité
d’une règle est la priorité de son jeton le plus à droite. Les jetons sans priorité explicite sont considérés comme ayant
une priorité minimale.
Débogage
Afin de déboguer l’analyseur syntaxique, il suffit de positionner la variable yacc prédéfinie yydebug à 1 avant
l’appel à yyparse() ou pendant son exécution .
Makefile
YACC=bison
YACCFLAGS=-ydtv
#-y a la yacc : y.tab.c; -d genere y.tab.h; -t debogage possible; -v verbose
.y:
@echo debut $(YACC)-compil : $<
$(YACC) $(YACCFLAGS) $<
@echo debut compil c avec edition de liens de y.tab.c
$(CC) $(CFLAGS) -o $* y.tab.c
@echo fin $(YACC)-compil de : $<
@echo Vous pouvez executer : $*
%{ /* calc.l */
#define YYSTYPE double /* ATTENTION AUX 2 MACROS dans lex et yacc */
#include "y.tab.h" /* JETONS crees par yacc et definition de yylval */
#include <stdlib.h>/* pour double atof(char *) */
#include <stdio.h>/* pour printf */
%}
chiffre ([0-9])
entier ({chiffre}+)
3.3. UN LANGAGE ET UN OUTIL POUR L’ANALYSE SYNTAXIQUE : YACC 37
%option noyywrap
/* pas de continuation sur un autre fichier */
%%
[ \t]+ {/* filtrer les blancs */}
{entier}|{entier}\.{chiffre}*|{chiffre}*\.{entier} {
/* laisser l’accolade à la ligne precedente */
yylval=atof(yytext);return (LITFLOT);
}
sin { return(SIN); }
cos { return(COS); }
exp { return(EXP); }
ln { return(LN); }
pi { return(PI); }
exit|quit { return (QUIT); }
aide|help|\? { return (HELP); }
.|\n { return yytext[0]; /* indispensable ! */}
%%
%{ /* calc.y */
#include <math.h>
int errSemantiq=0; /* vrai si erreur sémantique : */
#define DIVPAR0 1 /* division par 0 */
#define LOGNEG 2 /* logarithme d’un négatif */
#define YYSTYPE double
int yylex(void);void yyerror(char *s);
%}
/* définition des jetons */
%token LITFLOT SIN COS EXP LN PI QUIT HELP
/* traitement des priorités */
%left ’+’ ’-’
%left ’*’ ’/’ ’%’
%right MOINSUNAIRE
%right ’^’
%%
Exemple 25
%{
#include <iostream>
using namespace std;
%}
%skeleton "lalr1.cc"
%union{
int i;
}
%%
s : CHAR ’\n’ {cout<<endl<<"Vous avez tapé le char : "<<$1<<endl;}
;
%%
>mini
a
syntax error
Syntaxe incorrecte
Pour plus de détails, notamment pour l’utilisation de flex, nous allons étudier un second exemple permettant de
maintenir une table de variables entières. Ces variables seront affectées grâce à un interprèteur et seront mémorisées
dans une map C++. Suit un exemple de fonctionnement de l’interprète :
40 CHAPITRE 3. ANALYSE SYNTAXIQUE
Exemple 26
>affect
b=5
a=8
b=3
set
a --> 8
b --> 3
a=0
set
a --> 0
b --> 3
quit
Syntaxe correcte
Voici le source bison affect.y+ :
%{
#include <iostream>
#include <string>
#include <map>
using namespace std;
/* redéfinition du prototype de la fonction yylex qui devra être déclarée dans
le source flex et dans le source bison */
#define YY_DECL yy::parser::token_type yylex (yy::parser::semantic_type* pyylval)
%}
%skeleton "lalr1.cc"
%union{
int i;
string *ps;
}
%{
/* A DECLARER ABSOLUMENT APRES L’UNION (ne sera pas dans le y.tab.h)*/
YY_DECL;
/* tableau des affectations */
map <string, int>* tvar=new map<string, int>();
%}
%token END 0 "end of file"
%token SET QUIT
%token <ps> ID
%token <i> VALEUR
%%
liste : { /* epsilon */}
| liste ligne {}
;
ligne : SET ’\n’ {
map <string, int>::iterator j = tvar->begin(); // attribut de liste !
while (j != tvar->end()){
cout << j->first<<" --> "<<j->second << endl;
++j;
}
}
| ID ’=’ VALEUR ’\n’ {(*tvar)[*$1]=$3;}
| ’\n’ {}
| QUIT ’\n’ {return 0;}
;
%%
void yy::parser::error(yy::location const& loc, std::string const& s){
cout<<endl<<s<<endl;
}
int main(){
3.4. ANALYSE ASCENDANTE PAR AUTOMATE À PILE 41
Dans l’exemple 27 précédent, le mot 1+2+3 ne possède qu’un manche (E → 1,1). En effet, ni (E → 3,5), ni
(E → 2,3) est un manche car ni 1+2+E, ni 1+E+3 ne dérive de E par une dérivation droite. Par contre, E+E+3
possède deux manches : (E → E + E,1) et (E → 3,5). On peut donc choisir entre les deux réductions possibles. Dans
l’exemple 27, nous avions choisi de réduire sur la position la plus à gauche de façon à réduire dès qu’un manche est
situé sur la pile. On aurait pu empiler + puis E au dessus de E+E puis réduire par deux fois E+E en E. Nous avions
choisi de privilégier la réduction (Reduce) sur le décalage (Shift) dans ce conflit Shift/Reduce.
Malheureusement, l’identification du manche n’est pas toujours aussi simple que dans l’exemple 27. Il peut exister
d’autres types de conflits Reduce/Reduce lorsque deux manches sont réductibles. Pour limiter ces conflits d’action, la
table d’analyse ainsi que la pile vont utiliser des états entiers correspondant à la configuration courante, c’est-à-dire à
ce qui a été reconnu jusqu’alors.
Définition 5 La pile d’un analyseur LR est une structure Dernier Entré Premier Sorti (LIFO) de couples (s,e) où
s ∈ V ∪ {$} est un symbole et e ∈ N est un état entier. L’état courant de l’analyseur est l’état situé au sommet de la
pile.
Définition 6 La table d’analyse d’un analyseur LR est constitué d’une partie Action et d’une partie Successeur.
— La table d’action est un tableau à deux entrées : les différents états sur les lignes, les terminaux et $ sur les
colonnes. On note une case de cette table par Action[e, x]. Une action d’un analyseur LR peut être :
— Décaler (Shift) le symbole courant du flot d’entrée sur la pile (empiler) avec un état e. Cette action est
notée : Se.
— Réduire (Reduce) par une production X → α. Cela consiste à dépiler α (à l’envers) de la pile et à le remplacer
par X et l’état correspondant dans la table Successeur, c’est à dire Successeur[sommet(P ile)[2], X]. Cette
action est notée : R(X → α).
— Accepter le mot d’entrée et terminer l’analyse. Cette action est notée : Accepter.
— Générer un message d’erreur de syntaxe et terminer l’analyse. Cette action n’est pas notée explicitement :
toutes les cases vides de la table Action représentent des actions Erreur.
— La table des successeurs est un tableau à deux entrées : les différents états sur les lignes, les non terminaux sur
les colonnes. On note une case de cette table par Successeur[e, X]. Cette table ne sert qu’à indiquer le nouvel
état courant après une réduction. Là aussi, toutes les cases vides de la table Successeur représentent des erreurs.
Avant de voir les algorithmes de construction de ces tables, regardons le fonctionnement de l’analyseur. L’analyse
d’un mot du flot d’entrée est décrit dans l’algorithme 16.
Pour illustrer le fonctionnement de l’algorithme 16, prenons un exemple simple d’une grammaire de Dyck à un
couple de parenthèses.
Exemple 28
Soit la grammaire Gd = ({a, b}, {S}, R, S) avec les règles de R suivantes :
S → SaSb|ε
Action Successeur
a b $ S
0 R(S → ε) R(S → ε) R(S → ε) 1
1 S2 Accepter
2 R(S → ε) R(S → ε) R(S → ε) 3
3 S2 S4
4 R(S → SaSb) R(S → SaSb) R(S → SaSb)
3.4.2 Algorithmique
Nous allons décrire comment calculer les tables d’analyses pour des grammaires LR(1), c’est-à-dire avec un sym-
bole de prévision. Il existe plusieurs méthodes de construction dépendant de la complexité de la grammaire et de
l’efficacité de l’analyseur, notamment en ce qui concerne la taille des tables. La méthode SLR, “Simple LR”, permet de
construire très efficacement des tables d’analyse assez petites. Malheureusement, certaines constructions syntaxiques,
peu nombreuses dans les langages de programmation, ne peuvent être gérées par cette méthode. D’autres méthodes
existent, dont la méthode LALR de yacc, résolvant certains problèmes de SLR au prix d’une taille plus importante
des tables. Enfin, il existe une méthode dite canonique qui assure la reconnaissance de toute grammaire LR(1) mais à
un cout prohibitif.
Nous nous contenterons ici de décrire la méthode SLR en conseillant le livre [1] pour ceux qui souhaiteraient en
savoir plus.
Soit la grammaire Gd = ({a, b}, {S}, R = {S → SaSb|ε}, S). L’ensemble des items de G est Items(G) = {S →
.SaSb, S → S.aSb, S → Sa.Sb, S → SaS.b, S → SaSb., S → ε.}. Un item représente ce qui a déjà été reconnu (à
gauche du point) lors de l’analyse, et ce qu’il reste à reconnaitre (à droite du point) avant de pouvoir réduire. Avant
de construire les tables Action et Successeur, il faut calculer un automate fini déterministe (ou collection canonique),
c’est à dire un ensemble d’états reliés par des transitions. Chaque état représente un ensemble d’items correspondant
à une situation d’analyse. Ces états sont les états de l’analyseur LR.
Définition 8 Une grammaire augmentée G’ d’une grammaire G = (VT , VN , R, S) est obtenue par ajout d’un nouvel
axiome S’ et d’une production S 0 → S : G0 = (VT , VN ∪ {S 0 }, R ∪ {S 0 → S}, S 0 )
44 CHAPITRE 3. ANALYSE SYNTAXIQUE
L’ajout de ce “super-axiome” est motivé par l’obtention d’un état initial de l’AFD qui soit une source : on ne peut
revenir sur cet état initial. La construction de l’AFD utilise une fonction Fermeture() qui regroupe tous les items
auxquels on peut s’attendre dans un état donné. La fonction Fermeture() est décrite dans l’algorithme 17.
Le principe de l’algorithme 17 tient en ce que lorsqu’on s’attend à reconnaitre un non terminal X, il faut également
s’attendre à reconnaitre toute partie droite de production dont X est la partie gauche.
Exemple 29
Soit la grammaire de Dyck augmentée : G0 = ({a, b}, {S, S 0 }, {S → SaSb|ε, S 0 → S}, S 0 ). Calculons les fermetures
des ensembles d’items {S 0 → .S} et {S → Sa.Sb}. F ermeture({S 0 → .S}) = {S 0 → .S, S → .SaSb, S → ε.} et
F ermeture({S → Sa.Sb}) = {S → Sa.Sb, S → .SaSb, S → ε.}.
Pour construire l’AFD des états de l’analyseur, également appelée collection canonique des ensembles d’items
LR(0), il faut examiner toutes les transitions possibles d’un état (ensemble d’items) vers un autre par le déplacement
du “.” d’une position vers la droite. L’algorithme 18 décrit cette construction.
Remarquons que l’algorithme 18 ne calcule pas d’états d’arrivée de l’automate. En effet, cet automate ne permet
pas de reconnaitre un mot du langage analysé mais sert uniquement à décrire les transitions entre états. Chaque chemin
dans l’AFD correspond à un préfixe d’un mot dérivant de l’axiome. Ces préfixes, aussi appelé préfixes viables, sont
constitués de terminaux et de non terminaux. Ils représentent le contenu possible de la pile de l’automate à un
instant donné.
Exemple 30
Soit la grammaire de Dyck augmentée : G0 = ({a, b}, {S, S 0 }, {S → SaSb|ε, S 0 → S}, S 0 ). Calculons l’automate corres-
pondant : I0 = F ermeture({S 0 → .S}) = {S 0 → .S, S → .SaSb, S → ε.}
I1 = F ermeture({S 0 → .S, S → .SaSb}) = {S 0 → S., S → S.aSb}
T = {(I0 , S, I1 )}
I2 = F ermeture({S → Sa.Sb}) = {S → Sa.Sb, S → .SaSb, S → ε.}
T + = {(I0 , S, I1 ), (I1 , a, I2 )}
I3 = F ermeture({S → SaS.b, S → S.aSb}) = {S → SaS.b, S → S.aSb}
T + = {(I0 , S, I1 ), (I1 , a, I2 ), (I2 , S, I3 )}
I4 = F ermeture({S → SaSb.}) = {S → SaSb.}
I2 = F ermeture({S → Sa.Sb}) = {S → Sa.Sb, S → .SaSb, S → ε.}
T + = {(I0 , S, I1 ), (I1 , a, I2 ), (I2 , S, I3 ), (I3 , b, I4 ), (I3 , a, I2 ), }
3.4. ANALYSE ASCENDANTE PAR AUTOMATE À PILE 45
Dans cet exemple, les préfixes viables sont : ε, S, Sa, SaS, SaSb, SaSaSb, . . . , SaS(aS)n b. La question que l’on se
pose est de savoir quand un préfixe situé en pile doit être réduit. Définissons la notion d’item valide pour un préfixe
viable.
Définition 9 Un item X → β1 .β2 est valide pour un préfixe αβ1 d’un mot dérivant de l’axiome si et seulement s’il
∗ 1
existe une dérivation droite : S 0 ⇒d αXm ⇒d αβ1 β2 m avec m ∈ VT ∗ , X ∈ VN , αβ1 β2 ∈ V ∗ .
Remarquons que dans le cas où l’item X → β1 . est valide pour le préfixe αβ1 , alors on a un manche qu’il faut
réduire. Dans le cas où l’item X → β1 .β2 est valide et que β2 n’est pas vide, il faut décaler. La question est maintenant
de savoir quand un item est valide pour un préfixe donné.
Théorème 13 L’ensemble des items valides pour le préfixe viable αβ1 est l’ensemble des items atteint par un parcours
de l’AFD depuis l’état initial, le long du chemin étiqueté par αβ1 .
Exemple 31
Soit le préfixe viable SaS, les deux items valides sont S → SaS.b et S → S.aSb. On a donc les deux types de dérivations
1 1 1 ∗
droites possibles : S ⇒ SaSb ou bien S ⇒ SaSb ⇒ SaSaSb ⇒ SaSa . . . Remarquons que le symbole d’entrée suivant
(a ou b) permettra de choisir l’état suivant qui correspondra soit à une réduction par S → SaSb ou bien par S → ε.
Remarquons qu’une seule action Accepter existe qui correspond à la réduction S 0 → S de la grammaire aug-
mentée. Une case de la table Action peut contenir plusieurs actions ! On peut obtenir des conflits Shift/Reduce ou
Reduce/Reduce. Dans ce cas, la grammaire n’est pas SLR et il sera nécessaire d’utiliser un algorithme de construction
de table plus complexe.
Exemple 32
Pour appliquer l’algorithme 19 sur la grammaire de Dyck augmentée G0 = ({a, b}, {S, S 0 }, {S → SaSb|ε, S 0 → S}, S 0 ),
il nous faut calculer les suivants de S : T abSuivants[S] = {a, b, $}. On obtient alors la table suivante :
Action
a b $
0 R(S → ε) R(S → ε) R(S → ε)
1 S2 Erreur Accepter
2 R(S → ε) R(S → ε) R(S → ε)
3 S2 S4 Erreur
4 R(S → SaSb) R(S → SaSb) R(S → SaSb)
46 CHAPITRE 3. ANALYSE SYNTAXIQUE
Exemple 33
L’algorithme 20 sur la grammaire de Dyck augmentée G0 = ({a, b}, {S, S 0 }, {S → SaSb|ε, S 0 → S}, S 0 ) fournit la table
suivante :
Successeur
S
0 1
1 Erreur
2 3
3 Erreur
4 Erreur
Efficacité
Théorème 14 Une grammaire est LR(0) ou SLR si et seulement si sa table Action ne contient aucun conflit.
Théorème 15 Un langage est LR(0) ou SLR si et seulement s’il existe une grammaire SLR le générant.
Différentes grammaires SLR existant pour un même langage, on peut se préoccuper de la “meilleure” en terme d’effi-
cacité. Par exemple, nous avons souvent considérée la grammaire augmentée de Dyck suivante : Gg = ({a, b}, {S, S 0 }, {S →
SaSb|ε, S 0 → S}, S 0 ). Il existe une autre grammaire SLR engendrant le même langage : Gd = ({a, b}, {S, S 0 }, {S →
aSbS|ε, S 0 → S}, S 0 ).
Exercice 5 Construire les tables d’analyse SLR de Gd . Examiner le fonctionnement de l’analyseur sur le mot
abaababb$.
Après construction des tables SLR de cette seconde grammaire, on s’aperçoit qu’elles possèdent un état de plus, mais
surtout que la reconnaissance d’un mot nécessite une pile beaucoup plus importante. En effet, la première réduction
par S → aSbS ne peut avoir lieu que très tard par rapport à l’analyseur de la grammaire Gg . La raison principale de
cette inefficacité tient en ce que Gd est récursive à droite. Par conséquent, on préférera toujours, quand on a le choix,
utiliser des grammaires récursives à gauche en analyse ascendante.
Quel que soit le mot d’entrée, il commence par aa. La lecture du premier a produit un décalage, puis il existe trois
actions possibles : deux réductions différentes et un décalage ! En fait, dans ce cas il faudrait examiner la troisième
lettre pour choisir la bonne réduction ou le décalage. Cette grammaire n’est pas LR(1) mais LR(2), par conséquent la
méthode SLR ne peut rien (pas plus qu’aucune autre méthode LR(1)).
D’autres méthodes existent pour les grammaires LR(1). En particulier, la méthode LALR de yacc, ou bison,
qui construit automatiquement les tables d’analyse. L’option -v de bison permet notamment de visualiser les tables
d’analyse utilisées. Voici, par exemple, le fichier .output obtenu avec la grammaire Gg = ({a, b}, {S, S 0 }, {S →
SaSb|ε, S 0 → S}, S 0 ).
state 0
$default reduce using rule 2 (S)
S go to state 1
state 1
S -> S . ’a’ S ’b’ (rule 1)
$ go to state 5
’a’ shift, and go to state 2
state 2
S -> S ’a’ . S ’b’ (rule 1)
$default reduce using rule 2 (S)
S go to state 3
state 3
S -> S . ’a’ S ’b’ (rule 1)
S -> S ’a’ S . ’b’ (rule 1)
’a’ shift, and go to state 2
’b’ shift, and go to state 4
state 4
S -> S ’a’ S ’b’ . (rule 1)
$default reduce using rule 1 (S)
state 5
$ go to state 6
state 6
$default accept
On retrouve, à quelques détails près, les tables Action et Successeur obtenus dans les exemples 32 et 33.
Conflit Shift/Reduce
Que fait yacc lorsqu’il rencontre des conflits ? Sur conflit Shift/Reduce, yacc avantage toujours l’action Shift.
L’une des raisons historiques de ce choix concerne les “si alors sinon” imbriqués. Soit la grammaire suivante :
S → iEtS|iEtSeS|a
E → b
La compilation yacc fournit un analyseur privilégiant le décalage du “else” plutôt que la réduction du iEtS empilé.
Voici la partie descriptive fournie par yacc -v :
state 6
S -> ’i’ E ’t’ S . (rule 1)
S -> ’i’ E ’t’ S . ’e’ S (rule 2)
Conflit Reduce/Reduce
Dans un conflit Reduce/Reduce yacc choisit d’utiliser la première règle dans l’ordre de description de la grammaire
du source yacc. Il est extrèmement périlleux d’utiliser cette caractéristique dans un analyseur car l’ordre des règles de
production dans le source yacc peut souvent varier dans la phase de conception du langage.
48 CHAPITRE 3. ANALYSE SYNTAXIQUE
Conflits multiples
Un autre exemple de gestion des conflits dans yacc consiste à voir les tables obtenues pour la grammaire non LR(1)
G = ({a, b, c}, {S 0 , S, A, B}, {S 0 → S, S → Aaa|Bab|aac, A → a, B → a}, S).
state 1
S -> ’a’ . ’a’ ’c’ (rule 3)
A -> ’a’ . (rule 4)
B -> ’a’ . (rule 5)
L’action Shift a bien été privilégiée par rapport aux deux reduce possibles. Yacc parvient donc à fournir un analyseur
pour nombre de grammaires mais attention, cet analyseur ne reconnait que le mot aac, ce qui n’est pas correct vis
à vis de la grammaire (ni aab, ni aaa ne sont reconnus). Pour finir, remarquons que toutes les grammaires LR(1),
c’est-à-dire nécessitant un seul jeton de prévision, ne sont pas analysable avec la méthode LALR.
Exercice 6 Soit la grammaire d’expression G = ({a, −, /, (, )}, {E}, {E → a|(E)|E − E|E/E}, S) dans laquelle a peut
être considéré comme un littéral entier.
1. Dessiner la collection canonique de G ;
2. Indiquer les suivants de E.
3. Construire la table d’analyse SLR de G ;
4. Indiquer les conflits obtenus et la manière dont bison les résoud ;
5. Donner les règles de priorité et d’associativité afin d’obtenir un automate à pile correct (division prioritaire par
rapport à la soustraction et toutes deux associatives à gauche).
6. Indiquer les modifications de la table.
Chapitre 4
Analyse sémantique
Dans ce chapitre, nous allons étudier une certain nombre de techniques concernant la gestion des tables des
symboles, la traduction dirigée par la syntaxe, le contrôle de type, . . .. Nous supposerons à chaque fois que nous
utilisons yacc pour l’analyse syntaxique. Il existe d’autres techniques, notamment liées à l’analyse descendante, mais
nous ne les aborderons pas ici et nous conseillons l’ouvrage [1] pour leur étude.
Dans les langages structurés en blocs, le plus simple est d’associer une table des symboles à chaque bloc. Lorsque
le même identificateur désigne différents objets, il sera nécessaire de construire une entrée différente pour chacun de
ces objets afin de pouvoir les renseigner. L’identifiant d’une entrée de la table des symboles deviendra donc l’agrégat
du nom de l’objet et de sa catégorie. Un exemple classique en C++ ou en Java concerne les méthodes surchargées.
L’identifiant d’une méthode est composé du nom de la méthode et de la liste ordonnée des types des paramètres de
cette méthode. Bien entendu, ces actions seront le plus souvent effectuées durant l’analyse syntaxique, car l’analyseur
lexical n’a pas les moyens de reconnaître les blocs ou les différentes catégories d’objets.
49
50 CHAPITRE 4. ANALYSE SÉMANTIQUE
Il est souhaitable que MAXHASH soit un entier premier. Chaque entrée de la table des symboles est alors un
élément, une cellule, de la liste. L’analyse lexicale retournera donc un jeton IDENTIFICATEUR ainsi qu’une valeur
sémantique (yylval de lex/yacc) sous forme de chaîne de caractères. Ensuite, l’analyseur syntaxique ajoutera une entrée
dans la table des symboles. Cette entrée contiendra au moins :
— une chaîne de caractères représentant le symbole,
— la catégorie syntaxique du symbole (variable, attribut, méthode, . . .),
— des propriétés spécifiques à chaque catégorie de symbole.
La variable numeroLigne est une variable globale définie par le source yacc et mise à jour par le source lex.
De même, lors de l’exécution (run-time), l’appel à une procédure du langage cible ayant la même sémantique est
la plus simple façon de gérer les erreurs d’exécution. Voici un exemple en C :
+
2
*
+
2
1
3
La construction de l’arbre abstrait associé au remplissage de la table des symboles est généralement effectuée au cours
de l’analyse syntaxique. Chaque noeud de l’arbre abstrait doit contenir :
— la catégorie syntaxique du noeud,
— un lien vers chacun de ses fils,
— un lien vers son père,
— éventuellement des informations complémentaires : entrée de la table des symboles, . . .
Une fois l’arbre abstrait construit, le compilateur pourra le parcourir à des fins d’analyse sémantique, d’optimisation,
de génération de code.
A chaque règle de production, correspond une ou plusieurs règles sémantiques indiquant le mode de calcul de
certains des attributs. Bien entendu, le calcul de certains attributs dépend d’autres. Lorsque la règle est récursive,
même symbole en partie gauche et droite de production, on indice les occurrences de droite pour les distinguer de
l’occurrence de gauche.
Définition 10 Dans une grammaire attribuée, une règle sémantique associé à une règle de production indique le mode
de calcul d’un attribut d’une occurence de symbole présent dans la production. Soit la production x0 → x1 x2 . . . xn ,
une règle sémantique s’écrit toujours : xi .val = f (xi1 .ai1 , xi2 .ai2 , . . . , xik .aik ).
Par exemple, le tableau suivant indique le calcul des attributs de la grammaire GET F . Soit la grammaire attri-
buée GET F = ({0, 1, . . . , 9, +, ∗, (, )}, {E{val}, T {val}, F {val}}, R, E) avec les règles de production R, et les règles
sémantiques suivantes calculant des valeurs entières (val) :
S → E \n Afficher(E.val)
int yylex(void){
int c=getchar();while(c==’ ’||c==’\t’)c=getchar(); /* filtrage */
if (isdigit(c)){
yylval=c-’0’;return CHIFFRE;
}
else return c;
}
void yyerror(char *s) {fprintf(stderr,"%s\n",s);}
int main(void){yydebug=0; return yyparse();}
Remarquons que dans yacc, le type par défaut des attributs est entier mais qu’on peut redéfinir YYSTYPE, soit par
un #define, soit par un %union{}. Si le type d’attribut est unique, alors il n’est pas nécessaire d’indiquer le type des
attributs des terminaux et des non terminaux. Sinon, il faut utiliser les définitions yacc %token<typeDeLUnion> JETON
et %type<typeDeLUnion> nonterminal.
Lors de l’analyse syntaxique, on construit très fréquemment un arbre abstrait décoré représentant la structure
syntaxique et certains éléments sémantiques du programme.
Définition 12 Dans une règle sémantique associé à une production, un attribut est synthétisé lorsque il est défini par
une fonction des valeurs de ses propres attributs et/ou de ceux de ses fils. Pour une production x0 → x1 x2 . . . xn , on
a donc : x0 .val = f (xi1 .ai1 , xi2 .ai2 , . . . , xik .aik ).
C’est le cas de tous les attributs de l’exemple précédent. En particulier, les attributs des chiffres sont des fonctions
constantes. L’analyse ascendante, par exemple avec yacc, permet facilement de calculer les attributs synthétisés. En
particulier, si l’on considère un noeud de l’arbre abstrait comme attribut, la construction de cet arbre abstrait peut
être réalisée des feuilles vers la racine. En analyse descendante, le calcul des attributs synthétisés doit se faire lors de
la remontée postfixe dans le parcours en profondeur.
Définition 13 Une grammaire est S-attribuée ssi toutes les règles sémantiques calculent des attributs synthétisés.
Définition 14 Dans une règle sémantique associé à une production, un attribut est hérité lorsque il est défini par une
fonction des attributs de son père et/ou de ses frères dans l’arbre syntaxique.
L’évaluation de certains attributs hérités (dépendant du père et des frères de gauche (resp. de droite)) est facile
en analyse descendante. Il suffit de les calculer lors du parcours en profondeur. Cela devient plus complexe en analyse
ascendante.
Définition 15 Une grammaire est L-attribuée ssi toutes les règles sémantiques calculent des attributs synthétisés et
des attributs hérités ne dépendant que d’attributs de leur père et/ou de leurs frères de gauche (Left).
En analyse ascendante LR, rappelons que parallèlement à la pile des symboles, une pile des attributs (valeurs
sémantiques) existe. De plus, rappelons que le symbole non terminal de gauche n’est réduit qu’après que tous ses fils
aient été reconnus. Par conséquent, il n’est pas possible d’hériter directement de son père. Par contre, tous les frères
gauches du symbole dont l’attribut doit être calculé sont sur la pile au moment de la réduction. On peut donc calculer
facilement les attributs ne dépendant que des attributs de frères gauches. Par exemple, une déclaration simple d’un
identificateur entier donne lieu aux règles suivantes.
Pour un attribut hérité du père, l’astuce consiste à aller chercher dans la pile l’attribut d’un “oncle” de gauche.
Un exemple classique concerne l’attribution d’un type à une liste d’identificateurs dans une déclaration, comme par
exemple en C : int i,j,k;.
Soit Gtype = ({IN T, CHAR, ID{h},0 ,0 ,0 ;0 }, {D, L{h}, T {s}}, R, D). Chaque attribut est une chaine de caractères
indiquant un type de données entier ou caractère. Cet attribut est nommé s et est synthétisé pour T, tandis qu’il
est nommé h et est hérité pour L et ID. On a les règles de production R, et les règles sémantiques suivantes :
4.4. TRADUCTION DIRIGÉE PAR LA SYNTAXE 53
Le premier héritage (L.h=T.s) concerne un frère gauche et peut donc être réalisé en yacc. Par contre, les trois
dernières règles sémantiques d’héritage du père (ID.h=L.h, ID.h=L.h, L1 .h=L.H) ne peuvent être mises en oeuvre
avec yacc. Aussi, il convient d’imaginer le contenu de la pile au moment où une production de L est en cours de
reconnaissance. On a forcément le symbole T avec son attribut T.s, dans l’élément de pile situé sous le premier ID
à être réduit (L→ID). Par conséquent, l’attribut d’ID peut être affecté de pileAttribut[sommet − 1], c’est-à-dire de
l’attribut de son oncle T. Par la suite, les réductions par L → L1 , ID pourront de la même façon affecter à l’attribut
d’ID, la valeur de pileAttribut[sommet − 3]. Nous avons donc remplacé les règles sémantiques x=L.h par x=T.s. On
n’hérite donc plus de son père mais du frère gauche de son père. Cette transformation est possible, avec yacc, en
accédant à l’élément de pile correspondant à T et qui est symbolisé par $0. Attention, cette méthode ne peut toutefois
pas être généralisé à tous les héritages de père. Il faut étudier soigneusement les différents états que peut prendre la
pile au moment de l’exécution de la règle.
Une implémentation yacc de la grammaire précédente de déclarations est donnée ci-après.
L’analyseur lexical
%{ /* declar.l */
#define YYSTYPE char * /* définition de YYSTYPE car pas dans y.tab.h ! */
#include "y.tab.h" /* JETONS crees par yacc et definition de yylval */
%}
lettre ([a-zA-Z])
chiffre ([0-9])
%%
[ \t]+ {/* filtrer les blancs */}
int {return INT;}
char {return CHAR;}
{lettre}({lettre}|{chiffre})* {yylval=yytext;return ID;}
.|\n {return yytext[0]; /* indispensable ! */}
%%
int yywrap(){return 1;} /* pas de continuation sur un autre fichier */
L’analyseur syntaxique
%{ /* declar.y */
#include <stdio.h>
#include <string.h>
#define YYSTYPE char * /* définition de YYSTYPE comme chaine */
int yylex(void);void yyerror(char *s);
int nb; char affich[1024];
%}
%token INT CHAR ID /* definition des jetons (tous chaines) */
%%
inter : {/* chaine vide sur fin de fichier Ctrl-D */}
| inter {affich[0]=’\0’;} ligne
;
ligne : ’\n’ {/* ligne vide : expression vide */}
| error ’\n’ {yyerrok; /* après la fin de ligne */}
| declar ’\n’ {printf("%i déclaration(s) : %s\n",nb,affich);
affich[0]=’\0’;
}
;
declar : type liste
;
type : INT {$$="entier";}
| CHAR {$$="caractère";}
;
liste : ID {
nb=1;char couple[128];
54 CHAPITRE 4. ANALYSE SÉMANTIQUE
Au moment de réduire par C → c, le calcul de C.s nécessite l’accès à C.h c’est-à-dire A.s. Malheureusement, il est
impossible de savoir si cet attribut A.s se situe en pileAttribut[sommet − 1] ou en pileAttribut[sommet − 2] !
Par conséquent, une méthode générique de traitement des attributs hérités consiste à faire précéder chaque symbole
ayant un attribut hérité par un non terminal “marqueur” dans chaque production. Ces marqueurs ont une seule ε-
production et ne sont présents que pour servir d’emplacement dans la pile d’attributs pour contenir les attributs
hérités. Cette méthode appliquée aux productions précédentes donne :
Ainsi, lorsque la réduction par C → c a lieu, il suffit de regarder en pileAttribut[sommet − 1] pour atteindre
C.h, c’est-à-dire M1 .s ou bien M2 .s. Attention, le calcul des Mi .h est bien entendu adapté : M1 .h = A.s devient
M1 .h = pileAttribut[sommet − 1] tandis que M2 .h = A.s devient M2 .h = pileAttribut[sommet − 2].
Sur le plan théorique, la méthode échoue parfois lorsque l’adjonction des non terminaux marqueurs et de leurs
production génère une grammaire non LR. Cela n’arrive que très rarement dans la pratique.
Enfin, dans deux cas, il n’est pas nécessaire d’introduire des marqueurs :
— dans une règle G → D1 . . . avec D1 .h = G.h, introduire un marqueur devant D1 ne sert à rien sauf quand G
est l’axiome ;
— dans une règle G → D1 D2 . . . Dn avec Di .h = Di−1 .h, introduire un marqueur devant Di ne sert à rien.
Exemple 34
Soit une grammaire d’expressions booléennes à évaluation partielle (ou court-circuit). Dans un interpréteur de ces
expressions, il n’est pas nécessaire d’évaluer la suite de l’expression lorsque le résultat est déjà connu. Pour réaliser
cette évaluation partielle :
— l’attribut synthétisé val remontera la valeur calculée (0 pour faux, 1 pour vrai),
— tandis que l’attribut hérité cal sert uniquement à indiquer s’il faut continuer à calculer le résultat de l’ex-
pression courante (dans ce cas sa valeur est 1), ou bien s’il est déjà connu (court-circuit et sa valeur est 0).
Remarquons qu’en cas de court-circuit, l’analyse syntaxique sera quand même effectuée mais pas l’évaluation.
Dans un interpréteur, l’unique intérêt de l’évaluation partielle consiste en la possibilité de mettre dans la même
expression des conditions causales, par exemple, if (!feof(f) && fgetchar(f)!=’x’) ...
4.4. TRADUCTION DIRIGÉE PAR LA SYNTAXE 55
Les valeurs 99, 98 et 97 signalent des valeurs farfelues qui n’ont aucune chance d’être remontées jusqu’à l’axiome :
en effet, lorsque E.cal est faux E.val n’a aucun intérêt car le résultat final est déjà connu !
La transformation de cette grammaire L-attribuée par l’introduction de marqueurs donne les règles sémantiques
suivantes. Remarquons qu’un marqueur Mi précède toujours une expression E dans la pile, ce qui permet d’obtenir
facilement l’attribut hérité cal.
Production Règles sémantiques Commentaire
S → M1 E S.val = E.val, M1 .cal = 1; E.cal = M1 .val au début, il faut calculer
M1 → ε M1 .val = M1 .cal transmission
E→1 E.val=1 calcul de base
E→0 E.val=0 calcul de base
E → E1 ||M2 E2 E1 .cal = E.cal, M2 .cal = (E.cal?!E1 .val : 0), E2 .cal = M2 .val transmission du court-circuit
E.val = (E.cal?(E1 .val?1 : E2 .val) : 99) calcul de l’expression
M2 → ε M2 .val = M2 .cal transmission du court-circuit
E →!M3 E1 M3 .cal = E.cal, E1 .cal = M3 .val, E.val = (E.cal?!E1 .val : 98) calcul de l’expression
M3 → ε M3 .val = M3 .cal transmission du court-circuit
E → (M4 E1 ) M4 .cal = E.cal, E1 .cal = M4 .val, E.val = (E.cal?E1 .val : 97) transmission
M4 → ε M4 .val = M4 .cal transmission du court-circuit
Remarquons que nous avons introduit les marqueurs Mi afin que l’héritage provienne toujours d’un frère gauche ou
d’un oncle gauche. Chacun des marqueurs n’utilise en fait qu’un seul attribut puisqu’il recopie toujours Mi .cal dans
Mi .val. De plus, l’attribut E.cal provient toujours d’un Mi .cal. Aussi, plutôt que d’utiliser les notations théoriques
un peu lourdes, on utilise une syntaxe à la yacc avec des $i pour représenter les attributs sur la pile.
;
m1 : {$$=1; /* $$=vrai */}
;
exp : exp ’|’ m2 exp {$$=($0?($1?2:$4):99); /* un peu condensé ! */}
| ’!’ m3 exp {$$=($0?!$3:98); /* $0 est l’attribut de mi */}
| ’(’ m4 exp ’)’ {$$=($2?$3:97);}
| ’1’ {$$=1; /* $$=vrai */}
| ’0’ {$$=0; /* $$=faux */}
;
m2 : {$$=($-2?!$-1:0);}
;
m3 : {$$=$-1;}
;
m4 : {$$=$-1;}
;
%%
int yylex(void) {int c; while(((c=getchar())==’ ’) || (c==’\t’)); return (c);}
void yyerror(char *s) {fprintf(stderr,"%s\n",s);}
int main(void){/*yydebug=1*/; return yyparse();}
Dans cet évalaluateur à court-circuit, nous avons donné la valeur 2 lorsqu’un court-circuit était réalisé grâce au ou
logique. Voici quelques exécutions :
0|0|1|0
Résultat : 2
0|0|0|0|1
Résultat : 1
(!!1)
Résultat : 1
!(1|0)|0
Résultat : 0
Exercice 7 Compléter l’évaluateur booléen en ajoutant la règle du et logique à court circuit. Compléter le source
yacc.
Chapitre 5
Génération de code
5.1 Introduction
On utilise généralement un langage intermédiaire entre le langage évolué et le langage de la machine hôte.
— Deux frontaux (“front-end”) de gcc et g++, qui traduisent le fichier source en une représentation interne ar-
borescente commune : Register Transfer Language (RTL). Inspiré de Lisp ce langage a une représentation
interne, structures chainées par pointeurs, et textuelle aux fins de débogage. Pour lire cette apparence tex-
tuelle : gcc -dr exrtl.c; cat exrtl.c.rtl. Cette représentation dépend tout de même de la machine cible
et n’est donc pas totalement portable. La seconde partie finale (“back-end”, bulk compiler), est commune à gcc
et g++ pour une machine donnée.
— Le byte-code de Java est un langage universel qu’interprète une machine virtuelle. La portabilité des .class
est donc totale à condition d’avoir un interprèteur (java, machine virtuelle) sur la machine cible. Le langage
byte-code est assez proche d’un langage machine, à ceci près qu’il utilise beaucoup la pile et des variables locales
plutôt que des registres. Il contient environ 200 instructions, ce qui permet de stocker le code opération sur un
octet.
— Le P-code du Pascal est l’un des premiers langages intermédiaires à avoir été utilisé par un compilateur. C’est
un langage pour machine abstraite à pile (on voit la filiation avec Java).
Le langage intermédiaire est souvent soit un langage de machine virtuelle à pile, soit un langage d’arbre représenté
par une notation postfixée. Sans en étudier tous les détails, la section suivante illustre le fonctionnement d’une machine
à pile.
57
58 CHAPITRE 5. GÉNÉRATION DE CODE
Un compilateur peut être représenté par une forme géométrique en T, notée SIO, où S est le langage source,
O le langage objet, et I le langage d’implémentation du compilateur. Par exemple, un compilateur écrit en C++
traduisant du Pascal en C est noté : PascalC++C. Ces formes en T peuvent être imbriquées, représentant en ceci la
composition de compilateurs. Ainsi, si nous disposons d’un second compilateur C++ en langage machine, la compilation
de PascalC++C par C++MM fournit un compilateur de Pascal en C écrit en langage machine.
Cette technique de compilation de compilateur a souvent été utilisée dans la technique d’auto-amorçage. Pour un
langage L dont on souhaite écrire un compilateur pour la machine M, cette technique consiste à écrire un premier
compilateur grossier en L L’LM, puis à traduire à la main ce compilateur dans le langage M, on obtient donc L’MM.
Ensuite, on utilise ce premier compilateur grossier pour recompiler le compilateur écrit en L : ce compilateur s’est
compilé lui-même ! De la même façon, le premier interpréteur Lisp a été écrit en Lisp puis traduit à la main. De
nouvelles modifications du compilateur sont ensuite utilisées pour l’affiner.
Les techniques de compilation de compilateur sont également utilisées pour les compilateurs croisés. Supposons que
l’on a écrit un compilateur L en L générant du code pour la machine N : LLN. Si l’on a à sa disposition un compilateur
de L sur une autre machine M, LMM, alors on peut très bien obtenir une version du compilateur fonctionnant sur la
machine N de la façon suivante :
1. compiler LLN gâce à LMM : on obtient LMN qui est un compilateur.
2. compiler encore une fois LLN gâce à ce nouveau compilateur LMN : on obtient donc LNN.
Remarquons que l’on a conçu un compilateur tournant sur la machine N, sans jamais utiliser la machine N. Il suffit
de connaître les spécifications de cette machine avant même qu’elle ne soit construite.
Pour ces deux raisons, auto-amorçage et compilation croisée, mais aussi afin de tester la puissance du langage en
cours de développement, il est souvent intéressant d’écrire un compilateur dans son propre langage source.
Chapitre 6
6.1 Introduction
Ce chapitre étudie différents modèles de programmation et leur implantation sur les machines informatiques clas-
siques (modèle de Von Neumann). Les langages utilisés pour illustrer nos propos seront le C, le C++, Java. Le C est
un langage évolué qui est en même temps très proche de la machine ; il est donc couramment utilisé pour écrire des
compilateurs, des systèmes d’exploitation, ... Les langages à objets C++ et Java introduisent un niveau conceptuel
supplémentaire dans la programmation. Il est cependant utile de connaître leur implantation afin de programmer
presque aussi efficacement avec ces langages qu’avec le C.
59
60 CHAPITRE 6. SÉMANTIQUE OPÉRATIONNELLE DES LANGAGES DE PROGRAMMATION
6.3.1 C++
En C++, les objets peuvent être globaux, locaux (automatiques), dynamiques (new).
6.3. LANGAGES À OBJETS 61
Données membres
Les données membres statiques (attributs de classe) sont stockées en une seule occurrence dans le segment de
données statique. La liaison est donc effectuée à la compilation. On peut les assimiler à des variables globales, sauf en
ce qui concerne le contrôle d’accès (public, private) et la portée. L’initialisation de toutes les données du segment de
données statique les concerne.
Les données membres non statiques (attributs d’instance) sont stockées dans chaque instance. La liaison est effectuée
à la compilation pour la partie déplacement à l’intérieur de la “struct”. Des contraintes d’alignement nécessitent
souvent une taille d’instance supérieure à la somme des tailles des attributs d’instance. Les classes vides d’attribut ont
une taille par défaut de 1.
Fonctions membres
Les fonctions membres non virtuelles sont stockées dans le segment de code. La liaison est effectuée à la compilation.
Le coût à l’exécution est donc identique à celui de fonctions externes (à la C).
Les données membres virtuelles sont stockées dans un tableau de fonctions virtuelles propre à la classe. Chaque
instance contient un pointeur (vptr) sur cette table de pointeurs de fonctions. La liaison est effectuée à l’exécution
au prix d’une indirection. Remarquons que la programmation par objets préconise la “virtualisation” des fonctions en
raison justement de cette liaison tardive.
Exemple 35
class Vide{};
class UnIntUnChar{public:int i; private:char c;};
class UneFonction{int f(){return 0;}};
class UneVirtuelle{virtual int f(){return 0;}};
main(){
cout<<"Taille de Vide : "<<sizeof(Vide)<<endl;
cout<<"Taille de UnIntUnChar : "<<sizeof(UnIntUnChar)<<endl;
cout<<"Taille de UneFonction : "<<sizeof(UneFonction)<<endl;
cout<<"Taille de UneVirtuelle : "<<sizeof(UneVirtuelle)<<endl;
}
Une exécution donne le résultat suivant :
Taille de Vide : 1
Taille de UnIntUnChar : 8
Taille de UneFonction : 1
Taille de UneVirtuelle : 4
Héritage simple
Le C++ permet l’héritage multiple. Dans l’héritage simple, une classe dérive d’une classe de base. Pour l’implan-
tation d’une instance dérivée, la règle fondamentale est le respect de l’intégrité du sous-objet. Les attributs de base et
dérivés ne sont pas “compactés”.
Exemple 36
class UnInt2Char:UnIntUnChar{public:char c2;};
cout<<"Taille de UnInt2Char : "<<sizeof(UnInt2Char)<<endl;
Une exécution donne le résultat suivant :
Taille de UnInt2Char : 12
Héritage multiple
La règle d’intégrité du sous-objet est appliquée pour chaque sous-objet hérité. La taille de l’instance dérivée est
égale à la somme des tailles des classes de base.
Exemple 37
class Multiple:UnIntUnChar, Vide{};
cout<<"Taille de Multiple : "<<sizeof(Multiple)<<endl;
Exemple 38
class UneVirtuelleBis{virtual int g(){return 0;}};
class DeuxVirtuelleBis:UneVirtuelle, UneVirtuelleBis{};
cout<<"Taille de DeuxVirtuelleBis : "<<sizeof(DeuxVirtuelleBis)<<endl;
6.3.2 Java
En Java, les objets sont exclusivement dynamiques (new). Toutes les méthodes sont virtuelles, ce qui permet une
programmation fortement polymorphe. Une classe de base “Object” est la racine de la hiérarchie d’héritage. L’héritage
multiple n’est pas permis. Par contre, une classe peut implémenter plusieurs interfaces, signature publique d’une
classe. La compilation d’un fichier source .java génère un fichier de “byte-code” .class. Un interpréteur, ou machine
virtuelle java (“Java Virtual Machine”), exécute ensuite le fichier .class. L’intérêt de cette architecture réside dans
la portabilité totale des .class sous différents environnements (Unix, Windows, MacOS, ...). De plus, l’ensemble des
navigateurs internet (netscape, explorer, ...) possèdent une JVM intégrée, ce qui garantit l’exécutabilité sur la majorité
des machines.
Index
AFD, 5
Analyse descendante, 19, 21
analyse lexicale, 5
analyse syntaxique, 19
arbre abstrait, 19, 50
associativité, 35
attribut, 33
automate, 5
automate à pile, 21
bison, 31
catégorie lexicale, 7
conflit, 29, 46
expression, 3
factorisation, 26
filtrage, 7
flex, 10
frontal, 57
identificateur, 3
instruction, 3
jeton, 7
LALR(1), 31
langage intermédiaire, 57
lex, 10
lexème, 7
littéral, 3
LL(1), 30
LR, 42
mot-clé, 3
opérateur, 4
premiers, 26
priorité, 35
récursivité à gauche, 24
SLR, 43
suivants, 27
yacc, 31
63
64 INDEX
Bibliographie
[1] Ravi Sethi, Jeffrey David Ullman, and Alfred Vaino Aho. Compilateurs : Principes, techniques et outils. InterEdi-
tions, 1990. La bible, indispensable pour la compilation, aspect info., 870 pages.
[2] Arto Salomaa. Formal languages. ACM monograph series. Academic Press, New York, NY, 1973. Très formel, en
anglais, aspect maths, 320 pages.
[3] David Flanagan. Java in a Nutshell. " O’Reilly Media, Inc.", 2005. Le livre de référence sur java, 600 pages.
[4] Jon Meyer and Troy Downing. Java Virtual Machine. O’Reilly & Associates, Inc., Sebastopol, CA, USA, 1997.
Le byte-code de java, 420 pages.
[5] S. B. Lippman. Le modèle objets du C++. Int. Thomson Pub., 1996. Très technique, peu pédagogique, les entrailles
du C++, 260 pages.
[6] Margaret A. Ellis and Bjarne Stroustrup. The Annotated C++ Reference Manual. Addison-Wesley Longman
Publishing Co., Inc., Boston, MA, USA, 1990. L’ouvrage de référence sur le C++ et son implémentation, 440
pages.
[7] John R Levine, Tony Mason, and Doug Brown. Lex & yacc. " O’Reilly Media, Inc.", 1992. Pour apprendre à
programmer en lex et yacc, 330 pages.
65
66 BIBLIOGRAPHIE
Solutions des exercices
/**@file dyck.c
*@author Michel Meynard
*@brief Analyse descendante récursive de mots de Dyck
*/
#include <stdio.h>
#include <stdlib.h>
67
68 SOLUTIONS DES EXERCICES
Table d’analyse :
Action Successeur
a b $ S
0 S1 R(S → ε) R(S → ε) 5
1 S1 R(S → ε) R(S → ε) 2
2 S3
3 S1 R(S → ε) R(S → ε) 4
4 R(S → aSbS) R(S → aSbS)
5 Accepter
Avec le mot abaababb$, empilement de :
aSbaaSbaSbS avant la première réduction intéressante (R(S → aSbS))
4. Il y a donc 4 conflits décalage/réduction. Bison résoud les conflits en privilégiant le décalage. Donc, sémanti-
quement les opérations seront exécutées de droite à gauche. Par exemple, 1/2 − 6/2 = 1/(2 − (6/2)) = −1 !
5. priorités et associativités
%left ’-’
%left ’/’
6. table
a - / ( ) $ E
7 R(E → E − E) S5 R(E → E − E) R(E → E − E)
8 R(E → E/E) R(E → E/E) R(E → E/E) R(E → E/E)
Solution 7
E → E1 &&M5 E2 $$ = ($0?($1?$4 : 0) : 96) calcul de l’expression
M5 → ε $$ = ($ − 2?$ − 1 : 0) transmission du court-circuit
/* evalccet.y */
%{
int yylex(void);
void yyerror(char *s);
%}
/* définition de YYSTYPE comme int par défaut */
/* définition des précédences */
%left ’|’
%left ’&’
%right ’!’
%%
liste : /* chaine vide sur fin de fichier Ctrl-D */
| liste ligne
;
ligne : ’\n’ /* ligne vide : expression vide */
| error ’\n’ {yyerrok; /* après la fin de ligne */}
| m1 exp ’\n’ {printf("Résultat : %d\n",$2);}
;
m1 : {$$=1; /* $$=vrai */}
;
exp : exp ’|’ m2 exp {$$=($0?($1?2:$4):99); /* un peu condensé ! */}
| ’!’ m3 exp {$$=($0?!$3:98); /* $0 est l’attribut de mi */}
| ’(’ m4 exp ’)’ {$$=($2?$3:97);}
| ’1’ {$$=1; /* $$=vrai */}
| ’0’ {$$=0; /* $$=faux */}
| exp ’&’ m5 exp {$$=($0?($1?$4:0):96); /* un peu condensé ! */}
;
m5 : {$$=($-2?!$-1:0);}
;
m2 : {$$=($-2?!$-1:0);}
;
m3 : {$$=$-1;}
;
m4 : {$$=$-1;}
;
%%
int yylex(void) {int c; while(((c=getchar())==’ ’) || (c==’\t’)); return (c);}
void yyerror(char *s) {fprintf(stderr,"%s\n",s);}
int main(void){/*yydebug=1*/; return yyparse();}
0|1&0|1
Résultat : 1
0&1&1|1&0
70 SOLUTIONS DES EXERCICES
Résultat : 0
1&0|1&1|0|1
Résultat : 2