Table des matières Table des matières 1
Table des matières Liste des figures Prologue
III VII 1
2
Le besoin de langa...
31 downloads
2201 Views
1MB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
Table des matières Table des matières 1
Table des matières Liste des figures Prologue
III VII 1
2
Le besoin de langages variés
11
3
Terminologie et exemples
27
4
Grammaires formelles
57
1.1 1.2 1.3 1.4 1.5 1.6
2.1 2.2 2.3 2.4 2.5
3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8 3.9 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 4.1 4.2 4.3 4.4 4.5 4.6 4.7 4.8
Deux langages à implanter Une machine cible Orientation de ce livre Structure des chapitres suivants Note sur la fonction de Fibonacci Remerciements Langages de description de ressources Le langage PostScript™ Le langage CHIP™ Le langage DiaLog Exercices
Syntaxe et sémantique, notion de sur-langage Interprétation et compilation Empilement de machines informatiques Code et données, compilation incrémentale Analyse et synthèse, passes de compilation Ordre d’évaluation et notation postfixée Algorithmes de Markov Le langage Markovski Un analyseur Markovski Un interprète Markovski Un compilateur de Markovski vers Pascal Librairie de support d’exécution pour Markovski Un compilateur de Markovski vers C++ Compilation séparée, compilation indépendante Autointerprétation et autocompilation Générateurs de compilateurs Exercices Notion de grammaire Notations condensées Dérivation et réduction Arbres de dérivation Langage engendré par une grammaire Grammaires ambiguës Productions récursives Classification de Chomsky
1 4 5 7 8 10
12 13 15 18 25
27 29 32 34 36 38 40 42 43 45 47 50 51 52 53 55 55 57 59 60 61 63 64 64 66
IV
Compilateurs avec C++
4.9 4.10 4.11 4.12 4.13 4.14 4.15 4.16
Grammaires du type 3 Grammaires du type 2 Transformations de grammaires Suppression de la récursion à gauche Grammaires d’opérateurs Aspects théoriques Une grammaire du langage Markovski Exercices
66 68 69 69 71 76 78 80
5
Analyse lexicale
6
L’outil Lex
103
7
Analyse syntaxique
121
5.1 5.2 5.3 5.4 5.5 5.6 5.7 5.8 5.9 5.10 5.11 5.12
6.1 6.2 6.3 6.4 6.5 6.6 6.7 6.8 6.9 6.10 6.11 6.12
7.1 7.2 7.3 7.4 7.5 7.6 7.7 7.8 7.9 7.10 7.11 7.12 7.13 7.14 7.15 7.16 7.17 7.18 7.19 7.20 7.21 7.22 7.23 7.24
Niveau lexical et niveau syntaxique Lecture et consommation des caractères Aspects lexicaux des langages Algorithme d’analyse d’expressions régulières Lecture des caractères Analyse lexicale prédictive de Formula Analyse des constantes numériques Gestion des mots clés réservés Analyse des commentaires Analyse des chaînes de caractères Analyse lexicale Formula Exercices
Qui fait quoi avec Lex ? Première partie d’un fichier Lex Deuxième partie d’un fichier Lex Troisième partie d’un fichier Lex Fonctions importantes pour Lex Une librairie de support en C++ pour Lex Analyse lexicale de Formula avec Lex Expressions régulières acceptées par Lex Actions prédéfinies de Lex Gestion des ambiguïtés par Lex Analyse multilangage Exercices Le besoin d’algorithmes d’analyse Analyse ascendante et analyse descendante Les trois méthodes prédictives principales Descente récursive Grammaires LL(1) Problème des notions engendrant le vide Récursion à gauche, ambiguïté et type LL(1) Une grammaire LL(1) de Formula Une descente récursive pour Formula Comportement en cas d’erreurs syntaxiques Rattrapage d’erreurs syntaxiques Méthode de priorités d’opérateurs La méthode LR Positions et états d’analyse LR Transitions LR Conduite de l’analyse LR Conflits LR Le besoin de méthodes plus puissantes que SLR(1) Construction des tables pour les méthodes LR(1) Algorithme d’analyse LR(1) Exemples d’analyse par la méthode LR Comparaison entre grammaires LL(1) et LR(1) Récursion à droite dans les méthodes LR(1) Exercices
81
82 85 86 88 89 92 94 96 98 99 100 102 104 106 109 112 114 115 116 117 118 118 119 120
121 123 124 124 126 127 128 129 130 133 134 137 140 141 144 147 148 149 150 151 152 154 155 158
Table des matières
V
8
Analyse sémantique
161
9
L’outil Yacc
209
10
Évaluation et paramètres
241
8.1 8.2 8.3 8.4 8.5 8.6 8.7 8.8 8.9 8.10 8.11 8.12 8.13 8.14 8.15 8.16 8.17 8.18 8.19 8.20 8.21 8.22 8.23 8.24 8.25 8.26 8.27 8.28
9.1 9.2 9.3 9.4 9.5 9.6 9.7 9.8 9.9 9.10 9.11 9.12 9.13 9.14 9.15 9.16 9.17 9.18
10.1 10.2 10.3 10.4 10.5 10.6 10.7 10.8 10.9 10.10
Identité de types Collecte d’informations sémantiques Forme syntaxique et sémantique associée Limite entre syntaxe et sémantique Sémantique de Formula Inférence de type en Formula Description des types Description des constantes autodéfinies Description des identificateurs Description des niveaux de déclarations Structure de la table des symboles Exemple de table des symboles Point de déclaration d’un identificateur Construction ou traversée de la table des symboles Graphes sémantiques Exemple de graphes sémantiques non construits Graphes sémantiques et forme postfixée Analyse sémantique de Formula Création des identificateurs Formula prédéfinis Description sémantique des fonctions Formula Analyse d’une définition de fonction Formula Description des appels aux fonctions Formula Analyse des appels aux fonctions prédéfinies Formula Analyse des appels aux fonctions utilisateur Formula Exemples d’analyse sémantique de Formula Remarque importante Exemple de description de types structurés Exercices
Qui fait quoi avec Yacc ? Première partie d’un fichier Yacc Deuxième partie d’un fichier Yacc Troisième partie d’un fichier Yacc Une librairie de support en C++ pour Yacc Mise sous forme postfixée Gestion des conflits LR par Yacc Exemple de conflits ”consommer/réduire” Exemple de conflits ”réduire/réduire” Priorités relatives et associativités Gestion des erreurs de syntaxe Rattrapage d’erreurs syntaxiques avec Yacc Actions prédéfinies de Yacc Valeurs retournées par les productions Yacc Interaction entre analyses lexicale et sémantique Une grammaire sémantique Yacc de Formula Analyse sémantique de Formula avec Yacc Exercices Passage de paramètres courants Exemple de passage par nom Exemple de non-terminaison d’une évaluation Evaluation paresseuse et passage par besoin Des graphes sémantiques à la forme postfixée Evaluation des graphes sémantiques Formula Evaluation des graphes sémantiques simples Evaluation des arguments d’appel Evaluation des arguments par valeur Evaluation des arguments par nom
162 164 165 166 167 170 174 176 176 180 180 182 184 185 186 190 192 194 195 195 199 199 200 201 203 205 205 207
210 212 215 217 218 219 220 220 223 224 226 227 230 230 233 235 236 237 241 243 244 247 248 250 252 255 256 257
VI
Compilateurs avec C++
10.11 10.12 10.13
Evaluation des arguments par besoin Evaluation des appels de fonction Exercices
259 261 261
11
Environnement d’exécution
263
12
Synthèse du code objet
307
Appendice : réalisation en C++
345
Bibliographie Index
395 399
11.1 11.2 11.3 11.4 11.5 11.6 11.7 11.8 11.9 11.10 11.11 11.12 11.13 11.14 11.15 11.16 11.17 11.18 11.19 11.20 11.21
12.1 12.2 12.3 12.4 12.5 12.6 12.7 12.8 12.9 12.10 12.11 12.12 12.13 12.14 12.15 12.16 12.17 12.18 12.19 A.1 A.2 A.3 A.4 A.5 A.6 A.7 A.8
Portées statique et dynamique des variables Allocation statique Allocation automatique Allocation dynamique Blocs d’activation et pile d’exécution Le cas des fonctions imbriquées Etablissement du lien statique Exemple de pile d’exécution La machine Pilum L’interprète Pilum Blocs d’activation dans la machine Pilum Passages de paramètres dans la machine Pilum Exemple de passage par valeur avec Pilum Exemple de passage par nom avec Pilum Exemple de passage par besoin avec Pilum Exemple de temporaires dans Pilum Passages par nom imbriqués Cas d’un processeur réel : le M680x0 Optimisation des appels terminaux Paramètres et registres dans le PowerPC Exercices Schémas de code pour les instructions de contrôle Traitement des instructions imbriquées Exemple de schémas de code Pilum imbriqués Synthèse de code pour Pilum Gestion des instructions et des étiquettes Exemple des instructions d’accès à la pile Synthèse de code Pilum pour Formula Synthèse pour les graphes sémantiques simples Synthèse pour les emplois des paramètres Synthèse pour les arguments d’appel Synthèse du corps des thunks Synthèse pour les appels de fonction Synthèse de code depuis Yacc Optimisation peephole Optimisation des sauts sur des sauts Gestion simple des registres Optimisations classiques Gestion moderne des registres et temporaires Exercices
Analyse lexicale L’outil Lex Analyse syntaxique Analyse sémantique L’outil Yacc Evaluation et paramètres Environnement d’exécution Synthèse du code objet
263 265 267 268 269 272 274 275 277 282 283 285 286 288 292 293 296 297 300 303 304
307 308 310 311 314 316 317 319 321 322 324 325 326 327 329 330 333 337 342
345 353 356 356 372 377 379 385
Liste des figures Liste des figures 1
Prologue
2
Le besoin de langages variés
3
Terminologie et exemples
4
Grammaires formelles
5
Analyse lexicale
6
L’outil Lex
7
Analyse syntaxique
8
Analyse sémantique
9
L’outil Yacc
1.1 1.2 1.3
2.2 2.1 2.3 2.4
3.1 3.2 3.3
4.1 4.2 4.3 4.4 4.5 4.6 4.7 4.8
5.1
6.1
7.1 7.2 7.3
8.1 8.2 8.3 8.4 8.5
9.1
Synoptique d’implantation du langage Markovski Synoptique d’implantation du langage Formula Synoptique d’implantation de la machine Pilum
Shadok décrit dans le langage PostScript Un menu décrit dans le langage Rez Solutions du puzzle logique décrit en CHIP Le contexte du projet DiaLog Architecture d’implantation du langage PostScript Architecture d’implantation de Formula/Pilum Graphe du code pour l’exemple Markovski
Exemple de diagramme syntaxique Exemple d’arbre de dérivation Arbres de dérivation multiples en cas d’ambiguïté Un arbre de dérivation est fini Exemple de diagramme syntaxique d’une itération Un opérateur ternaire en Smalltalk 80 Priorité relative des opérateurs Hiérarchie des grammaires usuelles Un automate fini acceptant les identificateurs Formula
Partage du travail lors de l’emploi de Lex pour Formula
Essai d’analyse ascendante Essai d’analyse descendante Ordre d’obtention d’un arbre de dérivation LR Graphe sémantique avec conversion implicite Exemple de structure de la table des identificateurs en Pascal Arbres et graphes sémantiques Hiérarchie des classes décrivant les graphes sémantiques pour Formula Description sémantique d’un appel à une fonction Formula
Partage du travail lors de l’emploi de Yacc pour Formula
2 3 4 14 14 18 19 32 33 45 60 62 65 70 72 73 74 78 88 106 122 123 153 166 183 187 189 198 213
VIII Compilateurs avec C++
10 11
Évaluation et paramètres Environnement d’exécution
12
Synthèse du code objet
11.1 11.2 11.3 11.4 11.5 11.6 11.7 11.8 11.9 11.10 12.2 12.1 12.3 12.4 12.5 12.6
Fonctions imbriquées et accès statique en Pascal Exemple de pile d’exécution en Pascal Exemple de code binaire Pilum Structure du bloc d’activation Pilum Bloc d’activation Pilum avec passage par valeur Bloc d’activation Pilum avec passage par nom Bloc d’activation Pilum avec passage par besoin Bloc d’activation Pilum contenant des temporaires Structure du bloc d’activation Pascal Macintosh Pile d’exécution et optimisation des appels terminaux Schéma de code pour “while“ Schéma de code pour “if“ Schémas de code pour “if“ imbriqués Schémas de code pour “while“ imbriqués Hiérarchie des classes instructions/étiquettes pour la synthèse Pilum Durée de vie des variables et disponibilité des registres
274 276 281 283 287 289 292 294 298 301 308 308 309 310 312 340
Chapitre
1
1 Prologue
Le présent livre traite de la manière de rendre opérationnel un langage sur du matériel informatique. Nous parlerons d’implantation d’un langage plutôt que d’implémentation, terme inélégant emprunté à l’anglais. L’auteur préfère le terme de langage informatique à celui, trop restrictif, de langage de programmation. Tous les langages sont en effet utilisés pour décrire quelque chose, mais pas forcément des programmes. Un bon exemple est fourni par les langages de description de ressources, illustrés au paragraphe 2.1. Le chapitre 2 contient d’autres exemples de langages différents des langages de programmation. 1.1
Deux langages à implanter
Pour aider le lecteur à comparer des techniques d’implantation diverses, nous avons choisi de définir et d’implanter deux langages pour les besoins de ce livre. Le langage Markovski
Markovski est un langage dont les instructions sont les règles de réécriture connues sous le nom d’algorithmes de Markov, dont voici un exemple : /* Permutation des 'a' et des 'b' dans une chaîne */ "1a" --> "b1" . "1b" --> "a1" . "1c" --> "c1" .
2
Compilateurs avec C++
"1" --> "" stop . "" --> "1" . eof.
Markovski permet de manipuler des chaînes de caractères avec un style de programmation particulier. Sa sémantique est présentée au paragraphe 3.7, et l’exercice 3.1 propose d’écrire une addition en base deux dans ce langage. Markovski est implanté au chapitre 3 à l’aide d’un interprète et de deux compilateurs, comme illustré à la figure 1.1. Ce langage permet aux utilisateurs de se concentrer sur leur domaine d’intérêt, sans devoir apprendre un autre langage de programmation. Code source Markovski A
analyse
S
synthèse de code
A Description sémantique E
E
évaluation directe
Résultats
S
Code Pascal
S Code C++
1.1Synoptique d’implantation du langage Markovski Le langage Formula
Formula est un petit langage que l’auteur a créé pour illustrer les techniques de compilation. Il permet de définir et d’évaluer des fonctions booléennes et numériques simples, de manière voisine de ce qui se fait en SML, décrit dans [Paulson 91]. Un certain nombre de fonctions comme Racine ou Sinus sont prédéfinies. L’allure générale des expressions que l’on peut écrire en Formula est calquée sur la notation algébrique usuelle, comme le montre l’exemple suivant : carre (t) = t * t; fact (n) = Si ( InfEgale (n, 0), 1, n * fact (n - 1) ) ; pi = 314.1592E-2; ? Pour (i, 4, 7, EcrireNombre (pi + fact (i - 2))); nand (p, q) = Non (Et (p, q));
Prologue
3
? nand (Vrai, Faux);
L’en-tête des fonctions utilisateur est séparée de leur corps par un signe égale (=), tandis que le point d’interrogation (?) demande l’évaluation d’une expression. Les fonctions comme Si, InfEgale et Pour sont prédéfinies en Formula. Code source Formula Ai
analyse
S
synthèse de code
A1
A2
Description sémantique E
évaluation directe
E
Résultats
S
Code binaire Pilum I
interprétation
I
Résultats
1.2 Synoptique d’implantation du langage Formula Formula sert de base à l’illustration de diverses techniques de compilation, comme on le voit à la figure 1.2. Nous illustrons en fait une paire de compilateurs : •
l’analyse lexicale est faite par la méthode prédictive ou par une grammaire Lex ;
•
l’analyse syntaxique est réalisée par la descente récursive ou par une grammaire Yacc ;
•
l’analyse sémantique est commune et a pour effet de construire les graphes sémantiques des expressions acceptées. Ces graphes sont ensuite utilisés pour une évaluation directe et pour la synthèse de code ;
•
la synthèse de code binaire pour la machine Pilum, présentée au paragraphe suivant, est commune aux deux compilateurs.
Du point de vue de l’utilisateur, à part une différence dans le traitement des programmes lexico-syntaxiquement erronés, ces deux compilateurs sont équivalents : ils acceptent le même langage source, et créent le même code objet. Il arrive que nous mentionnions dans ce livre “le compilateur Formula“ lorque aucun des deux compilateurs n’est visé en particulier. Les programmes écrits en Formula ont une allure familière. Nous avons choisi de ne pas alourdir ce langage par des opérations sur les chaînes de caractères ou les
4
Compilateurs avec C++
listes pour nous limiter autant que possible à l’essentiel. Ces extensions sont laissées au lecteur à titre d’exercice. Malgré son apparence simpliste, Formula pose des problèmes intéressants de gestion de la table des symboles et de passage des paramètres. Formula possède des caractéristiques suffisantes pour mettre en œuvre les techniques usuelles de compilation comme analyse lexicale, syntaxique et sémantique, avec gestion d’une table des symboles et d’une description des types des opérandes. Les termes figurant ci-dessus sont explicités dans la suite de ce livre. Un aspect intéressant de Formula est que les types des fonctions et de leurs paramètres sont déterminés automatiquement par le compilateur en fonction de leur usage. Cette inférence de type est présentée au paragraphe 8.6. La gestion de types définis par l’utilisateur n’est pas mise en œuvre pour implanter Formula : elle est illustrée sur un autre exemple au paragraphe 8.27. 1.2
Une machine cible
Lors de la compilation d’un langage de programmation, la machine cible est celle pour laquelle on produit du code. La machine cible pour le compilateur Formula est une machine virtuelle à pile baptisée Pilum. Cette machine créée par l’auteur pour les besoins de ce livre est implantée par son interprète écrit en C++, selon le schéma de la figure 1.3. Pilum est présentée au paragraphe 11.9, avec un exemple de code objet binaire. La notion de machine virtuelle est définie au chapitre 3, et nous montrons au chapitre 12 comment créer du code pour Pilum à partir du langage Formula. Code binaire Pilum I I
interprétation Résultats
1.3Synoptique d’implantation de la machine Pilum Avec un passage des paramètres par valeur, l’exemple Formula : CarrePlus (x, y) = x * x + y; ? CarrePlus (LireNombre (), 6);
Prologue
5
devient après compilation le code Pilum : 0: Sauter
12
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
Commentaire: EmpilerValeur Commentaire: EmpilerValeur Commentaire: FoisFlottant EmpilerValeur Commentaire: PlusFlottant RetourDeFonction Commentaire:
'Début du corps de 'CarrePlus'' 0,-3 'Par valeur x (no 1)' 0,-3 'Par valeur x (no 1)'
12: 13: 14: 15: 16: 17: 18:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne LireFlottant EmpilerFlottant
'Début d'une évaluation'
19: Appel 20: Commentaire:
0,-2 'Par valeur y (no 2)' 2 'Fin du corps de 'CarrePlus''
Valeur:
6.000000 1 'CarrePlus'
21: EcrireFlottant 22: EcrireFinDeLigne 23: 24: 25: 26:
EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire:
================= 'Fin d'une évaluation'
27: Halte
Ce listage en format “langage d’assemblage“ est produit par la machine Pilum ellemême, après chargement du code binaire. Lorsqu’on fait exécuter ce code par Pilum, on obtient l’interaction suivante : Valeur: Veuillez taper une valeur flottante: 17 295.000000 ================= 1.3
Orientation de ce livre
Les exposés sur la compilation utilisent traditionnellement l’une des deux approches suivantes pour présenter ce domaine : •
l’une est très formelle et justifie les algorithmes utilisés avec toutes les démonstrations de théorèmes et corollaires nécessaires ;
6
Compilateurs avec C++
•
l’autre est centrée essentiellement sur la méthode de descente récursive en une passe. Elle prend comme exemple une implantation similaire en complexité à celle de Pascal-S, un sous-ensemble de Pascal défini et implanté par Wirth en 1976 et décrit dans [Barron 81]. On trouve un extrait du compilateur Pascal-S au paragraphe 7.11.
L’auteur a choisi une voie médiane qui traite de manière détaillée ce qui lui semble important en matière de compilation. Ce n’est pas le formalisme pour lui-même qui nous intéresse dans ce livre, mais son application à la construction pratique de compilateurs. La présentation qui est faite dans les différents chapitres suit donc un cheminement “naturel“ dans les activités fondamentales d’implantation des langages, à savoir l’analyse du texte source, la définition de l’environnement dans lequel s’exécutera le code objet que l’on va synthétiser, puis la synthèse de ce code proprement dite.
Les langages d’implantation utilisés dans ce livre sont Prolog, choisi dans le chapitre 3 pour sa concision et sa puissance d’expression, et C++ par ailleurs. Ce dernier permet une structuration des données à l’aide de hiérarchies de classes, très puissante dans le domaine qui nous intéresse. Nous utilisons toutefois des goto sans arrière pensée lorsque nous recherchons l’efficacité ! Dans le texte, les parties en police courier comme ErreurSemantique renvoient aux d’extraits de code d’implantation, qui pourrait être par exemple : void ErreurSemantique (char * leMessage) { … … … } Les points de suspensions … sont utilisés pour abréger les extraits de code présentés, en ne laissant que ce qui est nécessaire à la compréhension.
Une attention particulière a été apportée au choix des identificateurs en langue française dans les programmes d’exemple écrits spécifiquement pour ce livre, et pour traduire en français tous les termes introduits en langue anglaise. En général, un terme introduit dans une langue est suivi entre parenthèses et en italique de sa traduction dans l’autre langue, comme reduce (réduire). Nous avons choisi de ne présenter que quelques exemples d’optimisation du code objet, ce qui laisse de la place pour une présentation complète de Lex et Yacc et de l’évaluation paresseuse. Certaines techniques d’implantation sont illustrées sur les exemples de : •
Pascal, langage très connu, qui est utilisé notamment pour illustrer les déclarations de fonctions imbriquées ;
Prologue
7
•
DiaLog, langage spécifique au diagnostic de pannes développé par l’auteur au Cern ;
•
C--, petit langage expérimental très proche de C++ développé par l’auteur. La similarité avec C++ est telle qu’il n’y a pas besoin d’en dire plus sur C-- pour les besoins de ce livre ;
•
Newton, langage général défini par le Prof. Charles Rapin à l’Ecole Polytechnique Fédérale de Lausanne (EPFL), et que l’auteur a implanté au moyen d’un autocompilateur. Là encore, aucune connaissance de ce langage n’est requise.
Des exemples de code objet pour les processeurs réels M680x0 et PowerPC sont présentés. Les extraits de code nécessaires à la compréhension des notions présentées figurent dans les paragraphes correspondants. D’autres extraits détaillant certains aspects des techniques présentées sont regroupés en appendice, pour ne pas alourdir le texte. 1.4
Structure des chapitres suivants
Voici comment sont organisés les chapitres de ce livre : •
on présente au chapitre 2 des exemples variés illustrant le besoin de langages informatiques différents selon les applications, et donc de compilateurs pour ces langages ;
•
le chapitre 3 introduit la terminologie et les concepts de base de la compilation. Le propos est illustré par l’implantation du langage Markovski, qui est très simple ;
•
une partie importante d’un compilateur est constituée par l’analyse du code source si ce dernier est un texte. Le chapitre 4 traite donc des grammaires formelles et des questions s’y rattachant ;
•
le chapitre 5 est consacré à l’analyse lexicale du texte source à compiler. L’exemple complet de Formula y est traité par une méthode prédictive. On y trouve de plus un exemple d’analyse lexicale de C--. Le chapitre 1 propose une présentation complète des possibilités de Lex pour la synthèse automatique d’analyseurs lexicaux ;
•
le problème de l’analyse syntaxique est présenté au chapitre 7. On y voit les principes sous-tendant l’analyse, et les problèmes posés par le rattrapage d’erreurs syntaxiques ;
•
le chapitre 8 est dédié à l’analyse sémantique du code source compilé et à la construction de graphes sémantiques encodant la sémantique des constructions du langage Formula ;
8
Compilateurs avec C++
•
les possibilités de l’outil de synthèse d’analyseurs syntaxico-sémantiques Yacc sont présentées complètement au chapitre 9 ;
•
le chapitre 10 est consacré aux problèmes d’évaluation des fonctions et de leurs arguments et présente les détails des passages de paramètres par valeur, par nom et par besoin. On présente également dans ce chapitre l’évaluation directe des graphes sémantiques des programmes Formula ;
•
l’environnement d’exécution du code objet est présenté au chapitre 11, de manière générale et dans le cas de la machine virtuelle Pilum en particulier ;
•
la synthèse de code elle-même est traitée au chapitre 12 avec des exemples de code objet Pilum synthétisé à partir de code source Formula. Les exemples d’optimisation présentés traitent les sauts sur les sauts, la technique “peephole“ et la gestion des registres et des temporaires :
•
enfin, l’appendice regroupe des extraits intéressants de certains compilateurs décrits dans ce livre.
Les exercices proposés à la fin de chaque chapitre permettent de mettre en œuvre la matière décrite dans ce livre. Il se présentent typiquement sous la forme de mini-projets, voire de projets plus ambitieux. Ils demandent une certaine créativité de la part du lecteur. Les degrés de difficulté des exercices sont indicatifs pour des étudiants avancés en informatique. Le degré “moyen“ indique une application directe de la matière présentée. Dans les exercices portant la mention “créativité“, le lecteur peut s’exprimer librement. Ceux indiqués comme “projet“ peuvent faire l’objet d’une évaluation dans le cadre d’un cours de compilation. Il est possible de réaliser des projets à partir des implantations présentées dans ce livre, par exemple en ajoutant un traitement des listes en Formula, ou en écrivant un générateur de code pour un processeur réel. 1.5
Note sur la fonction de Fibonacci
La couverture de ce livre est une illustration indirecte de la célèbre fonction de Fibonacci, alias Leonardo da Pisa, qui peut s’écrire en Formula : fib (n) = Si ( InfEgale (n, 1), n, fib (n - 1) + fib (n - 2) ) ;
Cette fonction est un cas typique de double récursion à gauche et à droite. Ce qui est moins connu, c’est que Fibonacci a découvert cette fonction en étudiant la botanique, bien loin des préoccupations des informaticiens actuels. Il avait en effet observé que dans les fleurs de tournesol et les pommes de pin, par exemple, les composants forment un dessin contenant deux jeux de spirales tournant dans des sens opposés.
Prologue
9
Ce qui est remarquable, c’est que les nombres d’arcs respectifs de ces deux jeux de spirales sont toujours deux nombres consécutifs d’une suite, dite suite de Fibonacci, dont chaque terme est la somme des deux précédents dans la suite. Ainsi, les pommes de pin ont 5 arcs de spirale dans un sens et 8 dans l’autre, les ananas en ont respectivement 8 et 13, tandis que les marguerites en ont 21 et 34.
Un autre point intéressant est que le rapport entre deux termes consécutifs de la suite de Fibonacci tend vers le nombre d’or, OR = 1.618, bien connu en architecture pour être une proportion agréable à l’œil entre les cotés d’un rectangle. Le Corbusier s’en est par exemple beaucoup servi. Cette convergence est très rapide, comme le montre l’évaluation Formula : ? Pour ( i, 0, 18, Seq ( EcrireNombre (i), EcrireNombre (fib (i)), Si ( Sup (i, 1), EcrireNombre (fib (i) / fib (i - 1)), Vide ), EcrireFinDeLigne () ) );
qui produit comme résultat, après compilation et exécution par Pilum : Execution... 1.000000 2.000000 3.000000 4.000000 5.000000 6.000000 7.000000 8.000000 9.000000 10.000000 11.000000 12.000000 13.000000 14.000000 15.000000 16.000000 17.000000 18.000000 ...Fin
1.000000 1.000000 2.000000 3.000000 5.000000 8.000000 13.000000 21.000000 34.000000 55.000000 89.000000 144.000000 233.000000 377.000000 610.000000 987.000000 1597.000000 2584.000000
1.000000 2.000000 1.500000 1.666667 1.600000 1.625000 1.615385 1.619048 1.617647 1.618182 1.617977 1.618056 1.618026 1.618037 1.618033 1.618034 1.618034
Ces propriétés découlent du fait que si l’on cherche les solutions de la forme fn = rn à l’équation : fn = fn–1 + fn–2
10
Compilateurs avec C++
on obtient successivement : n
r = r
et :
n–1
+r
n–2
2
r = r+1
d’où finalement :
1± 5 r = ---------------2
Les deux solutions de l’équation du second degré ci-dessus sont les nombres OR et 1 - OR. On peut donc ré-écrire la fonction de Fibonacci sous la forme : n+1
n+1
– ( 1 – OR ) OR fib(n) = ---------------------------------------------------------5
La fonction de Fibonacci a encore d’autres propriétés intéressantes. Ainsi, quels que soient les deux nombres entiers p et q plus grands que zéro, fib(p*q) est divisible à la fois par fib(p) et par fib(q). Citons encore pour finir que le rapport entre le rayon r d’un cercle et le côté d’un décagone régulier inscrit dans ce cercle est égal à OR - 1. 1.6
Remerciements
Je dédie ce livre à Charles Rapin, qui a été mon professeur à l’EPFL et m’a transmis un peu de sa passion pour les langages et la compilation. Benoît Garbinato a contrôlé le texte et les figures avec sa gentillesse et sa compétence naturelles. Il a aussi fait de nombreuses suggestions qui m’ont permis d’améliorer l’ouvrage. Qu’il en soit chaleureusement remercié ici. Pierre Bettevaux a été un exemple pour moi il y a bien longtemps, et ce livre lui doit beaucoup indirectement . Un remerciement encore pour Maria et Philippe, qui ont accepté pendant ces années de me voir souvent assis devant mon fidèle Macintosh.
Chapitre
2
2 Le besoin de langages variés
Les cours et les livres traitant des langages de programmation montrent la très grande variété des langages existants, dont nous ne citerons que quelques exemples ici. Dans les années 70, Le département de la Défense des USA avait recensé 450 langages différents, utilisés pour écrire des applications pour ses besoins. C’est ce qui a conduit à un concours pour définir un langage qui pourrait les remplacer tous, concours dont le vainqueur est aujourd’hui connu sous le nom de Ada. On peut se demander si un langage informatique unique ne pourrait pas suffire à satisfaire tous les besoins. Historiquement, par exemple, le développement de PL/1 sur les machines IBM/360 à la fin des années 60 avait été décidé pour remplacer simultanément Algol-60, Fortran et Cobol. A l’époque actuelle, on utilise des langages informatiques pour des besoins aussi différents que le pilotage de processus en temps réel, la gestion de bases de données relationnelles et la description de pages de textes et de graphiques à imprimer, pour ne citer que ces exemples.
Un langage informatique unique ne peut pas exister car il serait soumis à trop d’exigences contradictoires. Notons que sans cela, les cours de compilation perdraient sérieusement de leur intérêt ! Pour illustrer l’affirmation ci-dessus, mentionnons par exemple que : •
on a besoin de pouvoir exprimer la concurrence dans certaines applications industrielles dans lesquelles l’efficacité est souvent importante ;
12
Compilateurs avec C++
•
on veut de la simplicité et la ressemblance avec les langues naturelles dans un langage, comme SQL destiné à des utilisateurs de bases de données et très proche de la langue anglaise ;
•
les besoins de langages de description de pages comme PostScript conduisent à préférer une écriture postfixée du code source.
La notion de code postfixé est centrale dans ce livre, car elle est incontournable dans l’exécution du code par des machines informatiques, comme nous le verrons au chapitre 10. Malheureusement, l’esprit humain est ainsi fait que cette forme postfixée est très peu lisible pour nous. Un langage informatique sert à combler un fossé sémantique (semantic gap) entre le niveau d’abstraction du problème à résoudre et les opérations exécutables par une machine informatique. Plus le fossé est large, plus le langage est dit de haut niveau (high level language). La notion de sémantique d’un langage est présentée au chapitre 3. Un bon exemple de fossé sémantique comblé par le langage de haut niveau Formula est présenté au paragraphe 11.16. 2.1
Langages de description de ressources
La notion de ressource (resource) est un concept utilisé par des environnements graphiques, comme le Macintosh, X-Window et Windows 3. Dans ces environnements, tous les éléments d’une application qui ne font pas directement partie du code source peuvent en être séparés. Cela permet facilement de changer ces ressources sans devoir recompiler le code source. Les ressources sont un moyen très commode de paramétrer une application de manière assez fine, sans devoir accéder au code source. L’utilisateur peut changer lui-même ces paramètres. Une application contient ainsi une sorte de “base de données“ de réglages divers. Parmi les candidats à cette séparation, à cette “mise en ressources“, figurent les chaînes de caractères des messages fournis à l’utilisateur ou servant à paramétrer l’application, la composition et l’apparence des menus, et même des fenêtres complètes, avec leur géométrie et leur composition. Un exemple est le fait qu’une fenêtre de terminal xterm dans l’environnement XWindow contienne ou non une barre de défilement (scrollbar). Voici une manière de paramétrer l’outil xterm : xterm*scrollBar.borderColor: xterm*foreground: xterm*nMarginBell: xterm*cursorColor:
White LightBlue 8 White
Le besoin de langages variés
xterm*borderColor: xterm*c132: xterm*borderWidth: xterm*marginBell: xterm*pointerColor: xterm*background: xterm*saveLines: xterm*scrollBar: xterm*font:
13
Red true 3 true Red Black 1000 true 6x10
L’environnement de développement MPW sur Macintosh permet quant à lui de décrire différentes ressources par un langage baptisé Rez, et qui est compilé en une forme binaire utilisée à l’exécution. A titre d’exemple, voici un extrait du source Rez décrivant les ressources d’une application développée par l’auteur, dans lequel on décrit un menu : resource 'CMNU' (mEdit, #if qNames "mEdit", #endif nonpurgeable) { mEdit, textMenuProc, EnablingManagedByMacApp, enabled, "Edition", { "Annuler", noIcon, "-", noIcon, "Couper", noIcon, "Copier", noIcon, "Coller", noIcon, "Effacer", noIcon, "Dupliquer", noIcon, "-", noIcon, "Tout sélectionner", noIcon, } };
"Z", noMark, plain, cUndo; noKey, noMark, plain, nocommand; "X", noMark, plain, cCut; "C", noMark, plain, cCopy; "V", noMark, plain, cPaste; noKey, noMark, plain, cClear; noKey, noMark, plain, cDuplicate; noKey, noMark, plain, nocommand; "A", noMark, plain, cSelectAll;
Comme on le voit, le langage Rez permet de spécifier l’apparence d’un menu ainsi que la commande associée à chaque ligne active de ce menu. A l’exécution du programme, le menu ainsi décrit ci-dessus a l’apparence montrée à la figure 2.1. 2.2
Le langage PostScript™
Le langage de description de pages PostScript, de la société Adobe, s’est beaucoup répandu pour les besoins du contrôle des imprimantes laser. L’architecture d’implantation de ce langage est précisée à la figure 3.2. Le nom PostScript vient de ce que l’on crée des scripts décrivant les actions à prendre pour imprimer les pages désirées de manière postfixée, c’est-à-dire que les opérations suivent leurs opérandes. On trouve des exemples de code postfixé au paragraphe 3.6.
14
Compilateurs avec C++
2.1Un menu décrit dans le langage Rez Pour illustrer le langage PostScript, on trouve à la page 16 et à la page 17 le code source décrivant le personnage de dessin animé nommé shadok, représenté à la figure 2.2. Cet exemple est basé sur une version originale due à Gilles Pandel. Comme le lecteur peut le remarquer sur cet exemple, la forme postfixée est particulièrement difficile à lire. Le mot clé def spécifiant que l’on définit une fonction vient bien entendu en dernier, après ses arguments !
Copyright Roussel
2.2Shadok décrit dans le langage PostScript
Le besoin de langages variés
2.3
15
Le langage CHIP™
CHIP (Constraint Handling In Prolog) est une extension de Prolog permettant de gérer des contraintes. Voici l’exemple classique de la conversion des températures entre degrés Celsius et Fahrenheit écrit en CHIP : celsius_fahrenheit( TemperatureCelsius, TemperatureFahrenheit ) :9 * TemperatureCelsius ^= 5 * (TemperatureFahrenheit - 32).
Le terminal ^= exprime une contrainte entre expressions fractionnaires. Ce programme se prête aux requêtes suivantes, listées avec leur résultat : ?- celsius_fahrenheit(0, Fahr). Fahr = 32 ?- celsius_fahrenheit(Cels, 77). Cels = 25 ?- celsius_fahrenheit(Cels, 100). Cels = (340/9) ?- celsius_fahrenheit(17, 32). no ?- celsius_fahrenheit(50, 122). yes ?- celsius_fahrenheit(Cels, Fahr). Cels = (-160/9) + (5/9)*Fahr Fahr = Fahr
Dans la dernère requête ci-dessus, on explicite simplement la manière dont CHIP représente cette contrainte de manière interne. Comme seond exemple, considérons le problème consistant à placer trois pièces géométriques de forme donnée sur un carré de manière à le recouvrir exactement, sans “trous“ et sans déborder. Chacune des pièces A, B et C est formée de trois carrés élémentaires, selon le dessin présenté à la figure 2.3, et le carré à recouvrir a trois carrés élémentaires de côté. L’idée utilisée est que les trois carrés élémentaires constituant chacune des pièces sont décrits par leurs coordonnées X et Y, et que leur appartenance à cette pièce impose des contraintes sur ces coordonnées. Le terminal #= exprime une contrainte entre une variable et une expression de type domaine, soit un intervalle de nombres entiers. On doit tenir compte des rotations possibles des pières, ce qui donne pour la pièce B : contraintes_B((B1_X, B1_Y), (B2_X, B2_Y), (B3_X, B3_Y)) :B3_X #= B2_X + 1, B1_X #= B2_X, B3_Y #= B2_Y, B1_Y #= B2_Y + 1. contraintes_B((B1_X, B1_Y), (B2_X, B2_Y), (B3_X, B3_Y)) :B2_X #= B1_X + 1, B3_X #= B2_X, B2_Y #= B1_Y, B3_Y #= B2_Y + 1.
16
Compilateurs avec C++
Dessin d’un Shadok en PostScript (début) /dimension 60 def /shadok { % initialisation 0.75 1 scale % dessin du corps newpath 0 150 dimension 0 360 arc stroke % dessin des pattes newpath -5 0 moveto 0 150 dimension 265 250 arcn stroke newpath 5 0 moveto 0 150 dimension 275 290 arcn stroke % dessin du bec newpath 15 65 moveto 0 150 dimension 40 140 arc closepath gsave 1 setgray fill grestore stroke 0 setgray % dessin des yeux newpath -10 192 10 0 360 arc stroke newpath 10 188 10 0 360 arc stroke % dessin des pupilles newpath -6 196 2.5 0 360 arc fill
newpath 4 193 3 0 360 arc fill % dessin des cheveux newpath -10 200 10 10 220 arc stroke newpath 20 202 15 180 45 arcn stroke newpath -28 197 30 5 70 arc stroke % dessin du pied gauche 1 0.5 scale newpath -5 0 moveto -15 -7 12 70 200 arc stroke newpath -5 0 moveto -18 -10 16 80 170 arc stroke 1 2 scale newpath -5 0 moveto -10 -8 10 85 180 arc stroke % dessin du pied droit 1 0.5 scale newpath 5 0 moveto 15 -7 12 110 340 arcn stroke newpath 5 0 moveto 18 -10 16 100 350 arcn stroke 1 2 scale newpath 5 0 moveto
Le besoin de langages variés
17
Dessin d’un Shadok en PostScript (fin) 10 -8 10 95 0 arcn stroke % retablissement % de l'echelle 4 3 div 1 scale } def
% Let's go ! 300 350 translate shadok showpage
contraintes_B((B1_X, B1_Y), (B2_X, B2_Y), (B3_X, B3_Y)) :B2_X #= B1_X, B2_X #= B3_X + 1, B2_Y #= B1_Y + 1, B3_Y #= B2_Y. contraintes_B((B1_X, B1_Y), (B2_X, B2_Y), (B3_X, B3_Y)) :B3_X #= B2_X, B1_X #= B2_X + 1, B1_Y #= B2_Y, B2_Y #= B3_Y + 1.
On associe à chaque position (X, Y) sur le carré un nombre la représentant de manière unique, à savoir son numéro d’ordre si l’on compte les positions de 1 à 9 : positions([], []). positions([Position | Positions], [Code | Codes]) :position(Position, Code), positions(Positions, Codes). position(((X, Y), NomDeLaPiece), Code) :X :: 1 .. 3, Y :: 0 .. 2, Code :: 1 .. 9, Code #= X + 3 * Y.
La notation X :: 1 .. 3 indique que X est une variable dont le domaine est l’intervalle de 1 à 3. Il reste à contraindre cls codes représentant les positions à être tous différents, et à faire énumérer leurs valeurs possibles, ce qui s’écrit : puzzle(Solution) :A_1 = (A1_X, A1_Y), A_2 = (A2_X, A2_Y), A_3 = (A3_X, A3_Y), B_1 = (B1_X, B1_Y), B_2 = (B2_X, B2_Y), B_3 = (B3_X, B3_Y), C_1 = (C1_X, C1_Y), C_2 = (C2_X, C2_Y), C_3 = (C3_X, C3_Y), Piece_A = [(A_1, 'A1'), (A_2, 'A2'), (A_3, 'A3')], Piece_B = [(B_1, 'B1'), (B_2, 'B2'), (B_3, 'B3')], Piece_C = [(C_1, 'C1'), (C_2, 'C2'), (C_3, 'C3')], append(Piece_A, Piece_B, I1), append(I1, Piece_C, Solution), positions(Piece_A, Codes_A), positions(Piece_B, Codes_B), positions(Piece_C, Codes_C), append(Codes_A, Codes_B, Inter1), append(Inter1, Codes_C, Codes), alldifferent(Codes),
18
Compilateurs avec C++
contraintes_A(A_1, A_2, A_3), contraintes_B(B_1, B_2, B_3), contraintes_C(C_1, C_2, C_3), enumerer(Codes).
/* C'est parti! */
enumerer([]). enumerer([Position | AutresPositions]) :indomain(Position), enumerer(AutresPositions).
Le prédicat prédéfini indomain est chargé d’énumérer les valeurs possibles de la variable domaine qu’on lui passe en paramètre. Nous avons utilisé des listes de ces variables pour garder un code compact. Ce programme fournit comme résultat les 32 solutions affichées à la figure 2.3. On voit l’agrément de pouvoir décrire des contraintes au plus haut niveau. CHIP propose encore d’autres fonctionnalités, comme l’optimisation combinatoire sous contraintes, qui en font un outil extrêmement intéressant. A1 C3 C2 A2 B1 C1 A3 B2 B3
B3 B2 A1 C1 B1 A2 C2 C3 A3
A1 A2 A3 C2 C1 B3 C3 B1 B2
B2 B1 A3 B3 C3 A2 C1 C2 A1
A3 B3 B2 A2 C1 B1 A1 C2 C3
B1 C3 C2 B2 B3 C1 A3 A2 A1
C3 C2 A1 B1 C1 A2 B2 B3 A3
A1 A2 A3 B2 B1 C3 B3 C1 C2
C2 C1 B3 C3 B1 B2 A1 A2 A3
A3 C3 C2 A2 B1 C1 A1 B2 B3
B3 B2 A3 C1 B1 A2 C2 C3 A1
A3 A2 A1 C2 C1 B3 C3 B1 B2
A1 C2 C1 A2 C3 B3 A3 B1 B2
B2 B1 C3 B3 C1 C2 A1 A2 A3
A1 A2 A3 C1 B3 B2 C2 C3 B1
C3 C2 A3 B1 C1 A2 B2 B3 A1
A3 A2 A1 B2 B1 C3 B3 C1 C2
C2 C1 B3 C3 B1 B2 A3 A2 A1
A1 B2 B1 A2 B3 C3 A3 C1 C2
C2 C1 A1 C3 B3 A2 B1 B2 A3
A1 A2 A3 B1 C3 C2 B2 B3 C1
C1 B3 B2 C2 C3 B1 A1 A2 A3
A3 C2 C1 A2 C3 B3 A1 B1 B2
B2 B1 C3 B3 C1 C2 A3 A2 A1
A3 A2 A1 C1 B3 B2 C2 C3 B1
B2 B1 A1 B3 C3 A2 C1 C2 A3
A1 B3 B2 A2 C1 B1 A3 C2 C3
B1 C3 C2 B2 B3 C1 A1 A2 A3
A3 B2 B1 A2 B3 C3 A1 C1 C2
C2 C1 A3 C3 B3 A2 B1 B2 A1
A3 A2 A1 B1 C3 C2 B2 B3 C1
C1 B3 B2 C2 C3 B1 A3 A2 A1
A B C
2.3 Solutions du puzzle logique décrit en CHIP 2.4
Le langage DiaLog
Le langage DiaLog illustre le cas d’un langage spécifique développé pour un besoin précis et pour lequel un langage existant aurait difficilement pu être utilisé. Il a été défini et implanté par l’auteur en 1987 au Cern à Genève pour diagnostiquer des pannes d’équipements d’instrumentation sur l’accélérateur de particules SPS. Les équipements en question sont des châssis (crates) multiprocesseur gérés par un système d’exploitation en temps réel. Ils permettent de mesurer et de régler
Le besoin de langages variés
19
des paramètres de la machine SPS en général et du faisceau de particules en particulier. Chaque châssis contient plusieurs cartes, chacune ayant son propre processeur, et l’une d’entre elles fonctionne comme moniteur. Les châssis sont répartis dans 6 bâtiments autour de l’anneau de 2,2 km de diamètre que constitue l’accélérateur, qui est d’ailleurs à cheval sur la frontière entre la France et la Suisse.
La figure 2.4 montre l’environnement dans lequel s’est inséré le projet DiaLog. Base de données “Pièces de rechange“
Page de texte
Lancement d’un programme de test Châssis
Accélérateur SPS
2.4Le contexte du projet DiaLog Tout châssis est formé de différents composants pouvant eux-mêmes être formés de sous-composants. La spécification correspondante en DiaLog est, par exemple, pour les châssis du type Copos, acronyme de Closed Orbit POSition (position de l’orbite fermée) : components( copos ) --> m1553, rti cpu tg3, gater,
subcomponent, subcomponent,
20
Compilateurs avec C++
scalers, crate
subcomponent,
dcps, status_crate subcomponent, fuse subcomponent.
Le principe du diagnostic est que chaque châssis peut exécuter certains programmes de test, décrits en DiaLog dans le cas du composant tg3 de Copos par : test_programs( tg3 ) --> event_arrival_times = resident( 15, timeout = 120 ), msec_clock = resident( 13, timeout = 30 ). Les deux programmes de test connus pour tg3 sont résidents dans le châssis : on indique pour chacun le numéro qui sert à le faire lancer par le moniteur, ainsi qu’un délai d’attente (timeout) au-delà duquel on considère que le châssis ne répond pas.
Les mots clés DiaLog montrent que leur choix a été fait pour “coller“ au domaine d’application. Tout programme de test produit une page de texte “en clair“. Cette page est affichée sur la console de diagnostic, un Macintosh dans notre cas. Voici un exemple de page de texte, produite par le programme de test global_status des châssis du type Copos, dans laquelle nous avons mis en évidence les lignes vides avec un commentaire en italique : // ligne vide // ligne vide COPOS COMMUNICATION TEST AND SUMMARY STATUS // ligne vide
GP1 GP2 GP3 GP4 GP5 GP6
:OK :OK :OK :OK :OK :OK
11:59:45 11:59:46 11:59:46 11:59:46 11:59:48 11:59:48
//
ligne vide
GP1 GP2 GP3 GP4 GP5 GP6
DelP 0 0 0 0 0 0
: : : : : :
P 0 0 0 0 0 0
AM CO CO CO CO CO CO
Nb. ECs 2 2 2 2 2 2
FP 10 10 10 10 10 10
This EC 0103 0103 0103 0103 0103 0103
Gn 14 14 14 14 14 14
S w 1 1 1 1 1 1
1st Meas 29110 29110 29110 29110 29110 29110
Last Meas 29380 29380 29380 29380 29380 29380
Acq Exp 10 10 10 10 10 10
Acq Mea 10 10 10 10 10 10
Cycle 0103 active TG3 SC no Delay Intvl Gate DelB 48975 0 30 1000 23300 48975 0 30 1000 11100 48975 0 30 1000 4200 48975 0 30 1000 9900 48975 0 30 1000 24400 48975 0 30 1000 10800
S P TG TG3-Rx H CP SD S Ch En Err-Reg Calibration Tag 1 1 1 1 OK 1 10001000 Comp:61-1988-10-24-10:36:25 1 1 1 1 OK 1 00001000 Comp:61-1988-10-24-10:36:25 1 1 1 0 OK 1 10001000 Comp:61-1988-10-24-10:36:25 1 1 1 1 OK 1 00000000 COMP:61-1988-10-25-08:06:01 1 1 1 1 OK 1 10001001 COMP:61-1988-10-25-08:04:43 1 1 1 1 OK 1 10001000 COMP:61-1988-10-25-08:01:41
Le problème est maintenant de pouvoir utiliser le contenu de cette page. Elle est synthétisée par un programme écrit par le concepteur du châssis concerné, mais
Le besoin de langages variés
21
les personnes de piquet à la salle de contrôle ne peuvent pas connaître tous les programmes de test de tous les châssis utilisés. Pour décrire le contenu des pages de texte retournées par les divers programmes de test, on utilise une spécification de format de page dans le langage DiaLog.
La spécification du format de la page ci-dessus est : page_format( global_status ) --> comment_lines( 2 ),
/* The page header */
data_line( ascii( 63 ), ev_status = ascii ), comment_lines( 1 ), comment_lines( 2 ),
/* The events section */
data_lines( computer = ascii(5), comm_ok = ascii(2), error_message = ascii(11), ascii(58) ), comment_lines( 3 ),
ascii(1),
/* The settings section */
data_lines( ascii(6), ascii(8), acquisition_mode = ascii(2), frev_bit = decimal(1), ascii(2),
blanks(1),
gain = decimal(2), calibration_switch = decimal(1), ascii(8),
blanks(1), blanks(1),
power_status = decimal(1), settings_check = ascii(2), tg3_enable = decimal(1), ascii(1), tg_no_msec = decimal(1), ascii ).
blanks(1), blanks(1), blanks(1),
On voit que la structure de cette page de texte, qui peut avoir un nombre variable de lignes selon le nombre de châssis GP…, peut être décrite de manière assez simple en DiaLog : •
certaines lignes sont des comment_line (ligne de commentaire) ou comment_lines lorsque le groupe peut en comporter plusieurs. Leur contenu est simplement ignoré lors du décodage de la page ;
22
Compilateurs avec C++
•
les lignes data_line (ligne de données) ou data_lines contiennent des données, c’est-à-dire des valeurs utilisables pour diagnostiquer le châssis concerné :
•
certains champs sont nommés, comme ev_status = ascii, tandis que d’autres sont anonymes, comme ascii( 63 ). Dans la page d’exemple ci-dessus, ev_status, qui est seul champ nommé sur la troisième ligne, a comme valeur active. Lorsqu’on fait exécuter un programme de test, on récupère via le réseau la page de texte résultante, puis on décortique le contenu de cette dernière à l’aide du format correspondant. Les valeurs nommées qui figurent dans le format sont ainsi mises en correspondance avec la valeur lue dans la page, et il reste à utiliser ces valeurs pour diagnostiquer l’état du châssis. Cela se fait au moyen de règles de diagnostic dans le langage DiaLog. La règle de diagnostic pour copos/global_status contient entre autres : diagnosis_rule( global_status ) --> … … … … … else if exists_data_line comm_ok <> 'OK' then set( n_value, 1 ), if error_message = 'NK NOT OPEN' then … … … … … else suspect m1553 endif endif, elsif exists_data_line frev_bit = 0 and not( settings_check = '??') then set( n_value, 1 ), suspect gater / frev_reception endif, … … … … … endif endif.
Comme le lecteur s’en rend compte sur l’exemple ci-dessus, la spécification de l’interprétation du contenu d’une page de texte peut s’avérer très complexe, ce qui justifie la création d’un langage dédié. Comment, en effet, exprimer simplement avec un langage de programmation usuel, la sémantique particulière et riche des spécifications DiaLog ?
Le besoin de langages variés
23
Les instructions spécifiques à DiaLog illustrées par la règles de diagnostic cidessus sont : •
set (variable_globale, expression) on affecte une valeur à variable_globale. Il existe cinq variables globales prédéfinies en DiaLog qui permettent de décrire la géométrie de l’anneau. Elle sont globales pour pouvoir être communiquées entre règles de diagnostic sans mettre en place de passage de paramètres. Cette notion de géométrie est en fait elle aussi spécifique au problème concret de diagnostic ;
•
external_failure (chaîne, …) on a trouvé une erreur due à un élément de l’accélérateur qui n’est pas placé sous la responsabilité du concepteur du châssis ;
•
component_failure (composant, chaîne, …) on a trouvé une erreur d’un composant du châssis, sans qu’on puisse rien faire d’autre que le signaler à l’opérateur ;
•
suspect composant on soupçonne que composant est en cause, et on lance tous les programmes de test de ce composant ;
•
suspect composant / programme_de_test on soupçonne que composant est en cause, et on lance programme_de_test sur ce composant pour voir ce qu’il en est.
La constante prédéfinie nl permet de manipuler une fin de ligne, à la manière du \n de C++. La règle de diagnostic ci-dessus montre aussi que l’on peut utiliser en DiaLog des quantificateurs dans les instructions conditionnelles. On dispose en DiaLog des possibilités de quantification suivantes : •
if exists_data_line condition then instructions … si une ligne de la page de texte retournée par le programme de test satisfait à la condition, on exécute les instructions pour cette ligne en ouvrant l’accès à ses champs, de la même manière qu’un with ouvre l’accès aux champs d’un enregistrement en Pascal ;
•
if whatever_data_line condition then instructions … si toutes les lignes de la page de texte retournée par le programme de test satisfont à la condition, on exécute les instructions. L’accès à une ligne particulière n’est pas possible dans ces instructions.
24
Compilateurs avec C++
On dispose de plus de deux opérateurs postfixés : •
un_champ different a la valeur “vrai“ si une ligne de la page de texte est telle que sa valeur pour un_champ est différente de la valeur la plus fréquente de un_champ parmi les autres lignes du même groupe. Cette possibilité d’expression est nécessaire dans les cas où l’on sait que tous les GPi, par exemple, doivent indiquer la même valeur pour un_champ, sans qu’on puisse prédire statiquement quelle sera cette valeur ;
•
un_champ unavailable a la valeur “vrai“ si le champ en question est rempli de points d’interrogation “?“, ce qui indique par convention que le programme de texte n’a pa pu obtenir l’information correspondante.
Le fait pour des champs d’apparaître dans une même condition quantifiée fait que les lignes correspondantes appartiennent à un groupe logique de lignes. Le lecteur remarquera que dans l’exemple particulier de la page de texte retournée par global_status, les lignes de données décrivant les events et les settings ne constituent qu’un seul groupe logique pour cette raison. En fait, on n’a deux groupes de lignes que pour des raisons de limitation de longueur physique des lignes : tout se passe comme si les deux lignes commençant par GP1, par exemple, n’en constituaient qu’une seule logiquement. Les groupes logiques de lignes doivent être formés de manière consistante. Ainsi, le code objet pour l’exemple de global_status vérifie que chaque GPi est bien décrit par exactement deux lignes. Le début du diagnostic des châssis du type Copos est décrit par : investigate( copos ) --> launch( global_status, nodal( '(LIB)<44>COPTES', 0, timeout = 60) ).
Cela indique qu’il faut lancer le test global_status ainsi que la localisation de ce programme dans une librairie sur le réseau. Lors de l’exécution du diagnostic DiaLog avec la page d’exemple présentée au début de ce paragraphe, on obtient dans le journal de diagnostic : We must suspect "dcps/beam_data" in COPOS-RING
[GP3
// à cause de: // if exists_data_line power_status = 0 then // set( n_value, 1 ), // // //
if gain = 0 and calibration_switch = 0 then component_failure( dcps,
, n_value 1]
Le besoin de langages variés
// // // // // // // //
25
'+48V p.s. used by status/control crate is off,', nl, ' or fuse is blown,', nl, ' -- examine locally and replace as necessary') else suspect dcps endif endif
L’exécution continue alors par : --- Launching nodal_test "dcps/beam_data" timeout = 180 ---
Ainsi on enchaîne les lancements de programmes de tests, de manière dirigée par l’état du châssis. 2.5
Exercices
2.1 : Décodage de mots clés (moyen). Soit une application où l’utilisateur dispose d’un ensemble donné de mots clés qu’il peut employer en les tapant au clavier. Le but est de permettre d’abréger le nom de ces mots clés à la frappe, à la manière de certains shells (interprètes de commandes) ou de l’éditeur Emacs. Cela permet, par exemple, de taper dir au lieu du nom complet directory. Il y a ambiguïté si la séquence de caractères fournies par l’utilisateur débute plus d’un mot clé, auquel cas un message doit être produit. En cas d’ambiguïté, on fournit à l’utilisateur la liste de tous les mots clés débutant par ce préfixe. On peut aussi permettre à l’utilisateur de taper un espace ou un caractère de tabulation à la fin d’une séquence de caractères : dans ce cas, l’application va compléter cette séquence par des caractères tant qu’aucune ambiguïté n’apparaît. Par exemple, si seuls les deux mots clés début et débuter sont connus, en tapant deb, on obtient début après insertion automatique de ut par l’application.
Ayant défini le jeu de mots clés utilisables, écrire un programme ayant l’effet ci-dessus. 2.2 : Synthèse d’expressions avec le chiffre 4 (facile). Ecrire un programme ayant pour effet de synthétiser des expressions arithmétiques composées du seul chiffre 4 et des quatre opérateurs +, -, * et /. Par exemple, on pourrait écrire 2 = (4 + 4) / 4 et 5 = 4 + (4 /4). On testera ce programme en obtenant une telle expression pour chacun des nombres entiers de 1 à 20.
2.3 : Langage XScript (créativité). Imaginer une écriture plus simple, et surtout plus lisible, que celle possible en PostScript pour l’exemple du shadok présenté au paragraphe 2.2.
26
Compilateurs avec C++
La lettre X est destinée à laisser des portes ouvertes. Ce langage pourrait, par exemple, être préfixé comme Lisp ou infixé comme bien d’autres langages courants. Par exemple, on pourrait écrire : move (45, 300) ou (move 45 300) en PréScript, et : 45 move 300 en InScript, pour avoir la même sémantique que le fragment PostScript : 45 300 move.
A titre d’aide à la conception du langage, le lecteur peut commencer par réécrire le code de shadok.ps dans sa propre version de XScript. La phase suivante sera bien sûr d’implanter XScript par compilation, le code objet étant du PostScript.
Chapitre
3
3 Terminologie et exemples
Dans ce chapitre, nous introduisons les notions fondamentales intervenant dans le domaine de la compilation. Différents exemples sont utilisés pour illustrer ces concepts. Il nous a semblé intéressant de montrer d’emblée très concrètement ce que sont les compilateurs et les interprètes. Nous nous appuierons pour cela sur le langage Markovski, présenté au paragraphe 1.1, dont trois implantations sont décrites dans ce chapitre. Afin de mettre l’accent sur l’essentiel, nous avons choisi pour les écrire le langage Prolog, dont le lecteur appréciera au passage la puissance d’expression. La connaissance de ce langage n’est pas nécessaire : il suffit de se laisser guider sans a priori pour tirer profit des exemples Prolog de ce chapitre. 3.1
Syntaxe et sémantique, notion de sur-langage
Un langage informatique est un formalisme de représentation d’informations. Il permet d’écrire des phrases, formées d’une suite ordonnée de mots appelés symboles terminaux (terminal symbol, token) ou parfois unités syntaxiques. La caractérisation “terminal“ est liée à la notion d’arbre de dérivation, comme on le voit au paragraphe 4.4. La syntaxe du langage régit la forme des phrases : les phrases acceptables au vu de la définition syntaxique appartiennent au langage, les autres n’y appartiennent pas.
28
Compilateurs avec C++
On dit que la syntaxe engendre un langage, soit l’ensemble des phrases qui y correspondent. Par exemple, le fragment : if 3+5 = 9 then write ( 'bravo' )
est syntaxiquement correct en Pascal. En revanche : if 3+5 = 9 then write 'bravo' )
est syntaxiquement mal-formé : il manque la parenthèse ouvrante après l’identificateur write. La sémantique d’un langage est la signification véhiculée par les phrases de ce langage. Le premier fragment ci-dessus est sémantiquement correct en Pascal : le fait que la condition 3+5 = 9 soit identiquement fausse ne pose pas de problème. En revanche, le fragment : if 3 = false then writeln ( 'bonjour' )
est sémantiquement incorrect en Pascal : ce langage ne permet d’attribuer aucune signification à la comparaison d’égalité entre une valeur entière et une valeur booléenne. Bien entendu, toute implantation de Pascal doit signaler cette faute sémantique. On distingue souvent les aspects lexicaux d’un langage de sa syntaxe proprement dite. Pour cela, on distingue deux niveaux pour la bonne forme des phrases : •
le niveau lexical, où l’on regroupe les caractères successifs en terminaux ;
•
le niveau syntaxique proprement dit, où l’on spécifie les formes que peut prendre une séquence de terminaux pour être une phrase du langage.
En fait, cette distinction n’est pas formellement nécessaire, mais elle permet d’obtenir plus d’efficacité lors des contrôles de bonne forme syntaxique : c’est une application du vieux principe qui consiste à diviser pour régner. Lisp est un exemple de langage dont la simplicité lexicale et syntaxique permet très facilement de fusionner ces deux niveaux, comme on verra à l’exercice 7.2. Mentionnons dès à présent que nous verrons au paragraphe 9.15, un exemple d’interaction entre les niveaux lexical et sémantique.
L’intérêt des langages informatiques est de véhiculer une sémantique, les aspects lexicaux et syntaxiques n’étant que des maux nécessaires pour y parvenir.
Terminologie et exemples
29
On a besoin de ce “support langage“ lorsque le domaine de résolution du problème auquel on s’attaque est trop différent de celui dans lequel une machine donnée travaille. Un sur-langage d’un langage donné est un autre langage, qui contient toutes les phrases du premier. On peut, par exemple, dire que la langue anglaise est un sur-langage de l’anglais technique. Dans l’implantation de certaines notions des langages informatiques que nous rencontrerons, il est parfois avantageux d’accepter un sur-langage dans un premier temps, pour ensuite restreindre ce que l’on a accepté sur des critères plus fins. Le but peut être d’utiliser des outils déjà disponibles pour analyser le sur-langage considéré, ou de simplifier l’application des techniques d’analyse en divisant pour régner. Nous verrons cette technique mise en œuvre au paragraphe 3.9. 3.2
Interprétation et compilation
On parle souvent de “langages compilés“ et de “langages interprétés“, voire de “langages pseudo-compilés“. Une certaine confusion règne dans les termes employés couramment en informatique, et il importe de préciser leur signification. En informatique, interpréter, c’est parcourir un graphe (une structure de données chaînée) dont les nœuds sont appelés des instructions. Un interprète est un programme réalisant une interprétation. Etymologiquement, “interpréter“ signifie “expliquer, donner un sens“. Le contenu de chaque nœud du graphe parcouru indique les actions à prendre, en particulier où continuer le parcours. Ce dernier s’arrête lorsqu’un nœud indique que l’interprétation est terminée. En considérant les choses sous cet angle, on voit qu’en fait interpréter, exécuter (au sens de Pascal, Modula-2 ou C), évaluer (au sens de Lisp), et démontrer (au sens de Prolog) sont synonymes.
On remarquera que selon cette définition, tout processeur réel est un interprète qui parcourt le graphe contenu dans sa mémoire de code binaire. De même, dans le cas où il est microcodé, l’unité de contrôle (control unit) est elle-même l’interprète du microcode. Une machine informatique est la combinaison d’une mémoire, contenant le graphe du code à exécuter, et d’un interprète.
30
Compilateurs avec C++
Une telle machine peut être physique (câblée) ou virtuelle, c’est-à-dire implantée par logiciel sur une autre machine virtuelle ou physique. Pilum est une telle machine virtuelle, comme nous le verrons au paragraphe 3.6. En informatique, compiler signifie analyser une description d’informations et synthétiser une autre forme de celles-ci, mieux adaptée à ce que l’on veut en faire, tout en maintenant la sémantique invariante. Un compilateur est un traducteur automatique d’une forme source (source form) en une forme objet (object form). Etymologiquement, “compiler“ est composé de “com-“, préfixe indiquant un regroupement, et de “piller“, dans le sens de prendre. Compiler, c’est prendre des morceaux épars et en faire un tout. C’est dans ce sens que l’on utilise ce mot en littérature lorsqu’on dit d’un ouvrage est une compilation, et dans l’industrie du disque musical. Dans le cas de la compilation des langages de programmation pour obtenir un code objet exécutable par un interprète réel ou virtuel, l’invariance de la sémantique fait que l’on ne change pas la signification du code source. Le but dans ce cas est de combler le fossé sémantique existant entre le langage de programmation et l’interprète, dont les instructions sont de plus bas niveau. On parle alors de code source (source code) et de code objet (object code). Le terme langage objet (object language) désigne dans ce contexte le langage dans lequel est synthétisé le code objet, indépendamment des classes ! Le dual est le langage source (source language).
On notera qu’un assembleur est aussi un compilateur selon la définition cidessus : il convertit un code source écrit en langage d’assemblage (assembly language) en du code binaire pour un processeur particulier. Le nom “assemblage“ vient de ce que l’on assemble (construit) des mots-mémoire contenant ce code binaire. L’adjectif “dynamique“ est synonyme de “à l’exécution du programme“ (at runtime). L’emploi de cet adjectif va de l’allocation dynamique de mémoire, présentée au paragraphe 11.4, aux fautes de sémantique dynamique, comme la division par zéro, en passant par le lien dynamique utilisé pour gérer les appels de fonctions et procédures. L’adjectif “statique“ est synonyme de “à la compilation du programme“ (at compile-time).
Terminologie et exemples
31
Ce terme s’applique à des tests statiques de type à l’allocation statique des variables, en passant par le lien statique employé pour implanter les appels de fonctions pouvant être imbriquées textuellement dans d’autres fonctions. On peut compiler d’autres choses que des langages de programmation, comme le propose l’exercice 12.3. Nous avons vu au paragraphe 2.1, que des spécifications non exécutables, comme l’apparence de l’interface utilisateur d’une application, pouvait être décrite par un langage que l’on compile en une forme utilisable à l’exécution. Nous verrons dans les chapitres suivants que l’on peut compiler une spécification grammaticale d’un langage pour en obtenir un analyseur. La notion de générateur de compilateur est présentée au paragraphe 3.16. En particulier, les outils Lex et Yacc, présentés respectivement au chapitre 6 et au chapitre 9, sont de tels compilateurs de grammaires.
Dans un contexte un peu différent, l’outil make originaire d’Unix sert à compiler un fichier de texte décrivant les dépendances entres les divers fichiers servant à construire une application. Le résultat de cette compilation est une séquence de commandes permettant de prendre en compte toutes les modifications intervenues depuis la dernière construction de l’application afin d’en construire une nouvelle version à jour. On trouve un exemple de fichier pour make en appendice, au paragraphe A.2.2. On appelle compilation conditionnelle le fait de contrôler par des variables connues lors de la compilation seulement quelles parties du texte source sont effectivement compilées. Un cas typique est celui où l’on veut empêcher que l’importation de l’interface d’un module C++ donne lieu à des déclarations multiples, même s’il est importé indirectement via d’autres modules. Cela peut se faire par exemple dans un fichier “.h“ au moyen de : #ifndef __URandomGenerator__ #define __URandomGenerator__ #include <stream.h> class TRandomGenerator { // … … … … … … … }; #endif __URandomGenerator__
Ainsi, la première importation de ce module lors de la compilation d’un fichier qui l’importe par une clause #include déclare la classe TRandomGenerator après avoir défini la variable de compilation __URandomGenerator__. En cas de réimportation de ce même module au gré d’autres clauses #include, le compilateur trouve cette variable définie et ignore le code jusqu’au #endif.
32
Compilateurs avec C++
Un compilateur croisé (cross-compiler) est un compilateur s’exécutant sur une architecture et synthétisant du code pour une autre. Un cas typique est celui des environnements de développement permettant d’obtenir, sur des machines usuelles, du code pour des cartes industrielles. Dans le cas des deux compilateurs Markovski présentés respectivement au paragraphe 3.11, et au paragraphe 3.13, on a affaire à un compilateur croisé si le code objet est exécuté sur une machine d’architecture différente de celle sur laquelle est exécuté le compilateur lui-même. 3.3
Empilement de machines informatiques
Le langage PostScript s’est imposé comme langage de pilotage d’imprimantes à laser. Comme tous les langages de description de pages, il permet à des applications et des imprimantes variés de collaborer sur la base d’un langage commun. Les imprimantes PostScript fonctionnent avec une machine virtuelle, selon le schéma de la figure 3.1. Selon notre terminologie, un outil interactif de création de dessins PostScript est donc un compilateur de spécifications de dessins synthétisant des programmes en langage PostScript. Il doit bien sûr maintenir invariante la sémantique pour qu’on n’obtienne pas un dessin différent de celui que l’on a spécifié ! Dessin tracé à la souris
Code PostScript
Code source de l’interprète PostScript
Code machine du processeur de l’imprimante
compilation interprétation
Microcode du processeur de l’imprimante ou matériel
3.1Architecture d’implantation du langage PostScript Il est aussi possible de synthétiser du code PostScript à l’aide d’un programme, par exemple pour mettre en forme automatiquement un rapport à imprimer. Les programmes synthétisant du code PostScript sont des compilateurs croisés, s’exécutant sur une machine différente de l’imprimante. L’interprète PostScript quant à lui est exécuté par le processeur logé dans l’imprimante. On a bel et bien affaire à une machine virtuelle puisque ce pro-
Terminologie et exemples
33
cesseur ne dispose en général pas de manière câblée des commandes PostScript stroke ou showpage, par exemple.. Cela évite de devoir développer des machines informatiques dédiées à PostScript : on peut utiliser n’importe quel processeur pour exécuter l’interprète dans l’imprimante. En revanche, la taille du code PostScript envoyé à l’imprimante peut être importante.
C’est dans un cas comme celui de PostScript que l’on parle usuellement de langage “interprété“ ou “pseudo-compilé“. Il s’agit en fait d’un empilement de machines informatiques, chacune exécutant (interprétant) un programme implantant l’autre. Il y a bien sûr toujours une machine réelle au bas de cet empilement. Cette interprétation d’une machine informatique par l’autre est dénotée par les flèches noires ( ) dans la figure 3.1. Exemple de Formula
L’implantation de Formula que nous illustrons dans ce livre synthétise du code de la machine virtuelle Pilum. Cette dernière dispose d’une mémoire et d’un interprète spécialisé (son “processeur“), implantés par un programme C++, selon un schéma illustré à la figure 3.2. Code source Formula
Code Pilum
Code source de l’interprète Pilum
Code machine du processeur
compilation interprétation
Microcode du processeur
3.2Architecture d’implantation de Formula/Pilum Il y a dans ce schéma d’implantation de Formula/Pilum : •
d’abord une phase compilation, faisant passer de la forme “caractères“ du programme source Formula à du code de la machine virtuelle Pilum, ;
•
puis une phase d’interprétation, lors de l’exécution de ce code par l’interprète de la machine virtuelle Pilum.
34
Compilateurs avec C++
La compilation de Formula nous sert d’étude de cas à partir du chapitre 5. L’implantation de la machine Pilum par son interprète écrit en C++ est présentée au chapitre 11. L’empilement des machines informatiques implique en général une pénalité en vitesse. La performance moins bonne d’une machine virtuelle par rapport à une machine réelle vient de ce que chacune de ses instructions est exécutée par plusieurs instructions de la machine sur laquelle est exécuté l’interprète. A titre d’exemple, voici comment la machine Pilum effectue une addition de deux nombres flottants : case iPlusFlottant: fPile [fSommet - 1].fFlottant = fPile [fSommet - 1].fFlottant + fPile [fSommet].fFlottant; -- fSommet; break;
L’addition est grevée par plusieurs accès à la mémoire de la machine virtuelle, ce qui coûte du temps, alors que le processeur pourrait faire une telle addition en une seule instruction câblée. On retrouve le même phénomène dans l’implantation de PostScript décrite plus haut. Lorsqu’on implante une machine comme un Motorola 680x0 ou un Vax au moyen de microcode, la pénalité en vitesse n’apparaît pas de façon aussi gênante, puisque le microcode accède en parallèle aux composants physiques de la machine. L’intérêt des machines virtuelles vient de ce que leur réalisation et leur mise au point sont infiniment plus économiques que ceux d’une machine réelle. Nous présentons la machine Pilum au paragraphe 11.9 et son interprète au paragraphe 11.10. 3.4
Code et données, compilation incrémentale
On dit souvent que les langages typiques de l’intelligence artificielle, comme Lisp, Prolog et Smalltalk 80, ont la particularité que le code et les données sont pour eux la même chose. En fait, en informatique, tout n’est que données, c’est-à-dire que tout n’est qu’information : le code binaire du processeur X est une donnée (un graphe) que ce processeur parcourt pour l’exécuter. Dans les langages mentionnés ci-dessus, code et données ont le même format. Cela permet d’ajouter facilement une donnée construite par un programme au code de celui-ci, comme de construire des structures de données à partir du code du programme.
Terminologie et exemples
35
Cette possibilité est en fait liée au mécanisme de compilation/décompilation incrémentale. En Pascal, cela équivaudrait pour un programme à pouvoir construire une chaîne de caractères, la soumettre au compilateur et ajouter le code résultant à son propre code.
Il se trouve que les trois langages mentionnés ci-dessus ont la particularité de fournir une notation syntaxique des structures de données, ce qui rend cette fonctionnalité particulièrement facile à mettre en œuvre. Considérons, comme exemple, la version Prolog ci-dessous de la fonction de Fibonacci, dont le code se modifie au fur et à mesure des évaluations successives : fibo_futee(N, Resultat) :/* Une version futée, tabulante ! */ N1 is N-1, fibo_futee(N1, Inter_1), N2 is N-2, fibo_futee(N2, Inter_2), Resultat is Inter_1 + Inter_2, asserta( (fibo_futee(N, Resultat) :- ! ) ). :-
asserta( (fibo_futee(0, 1) :- ! ) ), asserta( (fibo_futee(1, 1) :- ! ) ).
La donnée : fibo_futee(N, Res) :- !
qui est en fait un arbre, est ajoutée au source du programme dynamiquement par le prédicat prédéfini asserta, qui la transforme donc en un fragment de code. Après chargement de ce programme, la requête : ?- listing( fibo_futee ).
produit comme résultat : /* fibo_futee/2 */ fibo_futee(1,1) :!. fibo_futee(0,1) :!. fibo_futee(N,Resultat) :N1 is N - 1, fibo_futee(N1,Inter_1), N2 is N - 2, fibo_futee(N2,Inter_2), Resultat is Inter_1 + Inter_2, asserta((fibo_futee(N,Resultat) :- !)). La requête ci-dessus est bien une forme de décompilation puisque qu’elle affiche comme des données ce qui est en fait le code de fibo_futee.
Après exécution de la requête : ?- fibo_futee( 5, Resultat ).
36
Compilateurs avec C++
fournissant comme résultat : Resultat = 8
la même commande de listage de la définition de fibo_futee produit : /* fibo_futee/2 */ fibo_futee(5,8) :!. fibo_futee(4,5) :!. … … … … … … … … fibo_futee(0,1) :!. fibo_futee(N,Res) :N1 is N - 1, fibo_futee(N1,Inter_1), N2 is N - 2, fibo_futee(N2,Inter_2), Res is Inter_1 + Inter_2, asserta( (fibo_futee(N,Res) :- !)).
A la suite de cela, toutes les requêtes pour des valeurs de fibo_futee entre 0 et 5 se feront sans calcul puisque la définition a changé grâce aux appels à asserta. Les facilités de compilation-décompilation incrémentale permettent une grande souplesse de programmation. On retrouve ces possibilités notamment en Prolog, Lisp et en Smalltalk 80. 3.5
Analyse et synthèse, passes de compilation
Le travail de compilation consiste plus particulièrement en deux familles de tâches : •
analyse : on doit analyser la forme source, faire en général différents contrôles, et construire une représentation, interne au compilateur, du code source qui a été lu ;
•
synthèse : on doit synthétiser la forme objet, en s’appuyant sur la représentation interne.
Dans le cas fréquent où la forme source est un fichier de caractères, on trouve trois tâches d’analyse typiques : •
l’analyse lexicale consiste à lire les caractères du source, et à construire une séquence des terminaux la composant ;
Terminologie et exemples
37
•
l’analyse syntaxique consiste à vérifier que la structure de cette séquence est conforme à la syntaxe du langage considéré ;
•
l’analyse sémantique consiste à contrôler la signification du code source.
Par exemple, l’analyse du programme Pascal : program exemple; var i : integer; begin write ('Veuillez fournir un entier: ' ); readln (i); writeln ('Le carré de ', i, ' est ', i * i) end.
consiste à : •
vérifier que les constantes numériques et de chaîne sont correctement formées et ne débordent pas la capacité admise en Pascal, vérifier qu’aucun caractère étranger au langage n’est utilisé ;
•
vérifier l’emploi correct des mots clés (réservés en Pascal) dans les déclarations et les instructions ;
•
vérifier que les opérations indiquées, comme les affectations, l’arithmétique et les passages de paramètres, sont compatibles avec les types des opérandes qu’elles utilisent.
Si le langage compilé s’y prête, on peut mener toutes les tâches de compilation de front . On parle alors de compilation en une passe parce que l’on ne fait qu’un passage sur le texte source. Il est aussi possible, et parfois nécessaire, d’exécuter ces tâches l’une après l’autre, auquel cas on parle de compilation en plusieurs passes. Pour mener à bien les tâches d’analyse et de synthèse, un compilateur doit en toute généralité se construire une description du code source compilé. Il peut ainsi s’y référer selon ses besoins. Rappelons qu’un compilateur est lui-même un programme qui manipule des structures de données et met en œuvre des algorithmes particuliers.
Dans le cas d’un compilateur multipasse, certaines de ces structures de données sont créées par une des passes et utilisées par une ou plusieurs passe(s) ultérieure(s). Une architecture fréquente dans les compilateurs modernes consiste à : •
construire, par un premier module appelé front end (partie frontale), une description sémantique du code source compilé, indépendante de toute machine cible ;
38
Compilateurs avec C++
•
synthétiser du code pour la machine cible choisie à partir de cette description sémantique, à l’aide d’un second module spécifique appelé back end (partie arrière).
Cela permet d’utiliser facilement une même partie frontale pour compiler un langage donné en du code de différentes machines, comme de compiler différents langages en du code d’une machine donnée avec une seule partie arrière.
Les compilateurs Markovski et Formula présentés dans ce livre sont structurés en deux passes qui partagent une description sémantique intermédiaire, sur le modèle partie frontale/partie arrière. 3.6
Ordre d’évaluation et notation postfixée
Certaines des premières calculatrices de poche nécessitaient de postfixer les opérations, par exemple en tapant 3, puis 5, puis +, pour calculer la valeur de 3+5. On parle aussi parfois de notation polonaise inverse, en mémoire du mathématicien polonais Lukacievitz qui le premier proposa cette notation. Cela se dit “notacia polska odwrócona“ en polonais, et… “odwrócona polska notacia“ en notation polonaise inverse !
La notation postfixée est incontournable en informatique. En effet, comment calculer : f(3) + f(i)
sans disposer de l’opérande f(3) et de l’opérande f(i) avant d’exécuter l’addition ? L’ordre d’évaluation dans ce cas est donc : évaluer f(3) évaluer f(i) faire l’addition
ou : évaluer f(i) évaluer f(3) faire l’addition
mais l’addition se fait toujours en dernier. Que fait-on alors de la valeur résultant de l’évaluation du premier opérande pendant l’évaluation du second ? On doit la sauvegarder. On utilise une pile des opérandes en attente d’être consommés par l’opération qui les utilise.
Terminologie et exemples
39
Le terme anglais “push down list“ (liste où l’on pousse vers le bas) a été utilisé dans les premiers temps de l’informatique. C’est une analogie avec les boîtes servant à ranger des pièces de monnaie, dans lesquelles on doit comprimer un ressort pour pouvoir faire entrer une nouvelle pièce. Ce terme a été supplanté par “stack“ (pile), mais il en resté les noms en langue anglaise push (appuyer sur le ressort) pour empiler une nouvelle valeur sur une pile, et pop (sauter en l’air) pour désempiler la valeur placée au sommet de la pile. Dans l’architecture d’ordinateur dite machine à pile, toutes les instructions référencent implicitement la pile des opérandes : elles y prennent leurs arguments éventuels par désempilage, et y empilent leur résultat s’il y a lieu. Le code d’une machine à pile apparaît donc sous forme postfixée. La machine Pilum pour laquelle nous synthétisons du code dans ce livre est une machine à pile : c’est même pour cela que l’auteur l’a baptisée ainsi. Par exemple, le code objet produit par le compilateur Formula pour le code source : ? 17 * 2 - 9;
est : 0: 1: 2: 3: 4:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne
'Début d'une évaluation'
5: 6: 7: 8: 9: 10:
EmpilerFlottant EmpilerFlottant FoisFlottant EmpilerFlottant MoinsFlottant EcrireFlottant
17.000000 2.000000
11: 12: 13: 14: 15: 16:
EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire: Halte
Valeur:
9.000000
================= 'Fin d'une évaluation'
La nature postfixée du code est très nette dans cet exemple : l’opérateur FoisFlottant consomme les deux opérandes 17 et 2 préalablement empilés dans cet ordre, calcule leur produit, et empile la valeur résultante 34. On empile ensuite la valeur 9, puis l’opérateur MoinsFlottant consomme les deux opérandes 34 et 9, calcule leur différence, et empile la valeur résultante 25. Cette dernière est ensuite consommée par l’opérateur EcrireFlottant ajouté par le compilateur pour que l’utilisateur puisse connaître le résultat de l’évaluation. L’exécution du code par une machine à pile est typiquement faite par une boucle d’interprétation. On montre au paragraphe 11.10, la structure du code de l’interprète de la machine Pilum.
40
Compilateurs avec C++
Le fait que le code objet est “naturellement“ postfixé est un phénomène général : le code pour les machines à registres est également postfixé, comme on le voit au paragraphe 8.17. La notation postfixée n’est autre qu’une écriture linéaire d’un graphe, comme nous le verrons au chapitre 8. Elle est fondamentale parce qu’on ne peut interpréter des langages informatiques que si chaque opération est effectuée après que tous ses opérandes soient disponibles, donc évalués. Cela est vrai même lors du passage de paramètres par nom et du passage par besoin, comme on le verra au chapitre 10. Une fonction est dite stricte si elle évalue tous ses arguments. Une fonction stricte ne peut être calculée si un de des arguments ne peut l’être. Cette notion sous-tend les modes de passage des paramètres, qui sont traités au chapitre 10. Un exemple typique de fonction non stricte est la conditionnelle Si, qui n’évalue que l’un ou l’autre de ses deuxième et troisième arguments, le premier, la condition, étant lui toujours évalué. On voit cela en pratique au paragraphe 10.3. 3.7
Algorithmes de Markov
Dans le but d’illustrer par un langage très simple les notions importantes de la compilation, nous allons nous intéresser dans ce chapitre aux algorithmes de Markov, à ne pas confondre avec les chaînes du même nom, relevant de la statistique. Un algorithme de Markov est une séquence de règles de réécriture de la forme : chaîne_1 -> chaîne_2 ;
ou : chaîne_1 -> chaîne_2 .
où chaîne_1 et chaîne_2 désignent des chaînes de caractères, éventuellement vides, nommées respectivement membre gauche et membre droit de la règle. Les règles terminées par un point sont dites terminales, les autres sont nonterminales. Une règle est dite applicable à une chaîne de caractères si celle-ci contient au moins une occurrence de son membre gauche. Dans ce cas, appliquer la règle en question à cette chaîne consiste à remplacer dans la chaîne l’occurrence la plus à gauche du membre gauche de la règle par le membre droit. La chaîne vide est considérée comme ayant toujours une occurrence au début de toute chaîne. Donc, une règle ayant la chaîne vide comme membre gauche est toujours applicable à une chaîne quelle qu’elle soit.
Terminologie et exemples
41
L’exécution d’un algorithme de Markov se passe ainsi : étant donné une chaîne de caractères globale dite chaîne de travail, convenablement initialisée, on cherche la première règle, dans l’ordre textuel, qui lui soit applicable : •
si aucune telle règle n’existe, l’algorithme se termine par épuisement des règles applicables, et fournit comme résultat la valeur courante de la chaîne de travail ;
•
sinon, on applique la première textuellement des règles applicables à la chaîne de travail, ce qui conduit à une nouvelle chaîne. Si la règle est terminale, l’algorithme se termine et fournit comme résultat cette nouvelle chaîne ; sinon, on applique l’ensemble des règles (depuis la première) à la chaîne résultante, qui devient donc la nouvelle chaîne de travail courante.
Les deux cas qui font que l’exécution se termine sont : •
on a appliqué une règle terminale ;
•
aucune règle n’est applicable à la chaîne de travail.
Par exemple, considérons l’algorithme de Markov suivant : 1a 1b 1c 1
-> -> -> -> ->
b1 ; a1 ; c1 ; . 1 ;
L’application de cet algorithme à la chaîne de départ : acabac conduit aux chaînes de travail successives ci-dessous, où nous avons mis en évidence l’occurrence du membre gauche qui est remplacée à chaque étape : acabac 1acabac b1cabac bc1abac bcb1bac bcba1ac bcbab1c bcbabc1
et le résultat de l’exécution est : bcbabc
Notons que la toute première application d’une règle est l’insertion d’un 1 devant la chaîne de travail puisque la chaîne vide y apparaît. Cet exemple a pour effet net de permuter les a et les b dans une chaîne qui ne contient pas de 1 puisque celui-ci est utilisé comme marqueur mobile dans l’algorithme. On peut à titre d’exercice écrire un algorithme de Markov pour l’addition des nombres binaires, par exemple, comme proposé à l’exercice 3.1. Le fait d’utiliser des caractères auxiliaires comme marqueurs en cours de travail ne pose pas de vrai problème : on peut toujours ré-écrire un algorithme
42
Compilateurs avec C++
de Markov avec un alphabet de deux caractères α et β quelconques, en encodant chaque caractère d’origine par une séquence de un ou plus β placée entre deux α, par exemple.
Comme autre exemple d’algorithme de Markov, considérons : a
->
aa ;
On remarque tout de suite que cet algorithme n’a aucune clause terminale. Si la chaîne de départ ne contient aucune occurrence de a, il se termine immédiatement en retournant la chaîne de départ. Sinon, on est conduit à une suite sans fin de réécritures, chacune rajoutant un a au début de la chaîne de travail, sur laquelle la seule règle de l’algorithme est toujours applicable.. La puissance descriptive des règles avec lesquelles on écrit les algorithmes de Markov ne doit pas être sous-estimée : elle permet de démontrer le théorème d’indécidabilité de Gödel, par exemple. 3.8
Le langage Markovski
Appelons Markovski un langage dont les instructions sont les règles de Markov définies au paragraphe précédent. Nous pouvons définir lexicalement et syntaxiquement ce langage à notre convenance. Par exemple, nous pouvons décider que : •
les membres gauches et droits des règles sont écrits entre doubles guillemets, et sont séparés par un --> d’un seul tenant. La chaîne vide s’écrit donc "" ;
•
toutes les règles se terminent par un point, précédé du mot stop si la règle est terminale ;
•
la dernière règle est suivie de eof puis d’un point.
Il s’agit là d’un choix arbitraire, motivé au paragraphe suivant. Le lecteur peut imaginer d’autres choix syntaxiques à son gré. L’exemple du paragraphe précédent s’écrit en Markovski : /* Permutation des 'a' et des 'b' dans une chaîne */ "1a" --> "b1" . "1b" --> "a1" . "1c" --> "c1" . "1" --> "" stop . "" --> "1" . eof.
Terminologie et exemples
43
Nous supposerons dans les paragraphes suivants ce texte placé dans le fichier Permuter_a_et_b.mkv. 3.9
Un analyseur Markovski
Nous allons maintenant écrire un analyseur lexical, syntaxique et sémantique pour Markovski. Le choix de notre syntaxe particulière est dû à ce qu’elle est un sousensemble de celle de Prolog. Comme Prolog est un sur-langage de Markovski, tout analyseur lexical et syntaxique Prolog est aussi un analyseur Markovski, sans travail supplémentaire. En fait, la syntaxe de Prolog ne connaît pas d’opérateurs --> et stop, mais on peut définir des opérateurs nouveaux dans ce langage, ce que nous faisons avec : :- op( 50, xfx, --> ). :- op( 10, xf , stop ).
ce qui signifie que --> est un opérateur infixé non associatif de priorité 50, et que stop est un opérateur postfixé non associatif de priorité 10. En Prolog, un opérateur est d’autant plus prioritaire que la valeur spécifiant sa priorité est faible. Le paragraphe 4.13, contient une discussion détaillée de la gestion des opérateurs.
Ayant défini les opérateurs ci-dessus, il suffit d’utiliser le prédicat prédéfini read de Prolog pour analyser lexicalement et syntaxiquement une règle Markovski, comme dans : ?- read( UneRegle ), display( UneRegle ).
Si l’on tape alors : "1a" --> "b1" .
on obtient comme résultat : -->([49,97],[98,49]) UneRegle = [49,97] --> [98,49]
et si l’on tape : "1"
--> ""
stop .
on obtient comme résultat : -->([49],stop([])) UneRegle = [49] --> [] stop
Dans la syntaxe Prolog dite “d’Edinburgh“ utilisée ici, les chaînes de caractères sont représentées par des listes de codes ASCII, comme [49,97]. Les erreurs lexicales et syntaxiques, au sens de la syntaxe Prolog, sont directement prises en charge par read. L’analyse sémantique se limitera dans le cas de Markovski à filtrer les phrases syntaxiquement acceptables dans le sur-langage Prolog.
44
Compilateurs avec C++
Nous n’acceptons que des chaînes de caractères séparées syntaxiquement par l’opérateur -->, la seconde étant éventuellement suivie de l’opérateur stop. Ainsi : 1
--> ""
stop .
doit être rejeté avec un message d’erreur : *** 1 n'est pas une chaîne entre guillemets ***
Le résultat de l’analyse, lorsque les règles Markovski analysées sont correctes lexicalement, syntaxiquement et sémantiquement, est de stocker une forme interne de ces règles au moyen d’une relation que nous baptisons regle. Ainsi, après analyse du programme qui permute les a et les b, on obtient par la requête Prolog : ?- listing( regle ). /* regle/3 */ regle([49,97],[98,49],continuer). regle([49,98],[97,49],continuer). regle([49,99],[99,49],continuer). regle([49],[], (stop)). regle([],[49],continuer). yes
On voit que le résultat est calqué directement sur la forme source des règles : on a simplement explicité le fait que les règles non terminales conduisent à l’action continuer plutôt que stop. En ce sens, c’est une forme sémantique que nous avons obtenue par l’analyseur Markovski. L’analyseur n’est donc dans ce cas guère plus qu’un accepteur effectuant des contrôles et rejetant les règles mal formées.
Le code source Prolog de cet analyseur contient notamment : lire_les_regles :retractall( regle( _, _, _ ) ), /* on détruit toute règle éventuelle connue */ repeat, read( Regle ), /* analyse lexicale et syntaxique Prolog */ ( ( Regle = eof, ! ) /* fin du fichier, on sort */ ; ( decomposer_la_regle(Regle, Gauche, Droite, Continuation), verifier_la_chaine( Gauche ), verifier_la_chaine( Droite ), /* on stocke la règle dans un format interne */ assertz(regle(Gauche, Droite, Continuation)), /* on force un retour arrière pour épuiser les règles du fichier */ fail ) ).
Terminologie et exemples
45
L’analyseur Markovski décrit ici constitue la première passe des deux compilateurs présentés au paragraphe 3.11 et au paragraphe 3.13. 3.10
Un interprète Markovski
Comment maintenant donner une signification au programme Markovski que nous venons d’analyser avec succès ? Une possibilité est d’exécuter le graphe de code contenu dans la relation regle, en appliquant la façon de procéder indiquée au paragraphe 3.7. Rappelons que l’on doit repartir du début des règles en cas d’application de l’une d’elles, et que l’exécution peut se terminer soir par application d’une règle terminale, soit par épuisement des règles, c’est-à-dire lorsqu’on parcourt toutes les règles sans qu’aucune d’elles ne soit applicable. Le graphe de code de notre programme d’exemple Markovski peut être visualisé de la façon présentée à la figure 3.3. Nous verrons d’autres parcours de graphes pour l’évaluation dans le chapitre 10.
DEBUT
"1a" --> "b1"
"1b" --> "a1"
"1c" --> "c1"
"1" --> ""
""
--> "1"
FIN
3.3Graphe du code pour l’exemple Markovski Notre interprète Markovski va donc itérer en parcourant la table des règles sous forme sémantique, en essayant d’appliquer chacune à son tour, c’est-à-dire en regardant si son membre gauche apparaît dans la chaîne de travail courante. En cas
46
Compilateurs avec C++
de succès, on applique la règle et on recommence au début, et en cas d’échec on essaie la règle suivante, dans l’ordre textuel. Sur l’exemple de donnée acabac, cela donne la trace d’exécution suivante, dans laquelle nous avons mis en évidence la chaîne membre droit utilisée à chaque application d’une règle : Chaîne de départ? "acabac". ---> 1acabac ---> b1cabac ---> bc1abac ---> bcb1bac ---> bcba1ac ---> bcbab1c ---> bcbabc1 *** Une règle terminale a été appliquée *** Chaîne résultante: bcbabc Autre exécution (_/non) ? non. *** Au revoir! *** yes
Le code Prolog de cet interprète s’appuie sur les prédicats suivants : interpreter_markovski(Chaine_de_travail, Resultat) :regle( Membre_gauche, Membre_droit, Continuation ), regle_applicable( Membre_gauche, Membre_droit, Chaine_de_travail, Intermediaire ), ! ,/* seul le premier remplacement est désiré */ continuation( Continuation, Intermediaire, Resultat ). interpreter_markovski(Chaine_de_travail, Chaine_de_travail) :write( '*** Plus aucune règle n''est applicable ***'), nl. /* on retourne la chaîne de travail comme résultat */ continuation( continuer, Intermediaire, Resultat ) :write( '---> ' ), ecrire_la_chaine( Intermediaire ), nl, /* trace d'exécution */ interpreter_markovski( Intermediaire, Resultat ). continuation( stop, Intermediaire, Intermediaire ) :write( '*** Une règle terminale a été appliquée ***'), nl.
Là encore, on note la structure de boucle de l’algorithme d’interprétation. Le lecteur aura remarqué que le code ci-dessus utilise deux prédicats interpreter_markovski, l’un ayant deux arguments et l’autre aucun. Cela ne pose pas de problème car ils sont distingués par leur arité ou nombre d’arguments : il s’agit tout simplement d’un cas de surcharge sémantique.
Terminologie et exemples
3.11
47
Un compilateur de Markovski vers Pascal
Si la caractéristique d’un interprète programmé est la souplesse de développement, il présente, comme on l’a mentionné au paragraphe 3.3, une certaine perte d’efficacité potentielle. Dans le cas de celui du paragraphe précédent, cette lourdeur vient de ce qu’on doit parcourir une relation Prolog très souvent : cela n’est pas aussi rapide que faire des débranchements successifs, comme dans le code synthétisé par le compilateur présenté dans le présent paragraphe. Nous allons maintenant montrer comment obtenir un programme Pascal de même sémantique que notre exemple Markovski, à l’aide d’un compilateur de Markovski vers Pascal. Il s’appuie sur la forme sémantique des règles stockées dans la table regle, et la parcourt une fois en produisant au passage le code objet en langage Pascal. La variante de Pascal synthétisée ici est ThinkPascal, disponible sur Macintosh. En compilant le source Markovski Permuter_a_et_b.mkv avec ce premier compilateur, on obtient un fichier de texte Pascal Permuter_a_et_b.p contenant : program Permuter_a_et_b; label 1, 9; var chaine_de_travail: Str255; function regle_applicable (modele, ersatz: Str255): boolean; var position: integer; begin (* Pos retourne la position du premier caractère *) (* du modèle dans la chaîne de travail, 0 si pas trouvé *) (* Copy extrait la sous-chaîne de longueur arg. 3 *) (* d'une chaîne donnée (arg. 1) *) (* à partir de la position arg. 2, retourne '' si pas trouvé *) position := Pos (modele, chaine_de_travail); if position <> 0 then begin chaine_de_travail := Concat( Copy (chaine_de_travail, 1, Pred (position)), ersatz, Copy( chaine_de_travail, position + Length (modele), Length (chaine_de_travail) - Pred (position + Length(modele) ) )); regle_applicable := true end else regle_applicable := false end; (* regle_applicable *)
48
Compilateurs avec C++
begin (* Permuter_a_et_b *) writeln('*** Exécution de ', 'Permuter_a_et_b', ' ***'); write('Chaîne initiale: '); readln(chaine_de_travail); writeln; 1: ; writeln('--> ', chaine_de_travail); if regle_applicable goto 1; if regle_applicable goto 1; if regle_applicable goto 1; if regle_applicable goto 9; if regle_applicable goto 1;
('1a', 'b1') then ('1b', 'a1') then ('1c', 'c1') then ('1', '') then ('', '1') then
9: ; writeln; writeln ('Résultat = ', chaine_de_travail); end. (* Permuter_a_et_b *)
Le lecteur se convaincra sans peine que la sémantique de ce code objet est bien la même que celle de la spécification Markovski originale, à part le fait que la chaîne de travail est limitée à 255 caractères, là où le langage et l’interprète écrit en Prolog ne mettent aucune restriction particulière. Le lecteur retrouvera dans les goto la boucle d’interprétation typique. Rappelons que nous n’hésitons pas à recourir à un goto à bas niveau lorsque nous recherchons l’efficacité. Cette synthèse de code est fondamentalement basée sur un mécanisme d’instanciation de schémas de code, qui sont dans notre cas : •
le schéma de code contenant le début du programme Pascal, la fonction regle_applicable, l’interaction initiale avec l’utilisateur et le début de la boucle, où l’on imprime la valeur de la chaîne de travail. Ce schéma n’est instancié qu’une fois ;
•
le schéma contenant la fin de la boucle et l’impression de la valeur finale de la chaîne de travail. Ce schéma n’est lui aussi instancié qu’une fois ;
•
le schéma de code pour les règles non terminales, où : "1a" --> "b1" .
devient : if regle_applicable( '1a', 'b1' ) then goto 1;
Ce schéma est instancié une fois pour chaque règle non terminale présente dans le code source Markovski ;
Terminologie et exemples
•
49
le schéma de code pour les règles terminales transformant : "1"
--> ""
stop .
en : if regle_applicable( '1', '' ) then goto 9;
Ce schéma est quant à lui instancié une fois pour chaque règle terminale présente dans le source. Nous verrons au chapitre 12 que ce mécanisme d’instantiation fonctionne aussi pour des schémas de code imbriqués. Comme nous développons un compilateur de Markovski vers C++ au paragraphe 3.13, nous structurons la synthèse de code en une première partie indépendante du langage objet choisi, et une seconde spécifique à ce langage. Le code source Prolog spécifique à la synthèse de code dans le langage objet Pascal contient : synthese_du_debut_du_programme(Nom_du_programme, 'Pascal') :write( 'program ' ), write( Nom_du_programme ), write( ';' ), nl, nl, (* … … … … … … *) write( '1: ;'), nl, write( ' writeln(''--> '', chaine_de_travail);'), nl, nl. synthese_des_regles( 'Pascal' ) :regle( Membre_gauche, Membre_droit, Continuation ), name( Chaine_gauche, Membre_gauche ), name( Chaine_droite, Membre_droit ), write( ' if regle_applicable( '), ecrire_membre( Membre_gauche, pascal ), write( ', '), ecrire_membre( Membre_droit, pascal ), write( ' ) then' ), nl, synthese_de_la_continuation( Continuation, pascal ), nl, fail. /* on force le parcours de toutes les règles */ synthese_des_regles( 'Pascal' ) :nl. /* on sort */ (* … … … … … … … … *) synthese_de_la_continuation( continuer, pascal ) :!, write( ' goto 1;' ). synthese_de_la_continuation( stop, pascal ) :write( ' goto 9;' ). synthese_de_la_fin_du_programme(Nom_du_programme, 'Pascal') :write( '9: ;'), nl, write( ' writeln;'), nl, write( ' writeln(''Résultat = '', chaine_de_travail);'), nl,
50
Compilateurs avec C++
write( ' 3.12
end. (* ' ), write( Nom_du_programme ), write( ' *)' ), nl.
Librairie de support d’exécution pour Markovski
Le défaut de la génération de code Pascal du paragraphe précédent est que le code de la fonction regle_applicable est synthétisé à chaque compilation, pour être ensuite compilé avec le code spécifique à l’algorithme de Markov considéré. Un moyen d’éviter cela est de compiler séparément ce code et de le placer en librairie une fois pour toutes. Une telle librairie de support d’exécution contient typiquement le code des fonctions prédéfinies dans le cas d’un langage algorithmique du genre de Pascal ou C++.
Pour illustrer cela, nous présentons au paragraphe suivant un second cas de synthèse de code objet pour Markovski, mais en C++ cette fois, avec des chaînes de travail limitées à 2000 caractères. Ce code objet s’appuie sur la librairie C++ décrite ci-dessous. L’interface, à savoir le fichier MarkovskiSupport.h contient : #include const k_taille_chaine = 2000; typedef char chaine [k_taille_chaine]; extern Boolean regle_applicable ( chaine travail, chaine modele, chaine ersatz );
L’implantation quant à elle est faite dans le fichier MarkovskiSupport.cp, contenant : #include "MarkovskiSupport.h" #include <string.h> Boolean regle_applicable ( chaine travail, chaine modele, chaine ersatz ) { // strstr retourne la position du premier caractère // du modèle dans la chaîne de travail, NULL si pas trouvé // memmove copie la chaine arg. 2 à l'adresse donnée (arg. 1) // mais au plus arg. 3 caractères, et fonctionne correctement // si source et destination se recoupent if (strcmp (modele, "") == 0) { // insertion au debut de la chaine de travail // … … … … … … return true; } else { // char
insertion au milieu de la chaine de travail * occurrence = strstr (travail, modele);
Terminologie et exemples
51
if (occurrence != NULL) { // … … … … … … return true; } else return false; } } // regle_applicable
Il aurait été possible d’illustrer la même fonctionnalité avec Pascal comme langage objet, mais nous avons préféré montrer la variété de situations possibles en compilation. 3.13
Un compilateur de Markovski vers C++
Pour mettre en œuvre la librairie de support d’exécution présentée au paragraphe précédent, nous écrivons une autre synthèse de code qui en tient compte. Le contenu du fichier de texte C++ Permuter_a_et_b.cp, résultant de la compilation du source Markovski Permuter_a_et_b.mkv par ce second compilateur, est : // Permuter_a_et_b #include <stream.h> #include "MarkovskiSupport.h" main () // Permuter_a_et_b { chaine chaine_de_travail; cout << "*** Exécution de Permuter_a_et_b ***\n"; cout << "Chaîne initiale: "; cin >> chaine_de_travail; cout << "\n"; while (true) { cout << "--> " << chaine_de_travail << "\n"; if (regle_applicable( chaine_de_travail, "1a", "b1" )) continue; if (regle_applicable( chaine_de_travail, "1b", "a1" )) continue; if (regle_applicable( chaine_de_travail, "1c", "c1" )) continue; if (regle_applicable( chaine_de_travail, "1", "" )) break; if (regle_applicable( chaine_de_travail, "", "1" )) continue; } // while
52
Compilateurs avec C++
cout << "\n"; cout << "Résultat } // Permuter_a_et_b
= " << chaine_de_travail << "\n";
On voit que la structure du code est sémantiquement la même, bien que les goto aient disparu au profit des instructions continue et break, ce qui fait que la boucle d’interprétation, explicite, est écrite comme une boucle infinie. Il est clair sur cet exemple que le code objet à compiler par C++ ne contient pas la fonction regle_applicable, d’où une économie sur la taille du code à compiler à chaque fois. De plus, toute modification apportée à la librairie sera répercutée avec une simple édition de liens, sans re-compilation par C++ du code résultant de la compilation de Markovski en C++. La partie spécifique au langage objet C++ dans notre compilateur Markovski est similaire à ce que nous avons montré pour Pascal au paragraphe 3.11, et ne sera pas détaillée ici. Le code C++ synthétisé à partir du code source Markovski importe la librairie de support d’exécution au moyen de : #include "MarkovskiSupport.h" 3.14
Compilation séparée, compilation indépendante
La plupart des langages récents permettent de compiler des fichiers textuels distincts appartenant à un tout. L’exemple des fichiers “.h“ et “.cp“ en C++ au paragraphe 3.12, illustre cette façon de structurer les sources d’une application. On parle de compilation séparée en Modula-2 et en C, par exemple, pour indiquer que cette subdivision en plusieurs fichiers n’empêche pas les contrôles sémantiques effectués sur les sources en tenant compte des autres fichiers importés. Par contraste, les versions initiales du langage Fortran ne faisaient que de la compilation indépendante : il était possible de compiler dans un fichier une fonction à deux paramètres formels et de l’appeler avec zéro, un ou deux paramètre(s) depuis un autre fichier, sans que le compilateur ne produise de message. En fait, chaque fichier était compilé sans aucune notion des autres dans cette approche. Il va sans dire que la tendance moderne est de contrôler le plus de choses possible statiquement, à la compilation. En fait, Ada va même jusqu’à intégrer la gestion des librairies de fichiers compilés séparément dans la norme du langage, ce qui en assure la portabilité.
Terminologie et exemples
3.15
53
Autointerprétation et autocompilation
Une des notions les plus difficiles à saisir en compilation est la possibilité d’utiliser certains langages pour les implanter eux-mêmes. Il y a là une circularité délicate à assimiler. Un autointerprète est un interprète écrit dans le langage dont il peut interpréter les programmes. On parle aussi d’interprète méta-circulaire (meta-circular interpreter). Pour illustrer un cas d’auto-interprète, nous avons choisi d’implanter Prolog en Prolog, c’est-à-dire d’écrire un programme Prolog pouvant exécuter des requêtes Prolog comme le fait tout interprète Prolog. Un tel programme est classique dans ce langage. Nous devons convenir de la façon de représenter les faits et règles du programme original. Nous les stockons dans deux relations fait et regle. Cela est fait de la manière suivante, les commentaires étant destinés à fournir une justification des conclusions auxquelles on arrive : /* fait( Commentaire, LeFait ) */ fait( 'La contrebasse est encombrante', encombrant(contrebasse) ). fait( 'La harpe est encombrante', encombrant(harpe) ). fait( 'Le piano est encombrant', encombrant(piano) ). fait( 'La contrebasse est peu répandue', peu_repandu( contrebasse) ). fait( 'La harpe est peu répandue', peu_repandu( harpe) ). :- op( 51, xfy, & ). /* et logique */ /* regle( Commentaire, Conclusion, Prémisses ) */ regle( 'Il faut un véhicule si l''instrument est encombrant et peu répandu', il_faut_un_vehicule_pour( Instrument ), encombrant( Instrument ) & peu_repandu( Instrument ) ).
Le choix de l’opérateur & pour encoder le “et“ logique est une convention arbitraire. Les composants des relations fait et regle sont utilisés comme données par l’auto-interprète Prolog, dont le cœur est : vrai( Conclusion, Commentaire ) :fait( Commentaire, Conclusion ). vrai( Conclusion, PremierCommentaire & Commentaire ) :regle( Commentaire, Conclusion, Premisse ), premisse_vraie( Premisse, PremierCommentaire ). premisse_vraie( Premisse, Commentaire ) :vrai( Commentaire, Premisse ).
54
Compilateurs avec C++
premisse_vraie( Premisse & AutrePremisse, Commentaire & AutreCommentaire ) :vrai( Premisse, Commentaire ), vrai( AutrePremisse, AutreCommentaire ).
Le code ci-dessus spécifie que : •
un fait connu est vrai ;
•
la conclusion d’une règle est vraie si ses prémisses sont vraies ;
•
une prémisse simple est vraie si elle est vraie ;
•
une prémisse qui est une conjonction d’autres prémisses, reliées par un “et“ logique, est vraie si toutes ses prémisses conjointes sont vraies.
L’auto-interprète Prolog ci-dessus fonctionne des conclusions vers les prémisses, soit en chaînage arrière. Il semble difficile de faire plus simple que ces quatre clauses, étant donné que nous devons gérer le cas des faits et des règles (les deux clauses de vrai), et qu’un corps de règle ou une requête sont soit simple, soit conjointe (les deux clauses de premisse_vraie). Nous ajoutons une interface permettant de démontrer différents théorèmes et expliquant comment on y arrive : demontrer( Theoreme ) :vrai( Theoreme, Explication ), nl, write( 'Le théorème : ' ), write( Theoreme ), nl, write( 'peut être démontré par le chaînage:' ), nl, ecrire_explication( Explication ), nl, fail. demontrer( _ ).
Existe-t-il un instrument pour lequel il faut prévoir un véhicule ? La requête permettant de la savoir est : ?- demontrer( il_faut_un_vehicule_pour(Instr) ).
Elle produit comme résultat : Le théorème : il_faut_un_vehicule_pour(contrebasse) peut être démontré par le chaînage: Fait : La contrebasse est encombrante Fait : La contrebasse est peu répandue Règle : Il faut un véhicule si l'instrument est encombrant et peu répandu Le théorème : il_faut_un_vehicule_pour(harpe) peut être démontré par le chaînage: Fait : La harpe est encombrante Fait : La harpe est peu répandue Règle : Il faut un véhicule si l'instrument est encombrant et peu répandu
Il est inutile de prévoir un véhicule pour l’instrument encombrant qu’est le piano parce qu’on en trouve en maint endroit.
Terminologie et exemples
55
On voit sur cet exemple toute la puissance d’un langage comme Prolog. La littérature contient beaucoup d’exemples d’auto-interprètes dans des langages fonctionnels, comme Lisp [Winston & Horn 84] ou Scheme : nous avons préféré en montrer un qui soit un peu moins connu. De plus, il est plus court en Prolog qu’en ces autres langages ! Un autocompilateur est un compilateur écrit dans le langage qu’il peut compiler. Nous ne présentons pas le mécanisme d’autocompilation ni le portage d’un autocompilateur d’une architecture à une autre, qui sortent du cadre de ce livre. 3.16
Générateurs de compilateurs
On parle en anglais de “compiler compiler“ (compilateur de compilateurs), pour désigner les générateurs de compilateurs, qui réalisent la synthèse automatique de compilateurs. Ce terme est en fait mal choisi car c’est une grammaire que l’on compile pour obtenir un compilateur, et non pas un autre compilateur. Il serait plus approprié de parler de “grammar compiler“ ou de “‘compiler generator“. Des formalismes plus puissants que les grammaires de Chomsky, présentées au chapitre 4, sont nécessaires pour décrire simultanément la syntaxe et la sémantique des langages. Les grammaires que l’on soumet à Lex, comme celles que l’on soumet à Yacc, entrent dans la catégorie des grammaires décorées : elles spécifient la syntaxe du langage plus d’autres choses, notamment les aspect sémantiques de ce langage. Les grammaires d’attributs (attribute grammars) en font aussi partie. Nous présentons Lex et Yacc dans le chapitre 6 et le chapitre 9, respectivement. 3.17
Exercices
3.1 : Addition binaire en Markovski (moyen). Programmer en Markovski un algorithme d’addition binaire. En partant d’une chaîne de travail formée de 0, de 1, de + et de -, on doit obtenir comme chaîne résultante la valeur de l’expression correspondante, en binaire elle aussi.
Par exemple, la chaîne de travail 01+0111 devrait être transformée en la chaîne résultante 1000. 3.2 : Implantation Markovski (facile). Ecrire un compilateur-interpréteur Markovski dans un langage autre que Prolog, en vous inspirant des implantations présentées dans ce chapitre.
56
Compilateurs avec C++
Chapitre
4
4 Grammaires formelles
Les langages sont définis syntaxiquement par des grammaires décrivant quelles phrases appartiennent au langage. L’analyse syntaxique s’appuie alors sur un algorithme d’analyse adéquat selon la forme de la grammaire. Dans ce chapitre consacré aux grammaires, l’accent sera mis sur les possibilités d’expression de la syntaxe d’un langage, tandis que les chapitres suivants seront dédiés à différentes techniques d’analyse lexicale et syntaxique.
Les grammaires formelles sont à la base des techniques d’analyse textuelle. Nous nous limitons dans ce chapitre à ce qui est nécessaire pour écrire des compilateurs, en particulier avec les outils Lex et Yacc. 4.1
Notion de grammaire
Un langage est un ensemble de phrases satisfaisant à une certaine forme. Chaque phrase du langage est une séquence de mots formés à partir d’un alphabet. Un terminal est un mot, soit une séquence de caractères. Une phrase est une séquence de mots. Par exemple, Pascal est l’ensemble des phrases, c’est-à-dire des textes sources, répondant à certaines règles de bonne forme. Chaque phrase est composée de ter-
58
Compilateurs avec C++
minaux comme 3, begin ou integer. L’alphabet dans lequel on écrit les phrases Pascal est un sous-ensemble du jeu de caractères ASCII. Une grammaire est un quadruplet : (terminaux, non_terminaux, productions, axiome). Dans le quadruplet définissant une grammaire : •
”terminaux” est l’ensemble des mots formant les phrases ;
•
non ”non_terminaux” est l’ensemble des notions dites non terminales décrivant des morceaux de phrases ;
•
les ensembles “terminaux“ et “non_terminaux“ sont disjoints ;
•
”productions” est l’ensemble des règles grammaticales appelées aussi productions. Chaque production est de la forme : tête ⇒ corps où tête et corps sont des séquences de terminaux ou non terminaux, et où tête contient au moins un non terminal ;
•
”axiome” est une notion non terminale particulière qui est la racine de la grammaire.
Soit par exemple le quadruplet : G1 = ( {
/* Terminaux */ "a", "b", "c", "d", "+", "-", "*", "/", "(", ")", "^"
}, {
/* Non_terminaux */ expression, facteur
}, {
/* Productions */ expression expression expression expression expression
⇒ ⇒ ⇒ ⇒ ⇒
facteur facteur facteur facteur
"a", "b", "c", "d",
⇒ ⇒ ⇒ ⇒
facteur, expression expression expression expression
"+" "-" "*" "/"
expression, expression, expression, expression,
facteur ⇒ "(" expression ")", facteur ⇒ facteur "^" facteur }, expression )
/* Axiome */
Grammaires formelles
59
La notation que nous utilisons pour les productions des grammaires est celle dite “forme de Backus-Naur étendue“ ( (Extended Backus-Naur Form), en abrégé EBNF. Les terminaux y apparaissent entre des guillemets ("), tandis que les non terminaux apparaissent tels quels. G1 est une grammaire au sens défini ci-dessus. Elle peut être utilisée pour décrire des expressions algébriques au sens usuel du terme, où ^ est l’opérateur d’exponentiation. On voit que : •
les terminaux sont les briques servant à construire les phrases, les “mots“ du langage ;
•
les notions non terminales expression et facteur servent à donner une structure aux phrases ;
•
les productions définissent la forme des notions non terminales en fonction des autres notions non terminales et des terminaux ;
•
l’axiome est la notion non terminale “de plus haut niveau“, dans notre cas expression puisque nous avons écrit une grammaire des expressions.
4.2
Notations condensées
Pour alléger l’écriture des grammaires, on a défini des écritures condensées : •
{sequence} indique la répétition 0 fois ou plus de sequence ;
•
[sequence] indique la répétition 0 ou 1 fois de sequence ;
•
sequence{sequence} indique la répétition 1 fois ou plus de sequence ;
•
sequence1|sequence2 indique le choix entre sequence1 et sequence2 ;
•
(sequence) a la même signification que sequence.
Par l’emploi de “|“ et “(…)“, on peut factoriser le corps les productions, c’est-à-dire mettre des descriptions partielles en commun, comme on le fait dans la notation algébrique usuelle. On peut ainsi ré-écrire les productions de la grammaire G1 sous la forme compacte : {
/* Productions */ expression ⇒ facteur | expression { "+" | "-" | "*" | "/" ) expression, facteur ⇒ "a" | "b" | "c" | "d" | "(" expression ")" | facteur "^" facteur
}
Les diagrammes syntaxiques sont une représentation graphique des productions grammaticales. La notions facteur de G1 peut être représentés par le dia-
60
Compilateurs avec C++
gramme syntaxique de la figure 4.1. Nous avons encadré les notions non terminales par des rectangles et les terminaux par des ovales. Cette notation très visuelle et explicite est souvent utilisée. facteur a
b
c
d
(
expression
facteur
^
)
facteur
4.1Exemple de diagramme syntaxique 4.3
Dérivation et réduction
Une dérivation élémentaire est l’emploi d’une production grammaticale de gauche à droite. On dit que dans la grammaire G1 le non terminal expression se dérive en facteur au vu de la première production, ce que l’on note : expression ⇒ facteur
Comme de plus facteur se dérive en b : facteur ⇒ b
on dit que expression se dérive de manière transitive, par un enchaînement de dérivations, en b, ce que l’on écrit : expression ⇒* b
Grammaires formelles
61
L’étoile * dans ⇒* indique cet enchaînement de dérivations élémentaires, par lequel nous avons dérivé la notion non terminale expression en b, qui est bien sûr une expression particulière.
On appelle dérivation un enchaînement de dérivations élémentaires. Dans l’exemple ci-dessus, les dérivations élémentaires successives nous ont fait passer d’une notion non terminale à une phrase du langage.
Une réduction élémentaire est l’emploi d’une production grammaticale de droite à gauche. On dit que dans la grammaire G1, a se réduit en facteur, ce que nous notons, avec la flèche → au lieu de ⇒ pour indiquer que la production est cette fois utilisée de droite à gauche : a → facteur
De même, d se réduit aussi en facteur : d → facteur
et nous dirons que a^d se réduit en facteur^facteur : a^d → facteur^facteur
Continuons notre séquence de réductions : facteur ^ facteur se réduit en facteur : facteur^facteur → facteur
puis facteur se réduit en expression : facteur → expression
Par un enchaînement de réductions élémentaires, nous avons réduit a^d en expression, ce que nous écrirons : a^d →* expression
On appelle réduction un enchaînement de réductions élémentaires. Là encore l’étoile * dans →* symbolise l’enchaînement des réductions élémentaires. Dans l’exemple ci-dessus, les réductions élémentaires successives nous ont fait passer d’une séquence de terminaux du langage à une notion non terminale. 4.4
Arbres de dérivation
On remarque que la réduction de a^d en facteur au paragraphe précédent n’est pas linéaire, puisqu’on fait les réductions de a et d indépendamment l’une de l’autre, pour combiner ensuite les résultats et les réduire à leur tour. En fait, les
62
Compilateurs avec C++
enchaînements de dérivations élémentaires et de réductions élémentaires que nous avons effectués pour obtenir les dérivations et réductions ci-dessus correspondent à des traversées d’un arbre. Un arbre de dérivation (derivation tree) illustre le fait qu’une séquence de terminaux peut être réduite à, ou dérivée de, une notion non terminale de la grammaire. Dans le cas de la séquence de terminaux a^d et de la notion expression, un arbre de dérivation peut avoir la forme illustrée à la figure 4.2. La question de l’unicité des arbres de dérivation est traitée au paragraphe 4.6. expression
facteur
facteur
a
facteur
^
d
4.2Exemple d’arbre de dérivation Il est très important de comprendre le lien entre dérivation, réduction et arbre de dérivation. C’est la notion d’arbre de dérivation qui justifie les noms “terminaux“ et “non terminaux“ : •
les feuilles de l’arbre de dérivation terminent ses branches, d’où leur nom de “notions terminales“ ;
•
les nœuds correspondent à l’emploi des productions de la grammaire, d’où le nom de “notions non terminales“ par opposition aux feuilles de l’arbre.
Une dérivation est une traversée de haut en bas (top-down) de l’arbre de dérivation, de la racine vers les feuilles. Une réduction est une traversée de bas en haut (bottom-up) de l’arbre de dérivation, des feuilles vers la racine. L’ordre dans lequel les dérivations ou réductions élémentaires sont effectuées n’a pas d’importance : seule compte la structure de l’arbre de dérivation ainsi obtenu.
Grammaires formelles
4.5
63
Langage engendré par une grammaire
Le langage engendré par une grammaire est l’ensemble des séquences de terminaux que l’on peut dériver de l’axiome, ou de manière équivalente, qui peuvent être réduites en l’axiome. Nous avons montré au paragraphe 4.3, que b et a^d appartiennent au langage engendré par la grammaire G1. Ces séquences de terminaux sont, par abus de langage, des expression.
Le problème central dont traite l’analyse des langages est : “une séquence donnée de terminaux d’un certain langage appartient-elle à ce langage ?“. Il s’agit donc de construire, pour les besoins de la compilation, des accepteurs des langages que l’on désire implanter. En pratique, la question ci-dessus devient “un texte donné est-il du C++ bien formé“, par exemple. On appelle accepteur d’une grammaire une fonction booléenne retournant la valeur “vrai“ si une séquence de terminaux fait partie du langage engendré par cette grammaire, et “faux“ sinon. D’un point de vue concret, on pratique comme suit. Une séquence de terminaux est une phrase du langage engendré par une grammaire si l’on peut construire un arbre de dérivation pour cette séquence à partir de l’axiome en utilisant les productions. Comme nous l’avons vu dans les paragraphes qui précèdent, les productions d’une grammaire peuvent être utilisées : •
en synthèse, pour construire les phrases du langage engendré par dérivation ;
•
en analyse, pour déterminer si une séquence de terminaux est une phrase du langage engendré.
Nous verrons au paragraphe 4.14, les limites théoriques de ce que l’on rencontre dans le domaine des langages engendrés par les grammaires.
64
Compilateurs avec C++
4.6
Grammaires ambiguës
Une grammaire est ambiguë s’il existe une phrase du langage engendré pour laquelle plusieurs arbres de dérivation distincts peuvent être construits. Une phrase ayant plusieurs arbres de dérivation appartient donc au langage engendré d’autant de manières différentes.
La grammaire G1 est fortement ambiguë à cause des productions : expression expression expression expression
⇒ ⇒ ⇒ ⇒
expression expression expression expression
"+" "-" "*" "/"
expression, expression, expression, expression,
et : facteur ⇒ facteur "^" facteur Le langage engendré par G1 contient la phrase a+b*d, pour laquelle deux arbres de dérivation distincts existent, présentés à la figure 4.3. Rappelons encore que l’ordre dans lequel les arbres de dérivation sont obtenus n’a pas importance : seule leur structure compte.
L’ambiguïté vient dans G1 du choix pour l’association des opérandes aux opérateurs comme + et *. Nous verrons, au chapitre 7, que certaines méthodes d’analyse sont inapplicables en cas d’ambiguïté. Bien que l’ambiguïté soit en général gênante en informatique, il est possible d’utiliser des grammaires ambiguës, mais cela sort du cadre de ce livre. On évite surtout les grammaires ambiguës parce que l’analyse syntaxique ne peut pas être aussi efficace dans ce cas que pour des grammaires non-ambiguës. 4.7
Productions récursives
Il est extrêmement fréquent de rencontrer des productions récursives dans la pratique de la compilation. Ces récursions peuvent être caractérisées de la même manière que dans le cas des langages de programmation eux-mêmes : •
une production de la forme : notion ⇒ notion …
est dite récursive à gauche ; •
une production de la forme : notion ⇒ … notion
est dite récursive à droite ou récursive terminale. En pratique, la plupart des grammaires ont des productions récursives : c’est ce qui donne leur puissance descriptive. Pour s’en convaincre, on peut penser au déclin puis à l’abandon de la notation en chiffres romains pour les nombres entiers, face à la notation arabe en usage aujourd’hui.
Grammaires formelles
65
expression
expression expression
expression
expression
facteur
facteur
facteur
a
+
b
*
d
expression
expression
expression
expression
facteur a
expression
facteur +
b
facteur *
d
4.3Arbres de dérivation multiples en cas d’ambiguïté Le problème de la notation romaine est qu’elle n’a qu’un ensemble fini de terminaux, et pas de productions récursives : cela fait que seul un petit ensemble de nombres sont exprimables.
En revanche, la notation arabe, qui peut être définie par les productions : chiffre ⇒ "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ; nombre ⇒ chiffre, nombre ⇒ chiffre nombre;
permet de noter n’importe quel nombre entier non-négatif. L’existence du zéro pour dénoter l’absence est importante, mais la présence de productions récursives l’est au moins autant. Il s’agit là des deux cas minimaux nécessaires à toute récursion bien formée, à savoir le cas qui fait “avancer“ la récursion, et le cas qui la fait “se terminer“.
66
4.8
Compilateurs avec C++
Classification de Chomsky
Chomsky [Chomsky 69] a tenté vers les années 50 de définir des grammaires formelles pour les langues naturelles. Il pensait au départ que cela devrait toujours être faisable, quelle que soit la langue considérée. Plus tard, il a dû admettre que toute grammaire formelle qu’on peut écrire est largement insuffisante pour rendre compte de la richesse syntaxique des langues naturelles. Les recherches dans ce sens se sont depuis lors orientées vers des grammaires sémantiques, dont celles acceptées par les outils Lex et Yacc sont des exemples. Il est d’ailleurs intéressant de remarquer que ce même intérêt pour le traitement informatique des langues naturelles a conduit à la création du langage Prolog. La classification de Chomsky se fait selon la forme des productions en quatre types numérotés de 3 à 0. Nous allons caractériser les types 3 et 2 dans les paragraphes suivants, les types 1 et 0 n’étant pas utilisés en compilation. Chomsky a qualifié ces quatre types de “grammaires génératives“, avec un point de vue orienté vers la synthèse de phrases du langage engendré. Bien entendu, elles servent aussi à l’analyse de séquences de terminaux pour déterminer si elles sont des phrases de ce langage.
Toute grammaire du type 3 de Chomsky est aussi du type 2. 4.9
Grammaires du type 3
Le type 3 de Chomsky est celui des grammaires régulières (regular grammars). Toutes les productions sont de la forme : notion ⇒ terminal
ou : notion ⇒ terminal autre_notion_ou_la_même
auquel cas elle est dite régulière à droite. De manière duale, une grammaire dont toutes les productions sont de la forme : notion ⇒ terminal
ou : notion ⇒ autre_notion_ou_la_même terminal
est dite régulière à gauche. Le type 3 est bien adapté pour l’analyse lexicale et conduit à une analyse par un automate fini déterministe.
Grammaires formelles
67
Soit la grammaire suivante des identificateur : G2 = ( {
/* Terminaux */ "a", "b", "c", …, "z", "A", "B", "C", …, "Z", "0", "1", "2", …, "9", "_",
}, {
/* Non_terminaux */ identificateur,
}, {
/* Productions */ identificateur ⇒ "a" | "b" | "c" | … | "z" | "A" | "B" | "C" | … | "Z", identificateur ⇒ identificateur ( "a" | "b" | "c" | … | "z" | "A" | "B" | "C" | … | "Z" "0" | "1" | "2" | … | "9" | "_" )
}, identificateur )
/* Axiome */
G2 est régulière à gauche, donc du type 3. La seule production récursive est la seconde définissant la notion non terminale identificateur. Nous avons utilisé la factorisation et les points de suspension “…“ pour alléger l’écriture. L’axiome identificateur définit la notion courante d’identificateur dans les langages de programmation. On pourrait la compléter en admettant les caractères accentués, ce qui n’est pas fréquent dans les langages de programmation courants. Le langage engendré par la grammaire G2 contient les phrases H2SO4 et bien_le_bonJour, mais pas _235 ni 4jours. On parle aussi d’expressions régulières (regular expressions) pour désigner les grammaires du type 3. Il s’agit d’une notation très contractée spécifique permettant par exemple de définir la notion identificateur spécifiée par G2 au moyen de : [a-zA-Z][a-zA-Z0-9_]*
Dans cette notation, les spécifications entre crochets [ et ] indiquent un choix, et le trait d’union “-“ désigne un intervalle, comme nous l’avons fait avec “…“ dans les productions de G2. L’étoile * suivant un spécification indique une répétition 0 fois ou plus de ce qui précède. Attention, les espaces sont significatifs dans les expressions régulières ! On trouve beaucoup d’emplois des expressions régulières, dans les langages de commandes des ordinateurs actuels par exemple, de même que dans les outils
68
Compilateurs avec C++
de manipulation de textes classiques sur Unix. Nous verrons au chapitre 6 que Lex est fondamentalement basé sur des expressions régulières. 4.10
Grammaires du type 2
Le type 2 de Chomsky est celui des grammaires indépendantes du contexte, (context-free grammars). Il y a exactement un non terminal dans la tête des productions, qui sont de la forme : notion ⇒ séquence_de_terminaux_ou_non_terminaux
Si le corps d’une production est vide, c’est-à-dire qu’il n’y ni terminaux ni non terminaux après le marqueur ⇒, on dit que la notion non terminale ainsi définie se dérive en le vide ou engendre le vide. C’est le cas par exemple de instruction en Pascal, langage dans lequel deux “;“ successifs sont séparés par une instruction vide, de même que la paire “; end“, dans cet ordre. On note parfois une production dont le corps est vide sous la forme : séquence_tête ⇒ ε
ou le “epsilon“ ε met en évidence ce corps vide. Nous verrons plus loin que les notions dérivant en le vide jouent un rôle particulier, car elles peuvent poser des problèmes d’analyse. La grammaire G1 que nous avons présentée au début de ce chapitre est du type 2. Ce type est le plus utilisé dans l’analyse syntaxique et conduit à une analyse par un automate à pile, déterministe ou non. Nous verrons cela en détail dans le chapitre 7. L’apport décisif du type 2 par rapport au type 3 est qu’il permet de décrire des phrases parenthésées et imbriquées. Un exemple de cette possibilité est, dans une grammaire de Pascal : instruction ⇒ begin instructions end;
Il est clair qu’il est impossible de décrire une telle notion dans les grammaires du type 3, au vu des restrictions sur la forme des productions. Le terme “indépendance du contexte“ signifie qu’une production d’ûne grammaire du type 2 peut être employée dans n’importe quel contexte pour des dérivations ou des réductions, sans restriction.
Grammaires formelles
4.11
69
Transformations de grammaires
Une grammaire et le langage qu’elle engendre sont deux choses bien distinctes. Il est d’ailleurs souvent possible d’écrire plusieurs grammaires engendrant un langage donné. Par exemple, étant donnés un ensemble de terminaux adéquats pour le langage Pascal, on peut définir ce dernier par : programme ⇒ "program" identificateur ";" corps;
Si l’on veut expliciter la notion non terminale en_tête, on peut aussi écrire : programme ⇒ en_tête corps, en_tête ⇒ "program" identificateur ";"
Deux grammaires Pascal ne différant que de cette manière engendrent évidemment le même langage, mais sont formellement distinctes l’une de l’autre. Deux grammaires engendrant le même langage sont dites équivalentes. Nous verrons, au paragraphe 4.14, les difficultés qu’il y a à montrer que deux grammaires sont équivalentes. 4.12
Suppression de la récursion à gauche
Nous avons mentionné au paragraphe 4.7, que les grammaires récursives à gauche peuvent poser des problèmes pour l’analyse syntaxique. Heureusement, il est possible de ré-écrire une grammaire par suppression de la récursion à gauche. Appliquons cette transformation à notre grammaire G1 : G1 = ( {
/* Terminaux */ "a", "b", "c", "d", "+", "-", "*", "/", "(", ")", "^"
}, {
/* Non_terminaux */ expression, facteur
}, {
/* Productions */ expression expression expression expression expression
⇒ ⇒ ⇒ ⇒ ⇒
facteur, expression expression expression expression
"+" "-" "*" "/"
expression, expression, expression, expression,
70
Compilateurs avec C++
facteur facteur facteur facteur
⇒ ⇒ ⇒ ⇒
"a", "b", "c", "d",
facteur ⇒ "(" expression ")", facteur ⇒ facteur "^" facteur }, expression )
/* Axiome */
Le premier pas consiste à factoriser la récursion à gauche dans la notion non terminale expression au moyen d’une notion auxiliaire fin_expression. Cela conduit aux productions équivalentes : G1’ :
expression ⇒ facteur, expression ⇒ expression fin_expression, fin_expression ⇒ ( "+" | "-" | "*" | "/" ) expression
Le second pas s’appuie alors sur le fait qu’un arbre de dérivation, s’il existe, est fini, c’est-à-dire qu’il a un nombre fini de nœuds. Aussi, toute phrase du langage engendré par G1’ est de la forme montrée à la figure 4.4, puisque toutes les dérivations de expression se font par la production : expression ⇒ expression fin_expression
sauf celle qui se trouve au plus bas de l’arbre, laquelle est dérivée par : expression ⇒ facteur expression expression expression expression
fin_expression fin_expression
fin_expression
facteur
4.4Un arbre de dérivation est fini Dans cet arbre de dérivation, on trouve un premier facteur suivi de 0 fois ou plus une occurrence de fin_expression. Cette dernière est formée de l’un des quatre
Grammaires formelles
71
terminaux représentant les opérations arithmétiques suivi d’un facteur et d’une fin_expression. Ce fait peut être décrit par les productions : G1’’ : expression ⇒ facteur suite_expression, suite_expression ⇒ ε | ( "+" | "-" | "*" | "/" ) facteur suite_expression
En appliquant la même méthode au cas de la notion non terminale facteur, récursive elles aussi dans G1, on aboutit à la grammaire finale G1’’’ : G1’’’ = ( { /* Terminaux */ "a", "b", "c", "d", "+", "-", "*", "/", "(", ")", "^" }, {
/* Non_terminaux */ expression, suite_expression, facteur_elementaire, facteur, suite_facteur
}, {
/* Productions */ expression ⇒ facteur suite_expression, suite_expression ⇒ ε | ( "+" | "-" | "*" | "/" ) facteur suite_expression facteur_elementaire ⇒ "a" | "b" | "c" | "d" | "(" expression ")", facteur ⇒ facteur_elementaire, suite_facteur, suite_facteur ⇒ ε | "^" facteur_elementaire suite_facteur
}, expression )
/* Axiome */
Cette grammaire ne comporte aucune récursion à gauche : les notions suite_expression et suite_facteur sont récursives à droite et dénotent en fait des itérations. La figure 4.5 montre le diagramme syntaxique résultant pour expression. Le lecteur conviendra avec nous que les grammaires G1 et G1’’’ sont équivalentes sans preuve formelle. 4.13
Grammaires d’opérateurs
Dans un langage proposant de nombreux opérateurs de priorités variées, comme Newton, une grammaire du type 2 pour les expression comporte un grand nombre de non terminaux et de productions, du genre de expression et facteur. Les grammaires d’opérateurs sont une alternative intéressante pour décrire la syntaxe de telles expressions.
72
Compilateurs avec C++
expression facteur
+ facteur * /
4.5Exemple de diagramme syntaxique d’une itération La description des opérateurs et opérandes dans ce paragraphe correspond à l’usage qui en est fait dans la notation algébrique usuelle. On appelle opérandes la ou les expressions utilisées par un opérateur pour produire une valeur résultante.
Dans une grammaire d’opérateurs (operator grammar), deux terminaux autres que des opérateurs sont séparés par au moins un opérateur. Trois informations caractérisent un opérateur et sont utilisées lors de l’analyse. Il s’agit de son arité (arity), de sa priorité (precedence) et de son associativité (associativity). Nous précisons ci-dessous ce que recouvrent ces termes. L’opérateur principal d’une expression est celui qui ne fait pas partie d’une expression opérande elle-même d’un autre opérateur. On peut représenter une expression par un arbre dont la racine est l’opérateur principal, et dont les sous-arbres sont les opérandes de ce dernier. Un tel arbre met en évidence l’association des opérandes aux opérateurs, mais il n’est pas un arbre de dérivation. Il explicite la manière de prendre en compte les opérateurs de l’expression. Il est en ce sens plus proche d’une description sémantique de l’expression. L’arité d’un opérateur est le nombre de ses opérandes.
Grammaires formelles
73
La plupart des langages ont des opérateurs d’une arité de un ou deux, appelés opérateurs unaires ou monadiques et opérateurs binaires ou dyadiques, respectivement. Smalltalk 80 est un exemple de langage permettant de définir des opérateurs d’arité quelconque comme : i > 3 ifTrue: […] ifFalse: […]
Dans cet exemple, l’opérateur principal est ifTrue:ifFalse:. Son arité est 3, comme le montre l’arbre de la figure 4.6, et ses opérandes sont une expression booléenne et deux expressions du type “bloc de code“ placées entres crochets, respectivement. ifTrue:ifFalse:
>
i
[…]
[…]
3
4.6Un opérateur ternaire en Smalltalk 80 Il est fréquent de voir dans un même langage plusieurs opérateurs ayant le même nom, mais des arités différentes. Ainsi, en Pascal et en C, on dispose des opérateurs “moins unaire“ et “moins binaires“, tous deux écrits “-“. Bien entendu, la sémantique de ces deux opérateurs de même nom est distincte puisqu’ils ne consomment pas le même nombre d’opérandes. Il y a donc surcharge sémantique dans ces cas. Dans la plupart des langages algorithmiques, on utilise la notation algébrique usuelle pour écrire les expressions mathématiques. Ainsi : 3 + 5 * 2
est compris par tout un chacun comme signifiant : 3 + (5 * 2)
et non pas : (3 + 5) * 2
C’est d’ailleurs pour forcer cette seconde signification que l’on utilise les parenthèses afin de modifier l’association des opérandes aux opérateurs. La priorité des opérateurs sert à déterminer comment associer des opérandes aux opérateurs quand ces opérandes sont eux-mêmes des expressions contenant des opérateurs.
74
Compilateurs avec C++
Dans le cas de la notation algébrique usuelle, l’opérateur * est plus prioritaire que l’opérateur +. Dans l’expression 3 + 5 * 2, l’opérateur principal est +.L’arbre correspondant est montré à la figure 4.7. Il existe ainsi toute une hiérarchie d’opé+
3
*
5
2
4.7Priorité relative des opérateurs rateurs groupés par leur priorité : les opérateurs + et - binaires ont la même priorité, laquelle est plus faible que celles de * et /, qui sont égales entre elles. Que se passe-t-il lorsqu’un opérande d’un opérateur est une expression dont l’opérateur principal est de la même priorité que cet opérateur ? Par exemple, l’expression : 8 - 4 - 2
pourrait être comprise par association à droite comme signifiant : 8 - (4 - 2)
mais aussi par association à gauche comme signifiant : (8 - 4) - 2
Le fait que la valeur de l’expression soit différente dans les deux interprétations est secondaire : l’important est que l’arbre décrivant l’expression a une forme différente dans les deux cas. Dans le premier cas, l’opérateur principal est le premier “-“, tandis que c’est le second “-“ dans le second cas. Nous laissons comme exercice pour le lecteur le soin de dessiner ces deux arbres. L’associativité d’un opérateur permet de décider quel est l’opérateur principal en cas d’opérateurs de même priorité. Les cas de figure qui peuvent se présenter sont : •
un opérateur n’est pas associatif ;
•
un opérateur est associatif à gauche ou à droite, selon le côté où il admet un opérande ayant un opérateur principal de même priorité que lui.
Lorsqu’un opérateur est associatif à gauche, comme le “-“ binaire usuel, on prend en compte les opérateurs successifs de même priorité de gauche à droite.
Grammaires formelles
75
On ne peut avoir d’opérateurs associatifs à la fois à gauche et à droite : il y aurait dans ce cas ambiguïté, puisqu’il y aurait deux arbres distincts décrivant une même expression, comme on l’a vu dans l’exemple de 8 - 4 - 2 ci-dessus.
Dans les grammaires du type 2 de Chomsky les trois caractéristiques des opérateurs décrites ci-dessus ont souvent implicites. Ainsi, les productions : expression ⇒ terme | terme ( "+" | "-" ) expression, terme ⇒ facteur | facteur ( "*" | "/" ) terme,
impliquent que * et / sont plus prioritaires que + et -, du fait que la réduction de terme se fait prioritairement par rapport à celle de expression, et que ces quatre opérateurs binaires sont associatifs à gauche. Comme Prolog est un des seuls langages répandus permettant de définir de nouveaux opérateurs, nous nous en servirons pour illustrer la terminologie relative aux opérateurs par des exemples. La déclaration d’opérateurs en Prolog d’Edinburgh se fait par une requête comme : :- op( 50, xfx, --> ).
ou : :- op( 10, xf, stop ).
Dans cette écriture : •
le premier argument est la priorité, valeur entière pouvant aller de 0 à 1200, et 1200 étant la moins prioritaire ;
•
le deuxième argument décrit l’arité, le fait d’être préfixé, infixé ou postfixé, et l’associativité : f représente l’opérateur en cours de déclaration, x un opérande dont l’opérateur principal est plus prioritaire que lui, et y un opérande dont l’opérateur principal est plus prioritaire ou de la même priorité que lui ;
•
le troisième argument est un atome (identificateur en Pascal ou C) qui est le nom de l’opérateur, ou une liste de tels atomes, auquel cas on déclare plusieurs opérateurs d’un coup.
Un opérateur est donc infixé si le f apparaît entre deux opérandes, préfixé s’il apparaît en tête, et postfixé s’il apparaît après un opérande. Il est associatif du coté du y si celui-ci apparaît. Dans l’exemple ci-dessus --> est déclaré comme opérateur infixé non associatif, et stop est déclaré postfixé non associatif. Mentionnons encore les opérateurs suivants, prédéfinis en Prolog : :- op( 500, yfx, [ +, - ] ). :- op( 400, yfx, [ *, / ] ).
76
Compilateurs avec C++
::::4.14
op( op( op( op(
1100, xfy, ; ). 1000, xfy, ‘,’ ). 900, fy, not ). 700, xfx, [ =, is, ==, \==, <, >, =<, >= ] ).
Aspects théoriques
Bien que nous ne traitions pas de la théorie des langages et des grammaires formelles dans ce livre, il est nécessaire de préciser le cadre dans lequel on utilise des grammaires en compilation. Un langage est dit d’un certain type s’il existe une grammaire de ce type qui l’engendre. Cela n’empêche pas qu’il peut être d’un autre type, s’il existe une grammaire de cet autre type qui l’engendre également. Rappelons en effet que tout langage du type 3 de Chomsky et aussi du type 2.
que :
Pour montrer qu’une grammaire engendre un langage donné, il faut montrer •
toute phrase du langage engendré par la grammaire est dans le langage donné ;
•
tout phrase du langage donné peut être engendrée par la grammaire en question.
Déterminer si une grammaire est ambiguë est en général une question semi-décidable. En effet : •
si l’on peut exhiber une phrase du langage engendré par cette grammaire et ayant plusieurs arbres de dérivation distincts, alors cette grammaire est ambiguë ;
•
en revanche, il se peut qu’une grammaire soit ambiguë sans qu’on arrive à exhiber un tel contre-exemple.
Déterminer si deux grammaires sont équivalentes, c’est-à-dire si elles engendrent le même langage, est en général semi-décidable. On a dans ce cas la situation suivante : •
si l’on peut exhiber une phrase engendrée par une grammaire et pas par l’autre, on a la preuve que les deux langages engendrés sont différents ;
•
en revanche, il existe des paires de grammaires pour lesquelles on ne peut pas prouver que les langages engendrés sont les mêmes en un temps fini.
Grammaires formelles
77
Il faut se rappeler que les langages engendrés sont en général des ensembles infinis de séquences de terminaux de longueur arbitraire : montrer que de tels ensembles définis en compréhension sont identiques n’est pas possible en général. Le cas des grammaires du type 3 est favorable : pour toute grammaire du type 3, il existe un algorithme d’analyse indiquant si une séquence de terminaux appartient au langage engendré. Il existe aussi un algorithme de décision, indiquant en un temps fini si deux grammaires du type 3 sont équivalentes. Ce dernier algorithme s’appuie sur la construction d’un automate fini déterministe minimal, qui ne sera pas traitée ici. Toutes ces limites, que l’on ne peut espérer dépasser, n’empêchent pas l’emploi des grammaires, pas plus que les limites formulées par les premier et deuxième principes de la thermodynamique n’empêchent l’emploi de moteurs thermiques. Calculs sur les grammaires
Une opération souvent effectuée sur les productions d’une grammaire est le calcul de certains ensembles de terminaux par saturation, le terme technique étant fermeture transitive (transitive closure). L’ensemble des terminaux pouvant débuter une notion non terminale notion est noté FIRST (notion). Cet ensemble est par exemple utilisé pour déterminer si une grammaire est LL(1), comme indiqué au paragraphe 7.4. Il est nécessaire de procéder par fermeture transitive parce toute notion non terminale utilisée au début d’une production définissant notion doit être analysée elle-même pour déterminer l’ensemble des terminaux qui peuvent la débuter. La saturation apparaît clairement dans cet exemple.
L’ensemble des terminaux pouvant suivre une notion non terminale notion est noté FOLLOW(notion). Cet ensemble est beaucoup utilisé dans le cadre de l’analyse syntaxique, comme on le montre au chapitre 7. Il se calcule pa fermeture transitive, puisque le corps de chacune des productions définissant notion doit être soumis lui aussi à la détermination des terminaux pouvant le suivre.
Notons dès à présent que le pseudo-terminal FIN, utilisé par les techniques d’analyse ascendantes comme indiqué au paragraphe 7.2, est considéré comme faisant partie des terminaux pouvant suivre certaines notions non terminales. C’est le cas de l’axiome de la grammaire, pour des raisons évidentes. Le lecteur intéressé aux calculs sur les grammaires peut se référer à [Aho, Sethi & Ullman 88] et à [DeRehmer & Pennello 82].
78
Compilateurs avec C++
Hiérarchie des grammaires
La figure 4.8 illustre la hiérarchie des principaux types de grammaires. Nous ne présenterons pour l’analyse syntaxique que les techniques LL et LR, les grammaires de précédence ayant été supplantées par cette dernière. LR(1)
LALR(1)
SLR(1)
précédence faible
LR(0)
LL(1)
précédence simple
contient l’ensemble des langages du type
4.8Hiérarchie des grammaires usuelles La puissance descriptive des grammaires est de plus en plus petite au fur et à mesure que l’on descend vers le bas de cette hiérarchie. Nous détaillons, au chapitre 7, les types de grammaires figurant en gras dans cette figure. 4.15
Une grammaire du langage Markovski
Ce paragraphe présente une grammaire pouvant être utilisée pour engendrer le langage Markovski. Comme ce langage est très simple, sa grammaire l’est aussi. Une version possible est : Markovski = ( { /* Terminaux */ chaîne, "-->", "stop", ".", "eof", }, {
/* Non_terminaux */ algorithme, règles, règle, fin_règles
}, {
/* Productions */ algorithme ⇒ règles "eof" ".", règles ⇒ règle fin_règles,
Grammaires formelles
79
fin_règles ⇒
ε|
règle fin_règles, règle ⇒ chaîne "-->" chaîne "." chaîne "-->" chaîne "stop" "." }, algorithme )
/* Axiome */
On notera que cette grammaire impose qu’il y ait au moins une règle dans un fichier source Markovski. Le cas de chaîne est analogue à celui d’identificateur en Pascal ou C++ : c’est un terminal dans cette grammaire, bien qu’il s’agisse d’une classe de terminaux, définie par sa propre grammaire. Une telle grammaire pourrait être par exemple : G_chaîne = ( { """",
/* Terminaux */ /* le délimiteur de chaîne */
"a", "b", "c", …, "z", "A", "B", "C", …, "Z", "0", "1", "2", …, "9", …,
/* tous les autres caractères de chaîne */
}, {
/* Non_terminaux */ chaîne, caractères_de_chaîne
}, {
/* Productions */ chaîne ⇒ """" caractères_de_chaîne """", caractères_de_chaîne ⇒ "a", "b", "c", …, "z", "A", "B", "C", …, "Z", "0", "1", "2", …, "9", …,
/* tous les autres caractères de chaîne */
}, chaîne )
/* Axiome */
Nous interdisons le caractère guillemet (") dans une chaîne. Cela n’est pas typique des langages usuels, où il existe en général deux conventions pour le cas où le délimiteur de chaîne fait partie de la chaîne elle-même : •
soit on le redouble, comme l’apostrophe en Pascal dans : 'j''aime'
80
Compilateurs avec C++
•
soit on le précède d’un caractère conventionnel, comme devant le guillemet en C et C++ : "j\"aime"
4.16
Exercices
4.1 : Commentaires imbriqués (moyen). Peut-on décrire par une grammaire du type 3 de Chomsky des commentaires du genre ce ceux de C, mais imbriqués, comme dans : /* niveau principal /* niveau imbriqué */ suite … */
Chapitre
5
5 Analyse lexicale
Dans les cas où la forme source à compiler est un texte, une des tâches de compilation est la lecture des caractères du code source. Elles est réalisée par un analyseur lexical (scanner, lexical analyzer). Dans la plupart des langages, on distingue le niveau lexical du niveau syntaxique proprement dit, où l’on consomme des terminaux en vérifiant la bonne forme des phrases ainsi formées. Nous traitons ce point au paragraphe suivant. L’analyse lexicale consiste à lire des caractères pour les regrouper en terminaux, ignorant les séparateurs entre les terminaux. Les terminaux sont en général : •
les identificateurs comme unEntier et nombre_de_factures ;
•
les chaînes de caractères comme 'coucou' et "C'est moi\n" ;
•
les constantes numériques diverses ;
•
les mots clés du langage, comme if et return en C++ ;
•
les marqueurs syntaxiques propres au langage comme “=“, “!=“ “{“, “)“, “[“ et “;“.
Les séparateurs dans les langages usuels sont les blancs non significatifs, les caractères de tabulations, les fins de lignes et les commentaires. Dans ce chapitre, nous montrons comment réaliser concrètement l’analyse lexicale de Formula. L’analyse lexicale de Markovski est proposée en exercice. Nous
82
Compilateurs avec C++
montrons dans le chapitre suivant les possibilités offertes par l’outil Lex, qui est une alternative à la programmation explicite de l’analyseur telle qu’elle est présentée dans le présent chapitre. 5.1
Niveau lexical et niveau syntaxique
Au niveau syntaxique, on consomme des terminaux en vérifiant la bonne forme des phrases ainsi formées, ces terminaux étant le résultat de l’analyse lexicale. La séparation en deux niveaux n’est pas strictement nécessaire d’un point de vue formel. Après tout, les identificateurs et autres terminaux complexes sont bel et bien décrits par des règles de grammaire, qui pourraient être incluses dans la grammaire “principale“, c’est-à-dire celle de niveau syntaxique. C’est d’ailleurs le cas de Lisp, dont la syntaxe est tellement simple que les deux niveaux lexical et syntaxique ne méritent pas d’être dissociés. Cela fait l’objet de l’exercice 7.2. Nous présentons à la fin de ce paragraphe des cas où une interaction entre les deux niveaux est nécessaire. Le paragraphe 9.15, illustre quant à lui un cas où une interaction est nécessaire entre les niveaux lexical et sémantique.
L’intérêt de la séparation des niveaux lexical et syntaxique est que les terminaux sont très souvent décrits par des grammaires régulières (du type 3 de Chomsky) qui se prêtent à une algorithme d’analyse plus efficace que les grammaires indépendantes du contexte (du type 2) qui sont généralement nécessaires au niveau syntaxique. Cela est important car un compilateur passe beaucoup de temps à lire des caractères et à les regrouper en terminaux. Le nombre de caractères traités est en effet plus important que celui de terminaux traités, le rapport étant le nombre moyen de caractères par terminal lu.
Le temps passé à traiter des caractères ne doit pas être sous-estimé. On peut lire dans [Amman 75] que, lors de l’autocompilation des 6600 lignes de code de l’autocompilateur original Pascal, l’analyse lexicale consommait 25% du temps total de compilation. Rappelons que les deux niveaux lexical et syntaxique conduisent à une situation où les terminaux complexes comme identificateur sont des notions non terminales au niveau lexical. On peut ainsi écrire pour Formula, au niveau syntaxique, la production : EnteteDeFonction ⇒ IDENT "(" Parametres ")" | IDENT,
Analyse lexicale
83
et définir IDENT au niveau lexical par la grammaire suivante : G_IDENT = ( { /* Terminaux */ "a", "b", "c", ..., "z", "A", "B", "C", ..., "Z", "0", "1", "2", ..., "9", "_", }, {
/* Non_terminaux */ identificateur, lettre, chiffre
}, {
/* Productions */ lettre ⇒ "a" | "b" | "c" | ... | "z" | "A" | "B" | "C" | ... | "Z", chiffre ⇒ "0" | "1" | "2" | ... | "9", identificateur ⇒ lettre | identificateur ( lettre | chiffre| "_" )
}, identificateur )
/* Axiome */
Relation entre analyseurs lexical et syntaxique
L’interaction entre l’analyseur lexical et l’analyseur syntaxique est du type producteur-consommateur. Cette relation peut être implantée de plusieurs manières : •
une procédure “analyse lexicale“ est appelée par l’analyseur syntaxique lorsqu’il a accepté un terminal et qu’il a besoin du suivant. C’est le cas de Pascal-S ou cette procédure s’appelle insymbol ;
•
une coroutine “analyse lexicale“ est réactivée par l’analyseur syntaxique à chaque fois qu’il a besoin du terminal suivant ;
•
une première passe “analyse lexicale“ produit une séquence de terminaux en mémoire vive, qui est ensuite relue par une seconde passe “analyse syntaxique“. Ceci est peu intéressant en pratique car ce stockage explicite n’apporte rien ;
•
une première passe “analyse lexicale“ produit un fichier de terminaux qui est ensuite relu par une seconde passe “analyse syntaxique“. C’est le cas de l’autocompilateur Newton initial écrit par l’auteur.
•
un processus “analyse lexicale“ s’exécute concurremment au processus “analyse syntaxique“ qui se synchronise avec lui pour obtenir un terminal lorsque cela est nécessaire.
84
Compilateurs avec C++
Cette possibilité a été implantée à l’université de Rennes un peu avant 1980 sous la forme d’une machine à 7 processeurs, chacun effectuant une partie du travail de compilation. Certains langages ne permettent pas une séparation complète entre les niveaux lexical et syntaxique. En Fortran, par exemple, les mots clés ne sont par réservés, et dans : DO 5 I = 1.25
on ne peut savoir que DO n’est pas le mot clé équivalent au for de Pascal qu’à la lecture du “.“ après le 1. En effet, il est dans ce cas une partie de l’identificateur DO5I. Si l’on remplace ce point par une virgule, donnant : DO 5 I = 1,25
on a affaire à l’instruction DO se terminant à l’étiquette 5 et dont la variable de contrôle I varie de 1 à 25, et non à l’affectation à la variable DO5I. Comme autre exemple en Fortran, citons : DO 5 I = 4H1,25
dans lequel on affecte à DO5I la chaîne de caractères constante “1,25“, qui est préfixée par 4, sa longueur, et H, son type. Il ne suffit donc pas de traiter le cas particulier de la virgule pour se tirer d’affaire. Tout cela fait que l’analyseur lexico-syntaxique pour un tel langage est loin d’être simple. Des remarques analogues s’appliquent à PL/1, ces deux langages ayant été conçus à une époque où les idées sur la façon de concevoir des grammaires simples étaient moins claires que maintenant. Un autre cas est celui d’Ada, où interaction entre les deux niveaux est nécessaire pour traiter des constructions comme : integer'last
et : if 'last' Dans le premier cas, l’apostrophe “'“ précédant last est à prendre toute seule, tandis qu’elle doit être appariée avec celle qui suit last dans le second cas, où elle débute une chaîne de caractères. L’écriture integer'last est similaire à la notation génitive anglo-saxone. Voilà un exemple typique où une facilité donnée au programmeur complique la vie de l’implanteur !
Analyse lexicale
5.2
85
Lecture et consommation des caractères
Un analyseur lexical fait deux opérations distinctes sur un caractère qu’il traite : •
il commence par le lire du fichier texte source, pour le placer dans ses variables internes ;
•
plus tard, il le consomme, c’est-à-dire qu’il “dépasse“ ce caractère, même sans nécessairement aller pour cela lire le caractère suivant. Logiquement, un caractère consommé “n’est plus là“.
Pour comprendre la nuance entre lecture et consommation des caractères, considérons la consommation des caractères formant un identificateur. Dans l’exemple une_var on peut voir l’identificateur une_var, mais aussi l’identificateur un suivi de l’identificateur e_var ! On consomme toujours le plus possible de caractères lors de l’acceptation d’un terminal. On s’arrête sur le premier caractère qui ne fait pas partie de la notion lexicale en cours d’analyse. Cela est nécessaire pour éviter l’ambiguïté ci-dessus.
Lorsqu’on se rend compte que le dernier caractère lu est le premier ne faisant pas partie du terminal en cours d’acceptation, on dispose en fait déjà du premier caractère du terminal suivant, pour autant qu’il ne s’agisse pas du début d’un séparateur. En revanche, si un terminal est constitué d’un seul caractère comme =, et qu’il n’existe aucun autre terminal commençant par ce même caractère, on sait que ce terminal est “complet“ sans avoir besoin d’aller lire un caractère supplémentaire. D’un point de vue pratique, il existe trois manières de gérer l’avancée dans le flot de caractères soumis à l’analyse lexicale : •
on peut lire systématiquement un caractère d’avance, c’est-à-dire que l’on dispose toujours du premier caractère suivant le dernier terminal accepté ;
•
on peut aussi ne jamais lire ce caractère d’avance, quitte à reculer dans le flot de caractères lorsqu’on se rend compte que l’on a trop avancé, comme sur le premier caractère ne faisant plus partie d’un identificateur ;
•
on peut utiliser une variable booléenne indiquant à chaque instant si le caractère suivant le dernier terminal accepté a déjà été lu.
La première méthode se prête mal à un usage interactif, où l’on ne peut précisément pas lire le caractère suivant avant qu’il n’ait été tapé par l’utilisateur. Il peut dans ce cas devenir nécessaire de lire toute une ligne avant de pouvoir commencer à traiter les caractères qui la composent. La deuxième méthode exclut de lire le texte source simplement caractère par caractère, comme le fait getc (un_fichier) en C++ : il faut disposer d’un tampon minimum de deux caractères pour pouvoir reculer sur l’avant dernier. Ce
86
Compilateurs avec C++
tampon est en fait implicite dans ungetc (un_caractere, un_fichier) dans ces deux langages. Dans la troisième méthode, la gestion de la variable booléenne représente un petit coût supplémentaire. Elle permet de savoir à chaque instant où l’on en est dans la lecture des caractères, sans devoir revenir en arrière parce qu’on est allé trop loin, ni se forcer à lire un caractère supplémentaire dont on n’a pas encore besoin. Cette façon de procéder est la plus généralement applicable.
La préférence personnelle de l’auteur va à la deuxième méthode : on charge tout le contenu du fichier en mémoire vive, ce qui fait que la lecture du caractère suivant est extrêmement rapide et qu’on peut reculer à volonté. La manière de s’y prendre est présentée au paragraphe 5.5. La lecture et la consommation des terminaux au niveau syntaxique sont similaires à celles des caractères, et les méthodes exposées ci-dessus y sont aussi applicables. 5.3
Aspects lexicaux des langages
Tous les langages n’ont pas la même philosophie au niveau lexical. Un des rôles importants de l’analyseur lexical est d’“écrémer“ le texte source des séparateurs, qui sont en général des caractères particuliers, et les commentaires. Les séparateurs sont consommés par l’analyseur lexical, mais ils ne sont pas transmis à l’analyseur syntaxique. Ils servent à séparer des terminaux qui seraient sans cela un seul terminal, comme dans les exemples Pascal : if une_variable = ...
et : if(* ... *)une_variable = ...
Sans un séparateur au moins, on aurait ifune_variable, qui est un seul identificateur, et non le mot clé if suivi de l’identificateur une_variable. Certains langages traitent les espaces comme des séparateurs, alors que pour d’autres ils ne sont pas significatifs. Ainsi en Fortran : DO i
45
n’est pas différent de : DOi45
en tant qu’identificateur. En revanche, pour Pascal : DO i
45
est formé du mot clé do, de l’identificateur i et de la constante entière 45, dans cet ordre. Les commentaires sont également l’objet d’options diverses. Certains sont parenthésés comme : (* … *)
Analyse lexicale
87
en Pascal et Newton ou : /* … */
en PL/1, C++, Newton et Prolog. Historiquement, les commentaires en Pascal étaient en fait parenthésés par { et }. C’est le fait que ces caractères n’étaient pas disponibles dans l’architecture CDC, limitée à 63 caractères, sur laquelle Pascal a été implanté initialement, qui à conduit à l’adoption de la convention ci-dessus. Notons qu’en Newton, les commentaires peuvent être imbriqués, ce qui est pratique si l’on désire mettre d’un coup en commentaire toute une portion de texte contenant elle-même des commentaires.
D’autres types de commentaires sont introduits par un ou plusieurs caractères spéciaux et se terminent avec la fin de la ligne. Cela est fait par exemple par “;“ en Lisp, “--“ en Ada, “//“ en C++ , “%“ en Prolog d’Edinburgh ainsi que par “#“ dans divers outils fonctionnant sur Unix. Les langages d’assemblage ont des conventions similaires. Les pragmas sont des directives pour le compilateur, mais n’ont pas de valeur sémantique. On peut par exemple indiquer qu’un listing de compilation ou de la table des identificateurs doit être produit, ou qu’il faut créer du code pour tester si les indices des tableaux sont dans les bornes déclarées pour leur type. Les pragmas ont souvent la forme de commentaires particuliers et peuvent varier d’une implantation à l’autre pour un langage donné.
En Pascal, ils ont la forme d’un commentaire dont le premier caractère est un
$, et les caractères suivants indiquent de quel pragma il s’agit. Ainsi :
(*$R+*) indique en général au compilateur de créer le code de test des bornes mentionné ci-dessus : R signifie “range“ (intervalle) et + signifie que l’on active cette option.
En Newton, les pragmas sont des commentaires imbriqués au premier niveau. Les commentaires sont délimités à choix à la manière de Pascal ou à de C, et peuvent être imbriqués librement. Par exemple, le pragma : /* (*NO_WARN*) */
demande au compilateur de ne pas produire de messages d’avertissement à l’utilisateur. Ces messages donnent des informations pouvant intéresser le programmeur comme l’existence de variables inutilisées après leur déclaration ou le fait qu’une déclaration locale masque un identificateur global au niveau de déclaration courant. Nous verrons ce genre de message en Formula au chapitre 8. Il est en général également possible de donner les mêmes indications que celles véhiculées par les pragmas par des paramètres lors de l’appel au compilateur dans les systèmes possédant un langage de commande.
88
5.4
Compilateurs avec C++
Algorithme d’analyse d’expressions régulières
On démontre que les langages engendrés par les grammaires régulières (du type 3 de Chomsky) peuvent être analysés par un automate à états finis. Un tel automate peut se trouver dans différents états en nombre fini, et change d’état selon les caractères qu’il trouve lors de la lecture du source à analyser. On appelle transition un changement d’état d’un automate. Un automate à états finis se trouve initialement dans un état “neutre“. L’analyse consiste à effectuer les transitions correspondants aux caractères successifs rencontrés dans la phrase à analyser. Si cet algorithme conduit à un état acceptant, la phrase d’entrée est dérivable de l’axiome de la grammaire, et elle est donc bien formée. Un tel automate fini pour IDENT en Formula est présenté à la figure 5.1.
lettre 0
lettre
1
chiffre
autre 2
"_"
5.1Un automate fini acceptant les identificateurs Formula Un automate fini est déterministe (DFA, Deterministic Finite Automaton) si tous les arcs partant d’un nœud quelconque correspondent à des caractères différents. Dans le cas contraire, il est dit non déterministe (NFA, Nondeterministic Finite Automaton) : il se peut, si on s’y prend mal à l’analyse, qu’on doive faire un retour arrière sur un arc qu’on avait emprunté à tort. C’est cette particularité qui lui donne son nom. On démontre qu’on peut toujours convertir un automate fini non déterministe en un autre, déterministe, engendrant le même langage. La construction de l’automate fini à partir d’une grammaire régulière se fait par la construction de Thompson donnant dans certains cas un automate non déterministe. Dans ce cas, on applique la méthode de passage à l’automate déterministe engendrant le même langage, à partir de l’automate non déterministe. Cela se fait en s’appuyant sur la notion de fermeture transitive, et conduit à un automate comportant plus d’états. Le lecteur peut consulter [Aho, Sethi & Ullman 88] pour les détails.
Analyse lexicale
89
En pratique, deux cas se présentent pour l’analyse d’expressions régulières : •
soit il s’agit d’un langage de programmation, dont les aspects lexicaux sont figés une fois pour toute. Dans ce cas, on fige l’automate fini dans le code de l’analyseur lexical ;
•
soit il s’agit d’un langage de commande ou d’édition du genre de ceux des systèmes d’exploitation actuels, qui permettent, par exemple, de spécifier des noms de fichiers comme : ex*.pas
désignant tous les identificateurs formés de ex, puis de 0 fois ou plus un caractère quelconque, et enfin de “.pas“. Dans ce cas on doit construire l’automate fini de cas en cas pour chaque commande soumise par l’utilisateur. 5.5
Lecture des caractères
Avant de passer à la description de l’analyseur lexical, nous devons nous pourvoir d’outils pour la lecture des caractères sources Formula. La lecture des caractères d’un fichier texte utilise : •
un tampon, qui est une zone de mémoire dynamique destinée à recevoir en une fois tous les caractères du fichier lu. Cela est possible sans problème sur les machines courantes, où même un texte source de 500 kilooctets peut être ainsi chargé ;
•
une sentinelle, qui est un caractère conventionnel ajouté à la fin du fichier pour accélérer la reconnaissance de la fin du fichier et ne pas pénaliser chaque lecture d’un caractère par un test de fin du tampon. Il s’agit là d’une optimisation facile à mettre en place et très payante.
La déclaration de la classe correspondante est : class FichierDeCaracteres { public: FichierDeCaracteres (char * nomDuFichier); // un constructeur void void long
Ouvrir (); Fermer (); Taille ();
void
Rembobiner ();
void
LireDansTampon ( char * & leTampon, long & longueurDuTampon, char laSentinelle );
virtual void
ErreurFichier (char * message);
90
Compilateurs avec C++
private: int char }; //
fDescripteur; * fNomDuFichier; FichierDeCaracteres
Nous désirons que les caractères des codes sources Formula compilés puissent provenir soit d’un fichier, soit du clavier de l’utilisateur. Pour cela, nous factorisons le comportement commun à ces deux situations dans une classe. Dans la réalisation qui est faite : •
on accède directement aux caractères par des adresses en mémoire, à l’aide du champ fPosCaractereCourant.
•
on utilise la technique de la sentinelle pour savoir quand on a atteint la fin du texte à lire ;
•
les deux types de producteurs de caractères se distinguent par leurs méthodes LireUnCaractere et FinAtteinte ;
•
on peut revenir sur des caractères déjà lus pour ne pas imposer de contraintes sur les analyseurs lexicaux qui vont les utiliser cette classe ;
•
on évite autant que possible de recopier les caractères à plusieurs reprises d’un endroit à un autre. Ainsi, les caractères formant un identificateur ou un nombre peuvent être laissés où ils se trouvent dans un tampon. Il ne seront recopiés ailleurs que lorsque cela est indispensable, au moyen de la méthode ExtraireLaChaine. La classe correspondante est : class ProducteurDeCaracteres { public: virtual char LireUnCaractere () = 0; // virtuelle pure void
RevenirDUnCaractereEnArriere ();
int
PositionCourante ();
void
ExtraireLaChaine ( int positionDeDepart, int nombreDeCaracteres, char * destination );
virtual Boolean
FinAtteinte () = 0; // virtuelle pure
virtual void
ErreurProduction (char * message);
protected: char char char }; //
* fPosCaractereCourant; * fPosDebutTampon; * fPosFinTampon; ProducteurDeCaracteres
Analyse lexicale
91
La classe ProducteurDeCaracteresFichier permet de charger le contenu d’un fichier de caractères en mémoire vive : class ProducteurDeCaracteresFichier : public ProducteurDeCaracteres { public: ProducteurDeCaracteresFichier ( char * tampon, long longueurTampon ); char
LireUnCaractere ();
Boolean FinAtteinte (); }; Dans ce cas, le tampon destiné à recevoir les caractères du fichier est alloué par la méthode FichierDeCaracteres :: LireDansTampon.
La classe ProducteurDeCaracteresFlot va lire les caractères un à un sur un flot de caractères (stream). Elle peut être utilisée lors de la redirection des entréessorties ou de l’emploi de pipes (tuyaux), comme ceux d’Unix, et surtout en mode interactif : const short
kTailleTamponFlot
= 1000;
class ProducteurDeCaracteresFlot : public ProducteurDeCaracteres { public: ProducteurDeCaracteresFlot ( istream * leFlotDEntree = & cin, char laSentinelle = '\n', long laTailleDuTampon = kTailleTamponFlot ); char
LireUnCaractere ();
Boolean
FinAtteinte ();
private: istream
* fFlotDEntree;
char
* fTampon;
char
* fPosDernierCaractereLu;
char Boolean }; //
fSentinelle; fSentinelleRencontree; ProducteurDeCaracteresFlot
C’est dans ce cas le constructeur de la classe ProducteurDeCaracteresFlot qui alloue le tampon en mémoire. Les détails de réalisation sont présentés en appendice, au paragraphe A.1.4.
92
Compilateurs avec C++
5.6
Analyse lexicale prédictive de Formula
En pratique, puisque nous voulons automatiser le travail d’analyse lexicale, nous avons besoin d’algorithmes efficaces, si possible sans retour arrière, parce que c’est relativement coûteux en temps. Une méthode d’analyse est déterministe si toute phrase du langage peut être acceptée sans devoir revenir sur une tentative. Une méthode d’analyse descendante et déterministe est dite prédictive. Pour qu’une méthode d’analyse sot prédictive, il faut que l’on n’ait à chaque étape qu’une seule action d’analyse candidate : si celle-ci ne permet pas de construire l’arbre de dérivation, la phrase est alors nécessairement incorrecte. Dans une méthode prédictive, le flot du contrôle est calqué sur la grammaire du langage que l’on analyse. Bien qu’il soit possible d’écrire des analyseurs lexicaux prédictifs non déterministes, on se limite en pratique au cas déterministe pour des questions d’efficacité.
Les terminaux du langage Formula sont décrits par le type : enum Terminal { NOMBRE, PAR_GAUCHE, EGALE, PLUS, POINT_VIRGULE, FIN };
IDENT, PAR_DROITE, VIRGULE, MOINS, INTERROGE,
FOIS,
DIVISE,
Le dernier “terminal“ est en fait le pseudo-terminal FIN indiquant que la fin du fichier source a été atteinte. Il est nécessaire dans les méthodes d’analyse syntaxique ascendantes, comme on le verra au chapitre 7. La définition de l’analyseur lexical prédictif de Formula utilise les déclarations suivantes : const int const char
kLongueurIdentMax = 255; SENTINELLE = ';';
Elle s’appuie sur les champs suivants : •
fProducteurDeCaracteres est un pointeur sur une instance d’un producteur de caractères de l’une des deux classes présentées au paragraphe précédent ;
•
fCaractereCourant, du type char, contient le dernier caractère lu ;
Analyse lexicale
•
93
fNombre et fIdent servent respectivement à stocker le dernier nombre et le dernier identificateur Formula acceptés.
De plus, la classe AnalyseurLexicalFormula fournit les méthodes : void void
Avancer (); Reculer ();
void
LireExposant ();
Le rôle de LireExposant est d’accepter l’exposant pouvant apparaître dans un nombre. L’implantation de cette classe contient la méthode LireUnTerminal, dont la structure du code est la suivante : Terminal AnalyseurLexicalFormula :: LireUnTerminal () // l'analyseur lexical proprement dit { // Le premier caractère à analyser est déjà disponible Avancer (); int positionDebut = fProducteurDeCaracteres -> PositionCourante (); DEBUT: //
pour accélérer la consommation des séparateurs
switch (fCaractereCourant) { case ' ': case '\t': case '\n': Avancer (); positionDebut = fProducteurDeCaracteres -> PositionCourante (); goto DEBUT; // on consomme tous ces séparateurs case SENTINELLE: if (fProducteurDeCaracteres -> FinAtteinte ()) return FIN; else return POINT_VIRGULE; case '(': return PAR_GAUCHE; // //
tous les autres terminaux mono-caractère … … … … … …
case '?': return INTERROGE; default: if (isalpha (fCaractereCourant)) { // IDENT // voir les détails ci-dessous } else if (isdigit (fCaractereCourant)) { // NOMBRE // voir les détails ci-dessous }
94
Compilateurs avec C++
else ErreurLexicale ("caractère illégal"); } // switch } // AnalyseurLexicalFormula :: LireUnTerminal
L’analyse des identificateurs est faite par: if (isalpha (fCaractereCourant)) { // IDENT do
// on consomme tous les lettres, chiffres et soulignés Avancer (); while ( isalnum (fCaractereCourant) || fCaractereCourant == '_' ); Reculer (); int longueurIdent = fProducteurDeCaracteres -> PositionCourante () - positionDebut + 1; if (longueurIdent > kLongueurIdentMax) { cout << form ( "Ident trop long tronqué à"%d caractères\n", kLongueurIdentMax ); longueurIdent = kLongueurIdentMax; } fProducteurDeCaracteres -> ExtraireLaChaine ( positionDebut, longueurIdent, fIdent ); return IDENT; }
Un analyseur prédictif est par nature assez lisible, puisqu’il est calqué sur la grammaire. 5.7
Analyse des constantes numériques
Nous avons utilisé la fonction de librairie atof dans les paragraphes précédents pour évaluer les nombres acceptés lexicalement. Le problème de l’évaluation d’un nombre représenté par une séquence de caractères a été étudié par Horner. Il a montré que la méthode la plus efficace pour ce faire, connue depuis sous le nom de schéma de Horner, consiste à évaluer le nombre représenté par les chiffres : c1 c2 ... cn
selon la formule : (… (((0 + c1) * 10) + c2) * 10 + …) + cn
ce qui conduit à faire exactement n - 1 multiplications pour évaluer le nombre représenté par ces chiffres. Cette méthode a de plus l’avantage de pouvoir être exécutée au vol, sans avoir à stocker les chiffres successifs. En un mot, elle fonctionne en une seule passe, tan-
Analyse lexicale
95
dis que l’emploi de atof conduit à un second passage sur les caractères représentant le nombre, comme on le voit sur l’extrait de code suivant : fProducteurDeCaracteres -> ExtraireLaChaine ( positionDebut, longueurNombre, fIdent ); fNombre = atof (fIdent);
L’analyseur lexical doit éviter de faire des débordements de capacité arithmétique lors de l’évaluation des constantes numériques. Il est en effet écrit dans un langage qui a ses propres limitations de représentation des nombres. Notons que la plupart des assembleurs évaluent les constantes sans se préoccuper de ce genre de considérations. La question peut toutefois se poser pour un assembleur croisé (cross assembler), qui crée sur une architecture donnée du code objet pour une architecture différente.
Il est légitime, d’un point de vue sémantique, de signaler au programmeur que la spécification source qu’il a écrite n’a pas la signification désirée à cause d’un débordement de capacité. La technique mise en œuvre pour dépister les débordements consiste, lors des multiplications par la base de numération et l’ajout du poids du chiffre courant, à tester s’il y a débordement avant de faire ces opérations. Les bases 10 et 16 sont les plus fréquentes en pratique. Le schéma de Horner s’applique aussi aux nombres flottants, mais il est un peu plus complexe dans ce cas car on doit gérer la partie fractionnaire et l’exposant éventuel. L’analyseur lexical Formula utilise pour cette raison la fonction atof (une_chaine), qui retourne la valeur numérique représentée par son argument d’appel. Il est probable que cette fonction utilise le schéma de Horner de toute manière. Le code de l’analyse lexicale des nombres Formula est présenté en appendice, au paragraphe A.1.6. Pour illustrer le schéma de Horner, voici l’extrait correspondant tiré de l’analyseur lexical C-- : if (isdigit (fCaractereCourant)) { // ENTIER int lEntier = fCaractereCourant - '0'; while (true) // boucle infinie { // on consomme tous les chiffres, schéma de Horner Avancer (); if (! isdigit (fCaractereCourant)) break; short
poidsDuChiffre = fCaractereCourant - '0';
if (lEntier > (kEntierMaximum - poidsDuChiffre) / 10) ErreurLexicale ( form ("débordement d'un entier après %d", lEntier )); lEntier = lEntier * 10 + poidsDuChiffre; // on ne fait ce calcul qu’à coup sûr } // while Reculer ();
96
Compilateurs avec C++
fNombre = lEntier; return NOMBRE; }
On remarque dans cet exemple l’appel à Reculer, qui illustre le retour en arrière d’un caractère lorsqu’on a consommé le dernier chiffre du nombre entier. Avant de retourner la valeur NOMBRE, décrivant ce terminal, comme résultat de la fonction, on stocke dans le champ fNombre la valeur de l‘entier accepté. La constante kEntierMaximum est définie selon la capacité à ne pas déborder. On peut bien sûr paramétrer le code ci-dessus pour des bases différentes de 10, en remplaçant les deux occurrences de ce nombre par le paramètre contenant la base de numération à utiliser. Si la base est plus grande que 10, il faut de plus accepter les chiffres supplémentaires, comme A, B, C, D, E et F en base 16. 5.8
Gestion des mots clés réservés
Dans tous les langages fréquents, les mots clés réservés sont des identificateurs particuliers. C’est pour éviter l’ambiguïté entre un mot clé et l’identificateur s’écrivant comme lui que l’on réserve certains identificateurs dans les langages modernes. On fait le test d’appartenance à la table des mots clés juste après l’acceptation de la notion lexicale non terminale IDENT, mais avant de conclure à la présence d’un identificateur. Une technique qui a fait l’objet de nombreuses recherches est celle des fonctions associatives parfaites (perfect hash functions). L’idée est, étant donné un ensemble de mots clés donné, de construire une fonction, facile à calculer, qui soit un accepteur pour ces seuls mots clés. Nous ne présenterons pas cette approche plus en détail dans ce livre.
En C++, les types courants comme int et char sont des mots clés réservés Il y a en effet une longue tradition en C de ne pas avoir d’identificateurs prédéfinis : toutes les fonctions d’emploi courant comme printf (…) sont dans des librairies fournies avec les implantations du langage, mais ne font pas partie du langage luimême. En Pascal, en revanche, les types courants integer et real sont des identificateurs prédéfinis, ce qui fait que l’on peut les redéfinir soi-même. Comme Formula n’a pas de mots clés réservés, nous illustrons cet aspect de l’analyse lexicale par l’exemple de C--, dont les terminaux sont décrits par : enum Terminal { IDENT, PAR_GAUCHE, //
CARACTERE,
CHAINE,
PAR_DROITE,
CROCH_GAUCHE,
CROCH_DROIT,
TYPE_ENTIER,
TYPE_CARACTERE,
TYPE_BOOLEEN,
… … … … …
VIDE, //
ENTIER,
… … … … …
Analyse lexicale
INTERROMPRE, CREER, FIN };
CONTINUER, DETRUIRE,
DEFAUT,
97
SAUTER,
Différentes techniques existent pour gérer les mots clés réservés. Celle que nous avons choisie utilise les types : typedef char * typedef short
TexteDeMotCle; IndiceDeMotCle;
struct MotCleReserve { TexteDeMotCle Terminal };
fTexte; fTerminal;
Les mots clés sont insérés dans la table et triés par : ReserverLeMotCle ReserverLeMotCle ReserverLeMotCle ReserverLeMotCle //
("void", ("int", ("char", ("Boolean",
VIDE); TYPE_ENTIER); TYPE_CARACTERE); TYPE_BOOLEEN);
… … … … …
ReserverLeMotCle ReserverLeMotCle ReserverLeMotCle ReserverLeMotCle
("break", INTERROMPRE); ("continue", CONTINUER); ("default", DEFAUT); ("goto", SAUTER);
ReserverLeMotCle ("new", ReserverLeMotCle ("delete",
CREER); DETRUIRE);
TrierLesMotCles ();
Le code qui accepte les identificateurs et les mots clés C-- est : IndiceDeMotCle
bas, haut, milieu;
bas = 1; haut = kNombreDeMotsCles; do
// recherche dichotomique { milieu = (bas + haut) / 2; if (strcmp (fIdent, fTableDesMotsCles [milieu].fTexte) <= 0) haut = milieu;
else bas = milieu + 1; } while (bas != haut); if (strcmp (fIdent, fTableDesMotsCles [bas].fTexte) == 0 return fTableDesMotsCles[bas].fTerminal; // Mot Clé else return IDENT; }
98
5.9
Compilateurs avec C++
Analyse des commentaires
Nous allons illustrer cette activité typique d’un analyseur lexical par un extrait de celui de C--. Rappelons qu’en C++, les commentaires sont soit introduits par //, auquel cas toute la fin de ligne à droite de // est un commentaire, soit parenthésés par /* et */. Dans ce dernier cas, le commentaire peut s’étendre sur plusieurs lignes. Comme les commentaires jouent le rôle de séparateur au niveau lexical, ils sont simplement acceptés et “écrémés“ du code source en cours de compilation par l’analyseur lexical. Pour ne pas alourdir l’implantation, nous avons renoncé à permettre d’écrire des commentaires dans les sources Formula. Voici donc le code qui accepte les commentaires dans l’analyseur lexical C--, qui ne diffère pas de C++ dans de domaine : case '/': Avancer (); switch (fCaractereCourant) { case '/': // commentaire jusqu'à la fin de ligne do Avancer (); while (fCaractereCourant != '\n'); goto DEBUT; // on consomme ce séparateur // et on repart chercher le prochain terminal case '*': // commentaire parenthésé Avancer (); do { while (fCaractereCourant != '*') Avancer (); Avancer (); } while (fCaractereCourant != '/'); Avancer (); goto DEBUT; // on consomme ce séparateur // et on repart chercher le prochain terminal case '=': return DIVISE_EGALE; default: Reculer (); return DIVISE; } // switch
Analyse lexicale
99
Le lecteur trouvera en appendice, au paragraphe A.1.7, un exemple d’analyse lexicale de C-- illustrant les cas d’analyse ne se présentant pas en Formula, comme les commentaires et les chaînes de caractères. 5.10
Analyse des chaînes de caractères
L’analyse des chaînes ne pose pas de problème particulier, si ce n’est qu’il faut faire attention aux diverses conventions de délimitation que l’on rencontre en pratique. La plupart des langages présentent cette particularité que, parfois, deux caractères analysés n’en représentent qu’un dans la chaîne. Les chaînes de C++ sont parenthésées par des guillemets ("). De plus, certains caractères de contrôle et les guillemets faisant partie de la chaîne eux-mêmes doivent être préfixés par \, comme dans : "j\"aime le son du cor\n"
Dans le cas de Pascal, c’est le délimiteur apostrophe (') qui est employé pour les chaînes, et il est doublé s’il apparaît dans la chaîne, comme dans : 'j''aime le son du cor'
Les remarques ci-dessus conduisent à découper la chaîne en cours d’analyse en tronçons de caractères contigus, les tronçons étant eux-mêmes séparés par une de ces paires de caractères n’en représentant en fait qu’un. Un tronçon peut être vide. Par exemple, la chaîne : "C'est\n\nfini\"!\n"
est formée de : tronçon paire tronçon paire tronçon vide paire tronçon paire
C'est \n fini \n \" ! \n
Comme Formula ne permet pas de gérer des chaînes de caractères pour des raisons de simplicité d’implantation, le lecteur trouvera en appendice, au paragraphe A.1.5, un extrait de l’analyseur lexical de C--, qui illustre l’acceptation des chaînes.
100 Compilateurs avec C++
5.11
Analyse lexicale Formula
L’analyseur lexical prédictif décrit dans ce chapitre peut être mis en œuvre par le programme principal suivant : main (int nbArguments, char * arguments []) // le nom du programme est l'argument d'indice 0! { ProducteurDeCaracteres * leProducteurDeCaracteres; switch (nbArguments) { case 1: leProducteurDeCaracteres = LireLeFlot (& cin, SENTINELLE); break; case 2: leProducteurDeCaracteres = LireLeFichier (arguments [1], SENTINELLE); break; default: cout << "### Vous devez fournir un nom de fichier en argument\n" " ou pas d'argument pour un usage interactif ###\n"; exit (1); } // switch AnalyseurLexicalFormula Terminal
lAnalyseur (leProducteurDeCaracteres); leTerminal;
do { leTerminal = lAnalyseur . LireUnTerminal (); cout << lAnalyseur.TerminalSousFormeTextuelle (leTerminal) << "\n"; } while (leTerminal != FIN); } // main
La fonction TerminalSousFormeTextuelle est chargée de produite une chaîne représentant un terminal, ce qui est fait par : char * AnalyseurLexicalFormula :: TerminalSousFormeTextuelle (Terminal leTerminal) { switch (leTerminal) { case NOMBRE: return form ("Nombre %10.6f", DernierNombreLu ()); case IDENT: return form ("Ident
%s", DernierIdentLu ());
Analyse lexicale 101
case PAR_GAUCHE: return "
(";
… … … … … case POINT_VIRGULE: return "
;";
case INTERROGE: return "
?";
case FIN: return "--- FIN ---";
}
default: return form ( "### Erreur dans TerminalSousFormeTextuelle, " "code du terminal = %d ###", leTerminal ); break; } // switch // AnalyseurLexicalFormula :: TerminalSousFormeTextuelle
Exemple
Soit le programme source Formula : carre (x) = x * x; pi = 314.1592E-2; ? cube (sinus(pi));
Lorsqu’on l’analyse lexicalement avec l’analyseur prédictif présenté dans ce chapitre, on obtient comme résultat la séquence des terminaux lus, soit : Ident Ident Ident Ident Ident Nombre Ident Ident Ident
carre ( x ) = x * x ; pi = 3.141592 ; ? cube ( sinus ( pi )
Analyse lexicale 102
) ; --- FIN --5.12
Exercices
5.1 : Commentaires imbriqués (facile). Ecrire de manière prédictive un analyseur de commentaires du même type que ceux de C, mais pouvant être imbriqués, comme dans : /* niveau principal /* niveau imbriqué */ suite … */
5.2 : Analyse lexicale de Markovski (facile). Ecrire dans un langage à choix un programme pouvant lire un fichier de texte source Markovski et en faire l’analyse lexicale, en s’inspirant des principes exposé dans ce chapitre.
Chapitre
6
6 L’outil Lex
L’outil Lex est un générateur d’analyseurs lexicaux développé dans la mouvance d’Unix, la première version ayant été réalisée par Lesk. Il s’appuie sur une description grammaticale des aspects lexicaux du langage à compiler et sur des fragments de code venant préciser certains aspects opérationnels nécessaires à l’analyse. Lex compile une grammaire régulière (du type 3) et produit le texte source d’un analyseur du langage engendré par cette grammaire. Le langage cible de Lex est celui dans lequel le code de l’analyseur lexical synthétisé est écrit. Il s’agit en général de C, mais Lex s’accommode très bien de code C++. L’intérêt de Lex est que cet outil permet de se concentrer sur l’aspect grammatical de l’analyse lexicale, sans trop s’occuper des détails du fonctionnement de l’analyseur synthétisé.
Le fichier obtenu s’appelle lex.yy.c, avec des variations selon le système d’exploitation. Il contient le code de la fonction yylex qui réalise l’analyse lexicale et retourne comme valeur un entier décrivant le terminal lu. yylex retourne la valeur 0 à la fin du texte source analysé. Il ne faut donc pas utiliser cette valeur pour décrire un “vrai“ terminal.
104 Compilateurs avec C++
Nous donnons une spécification Lex du langage Formula à titre d’exemple dans les paragraphes suivants. Le langage cible est C++. Les détails d’implantation intéressants sont présentés au paragraphe A.1.7. Lex peut être utile pour toute analyse lexicale de texte, et pas seulement pour écrire des compilateurs. 6.1
Qui fait quoi avec Lex ?
Lex analyse un fichier de texte contenant une description des aspects lexicaux du langage pour lequel on désire synthétiser un analyseur. Cette description est écrite dans une syntaxe propre à Lex, décrite dans les paragraphes suivants. Un fichier de description pour Lex est formé de trois parties, séparées par des lignes contenant seulement %%, aligné à gauche, selon le schéma suivant : déclarations %% productions %% code de service
Aucune de ces trois parties n’est obligatoire, mais le premier %% l’est, pour indiquer la séparation entre les déclarations et les productions. La description minimale que l’on peut fournir à Lex est donc : %% auquel cas le programme synthétisé recopiera le texte fourni en entrée tel quel sur la sortie, sans modification.
Il semble que peu de versions de Lex supportent les jeux de caractères accentués, représentés par plus de 7 bits. Ce manque est très gênant, et c’est un critère de choix pour une version de Lex. Citons que Flex (Fast Lex), disponible dans le domaine public, n’a pas cette limitation. Attention, certaines versions de Lex ignorent le contenu de la dernière ligne du fichier de description si elle n’est pas terminée par une fin de ligne ! Les outils Lex et Yacc ont été pensés pour être utilisés ensemble. On peut toutefois utiliser l’analyseur lexical synthétisé par Lex sans que l’analyseur syntaxique soit synthétisé par Yacc.
L’outil Lex 105
Particularités de la synthèse de code C++
Comme nous l’avons dit, Lex a été pensé pour créer du code C. Toutefois, comme les actions sont recopiées telles quelles sur le fichier lex.yy.c, on peut les écrire en C++. La version de Lex que nous avons utilisée place la définition de certaines fonctions après leur premier emploi dans lex.yy.c. Pour que ce dernier du code C++ bien formé, il nous faut donc déclarer certaines fonctions dans la première partie du fichier de description. Dans l’exemple de Formula, cela est fait au moyen des déclarations : // Ces deux fonctions sont définies trop tard // dans le code synthétisé par Lex!! yylook (); yyback (int * p, int m); Avec d’autres versions de Lex, on pourrait devoir faire une “adaptation“ un peu différente. On trouve aussi maintenant des version de Lex qui synthétisent du code C++ correct et ne posent pas ce genre de problème. Exemple de Formula
La figure 6.1 montre la division des tâches entre Lex, le compilateur CPlus et l’éditeur de liens Link dans le cas de la construction de l’analyseur lexical de Formula présenté dans ce chapitre. Le fichier “make“ correspondant figure au paragraphe A.2.2. Il est important de bien comprendre les actions indiquées par les flèches dans cette figure : •
la commande Lex compile le fichier de description LexFormula.Lex et produit le fichier lex.yy.c, contenant le code C++ de l’analyseur lexical de Formula synthétisé ;
•
la commande Rename renomme le fichier yy.lex.c en yy.lex.cp, conformément à la convention du compilateur C++ utilisé ;
•
le fichier LexSupport.cp contient des fonctions complémentaires dont l’analyseur lexical synthétisé dans le fichier lex.yy.c a besoin, comme indiqué au paragraphe 6.6 ;
•
les commandes CPlus compilent du source C++ en code objet ;
•
la commande Link construit le code de l’analyseur lexical dans le fichier exécutable LexFormula ;
106 Compilateurs avec C++
LexFormula.Lex
Lex
lex.yy.c commande Rename
LexSupport.cp
lex.yy.cp
compilateur CPlus
LexSupport.cp.o
compilateur CPlu
lex.yy.cp.o
éditeur de liens Link
code source Formula
LexFormula
séquence de terminau
6.1Partage du travail lors de l’emploi de Lex pour Formula •
l’exécution de LexFormula analyse lexicalement le fichier source Formula qu’on lui donne en argument. Cela produit une séquence de terminaux, qui sont imprimés sur la sortie standard pour les besoins de ce livre.
Lex procède par instanciation d’un modèle de texte, en plaçant aux endroits adéquats le code réalisant l’analyse lexicale d’après la spécification qu’on lui soumet. On peut placer des fragments de code en langage cible en certains endroits de la spécification grammaticale soumise à Lex. Eux aussi se retrouvent “où il faut“ dans le fichier texte résultant. 6.2
Première partie d’un fichier Lex
La première partie d’un fichier de description pour Lex, si elle est présente, peut contenir : •
des spécifications écrites dans le langage cible, encadrées par %{ et %}, chacun de ces marqueurs étant aligné à gauche sur une ligne. Ces fragments de code se retrouveront au début du fichier synthétisé, donc globalement au corps de toutes les fonctions, et en particulier à yylex.
L’outil Lex 107
Il peut s’agir de constantes énumérées décrivant les terminaux comme nous l’avons fait avec le type Terminal dans le chapitre 5. Il peut aussi s’agir aussi de variables utilisées pour faire communiquer les fragments de code en langage cible présents dans les productions et le code de service placé dans la troisième partie ; •
des expressions régulières définissant des notions non non terminales telles que lettre, chiffre et nombre, à l’usage de Lex. Ces spécifications ont la forme : notion
expression_regulière
où notion et expression_regulière sont séparées par au moins un espace ou un caractère de tabulation. On peut ensuite utiliser ces notions dans la suite de la première partie et dans les productions de la deuxième, en les parenthésant par { et }. Attention, les espaces sont significatifs dans les expressions régulières ! Lex recopie tout ce qui se trouve entre %{ et %} tel quel sur le fichier de sortie. Exemple de Formula
Voici la première partie d’une description de Formula pouvant être soumise à Lex : %{ #include "LexFormula.h" // On doit fournir le programme principal #include "LexSupport.h" // On a besoin du type Boolean #include #include #include <stdlib.h> #include <stream.h> // Ces deux fonctions sont définies trop tard // dans le code synthétisé par Lex!! yylook (); yyback (int * p, int m); extern "C" double
atof (const char *nptr);
char
* SauvegarderChaine (char * laChaine); // déclaration
108 Compilateurs avec C++
Terminal
Decrire (Terminal leTerminal);
%} /* Expressions régulières */ /* ---------------------- */ blancs lettre chiffre10
[ \t\n]+ [A-Za-z] [0-9]
ident entier10 exposant
{lettre}(_|{lettre}|{chiffre10})* {chiffre10}+ [eE][+-]?{entier10}
Dans cette spécification : •
les blancs sont définis comme étant l’espace “ “, le tabulateur \t ou la fin de ligne \n. Le préfixe \ est utilisé pour dénoter des caractères non imprimables comme ces deux derniers ;
•
une lettre est une des lettres de l’alphabet en majuscule ou en minuscule. Le signe “-“ dans les expressions régulières permet de spécifier un intervalle de caractères ;
•
un chiffre10 est un des chiffres décimaux, soit de 0 à 9. Il serait facile de définir les chiffres hexadécimaux par exemple au moyen de : chiffre16
[0-9a-fA-F]
•
un ident est une lettre suivie d’autant de fois que l’on veut d’un souligné _ ou d’une lettre ou d’un chiffre10. Le choix entre deux sousexpressions est indiqué par la barre |, les parenthèses “(“ et “)“ servent à factoriser et l’étoile * indique la répétition à volonté ;
•
un entier10 est la répétition de une ou plusieurs occurrences de chiffre. Le signe + indique la répétition à volonté, mais au moins une fois ;
•
un exposant débute par e ou E, suivi d’un signe optionnel, suivi d’un entier10 optionnel. Le point d’interrogation “?“ spécifie que ce qui le précède, comme [+-] dans cet exemple, est optionnel.
On a de plus déclaré les fonctions SauvegarderChaine et Decrire, qui sont utilisées dans la deuxième partie du fichier de description, et définies dans le code de service de la troisième partie.
L’outil Lex 109
6.3
Deuxième partie d’un fichier Lex
La deuxième partie d’un fichier de description pour Lex, si elle est présente, peut contenir : •
des spécifications écrites dans le langage cible, encadrés par %{ et %}, qui seront placées par Lex au début du corps de la fonction yylex. Ces spécifications peuvent être alors être utilisées dans les actions des productions, définies ci-dessous ;
•
des productions grammaticales de la forme : expression_regulière
action
où expression_regulière et action sont séparés par au moins un espace ou un caractère de tabulation. Si action est absente, l’action par défaut consiste à recopier les caractères acceptés en tant que expression_regulière tels quels sur la sortie. Si action est présente, elle consiste en du code dans le langage cible. Si elle comporte plus d’une instruction ou ne peut s’écrire sur une seule ligne, elle doit être parenthésée par { et }. L’identificateur yytext désigne dans les actions les caractères acceptés par expression_regulière. Il s’agit d’un tableau de caractères dans le langage cible, de longueur yyleng. Dans le cas de C++, les indices vont de 0 à yyleng - 1.
Dans les expressions régulières, il est possible d’utiliser les noms des notions définies dans la première partie, en les parenthésant par { et }, comme dans : {ident}. Il est aussi possible de placer directement des chaînes de caractères telles que <= comme expressions régulières. Les notions non terminales définies dans la première partie du fichier de description pour Lex sont destinées à alléger l’écriture des productions, en factorisant une notion si elle sert plusieurs fois. L’expression régulière constituée du seul point “.“ accepte tout caractère autre que la fin de ligne. Attention, il n’est possible, dans la deuxième partie du fichier de description soumis à Lex, de placer des commentaires /* … */ que dans les actions parenthésées : sans cela, ils seraient considérés par Lex comme des spécifications d’expressions régulières ou des actions, ce qui donnerait lieu à un message d’erreur. Il est possible de spécifier un nombre de répétitions donné en l’encadrant par { et }. La répétition de 1 à 4 ident s’écrit alors : ident{1,4}
110 Compilateurs avec C++
Notons encore que l’on peut remplacer l’action d’une production par “|“, indiquant que l’action de la prochaine production est aussi utilisée par celle qui précède la barre “|“. La variable yylval, globale à yylex, sert à retourner une description complémentaire du dernier terminal lu à l’appeleur. Le nom yylval vient de last value (dernière valeur). Il est imposé par la collaboration possible avec l’analyseur syntaxique synthétisé par Yacc. La description des terminaux de Formula est réalisée par le type : enum Terminal { FIN, // 0, sera retourné par yylex () lorsque yywrap () // retournera lui-même une valeur non-nulle NOMBRE, PAR_GAUCHE, EGALE, PLUS, POINT_VIRGULE, };
IDENT, PAR_DROITE, VIRGULE, MOINS, INTERROGE
FOIS,
DIVISE,
La description complémentaire des terminaux NOMBRE et IDENT est faite par : union DescriptionTerminal { float char };
fNombre; * fIdent;
type dont on déclare la variable : DescriptionTerminal
yylval;
Cela permet de stocker la chaîne des caractères constituant un IDENT ou la valeur d’une NOMBRE, afin de les communiquer à l’analyseur syntaxique. Lorsqu’on utilise Lex en association avec Yacc, c’est le texte de l’analyseur syntaxique synthétisé par ce dernier qui définit yylval, comme on le verra au chapitre 9. Les actions sont du code en langage cible, que Lex insère dans le code de yylex comme alternatives d’une instruction de discrimination de cas switch. Elles sont typiquement de la forme : return UN_NOM_DE_TERMINAL;
dans le cas où on n’a rien de particulier à placer dans yylval.
L’outil Lex 111
Si une action est constituée du seul “;“, comme dans : [ \t]+$
;
les caractères acceptés sont simplement ignorés, et non recopiés tels quels sur la sortie comme lorsque l’action est vide.
Rappelons qu’il n’est pas nécessaire de combiner yylex avec un analyseur syntaxique synthétisé par Yacc, et qu’on peut très bien utiliser Lex pour des opérations sur des textes en dehors de toute préoccupation de compilation. Ainsi un fichier de description Lex contenant : %% [ \t]+$ [ \t]+
; printf (" ");
permet de créer par Lex une fonction yylex qui a pour effet de reproduire un fichier de texte en éliminant les espaces et tabulateurs en fin de ligne, et en remplaçant des séquences d’espaces et de tabulateurs partout ailleurs par un seul espace. La signification du caractère $ dans une expression régulière Lex est décrite au paragraphe 6.8. Exemple de Formula
Voici la deuxième partie d’une description de Formula pouvant être soumise à Lex : {blancs}
{ /* on les ignore simplement */ }
{entier10}"."{entier10}({exposant})? | {entier10}({exposant})? { yylval.fNombre = atof (yytext); return Decrire (NOMBRE); } {ident}
{ yylval.fIdent = SauvegarderChaine (yytext); return Decrire (IDENT); }
"(" ")"
return Decrire (PAR_GAUCHE); return Decrire (PAR_DROITE);
"=" ","
return Decrire (EGALE); return Decrire (VIRGULE);
112 Compilateurs avec C++
"+" "-" "*" "/"
return return return return
Decrire Decrire Decrire Decrire
(PLUS); (MOINS); (FOIS); (DIVISE);
";" "?"
return Decrire (POINT_VIRGULE); return Decrire (INTERROGE);
.
{ ErreurLexicale ("Caractère illégal"); exit ( -1 ); }
La fonction de librairie C++ atof est utilisée pour évaluer la valeur du nombre représenté par le contenu de yytext. Le recours à la fonction Decrire permet d’obtenir une trace des terminaux successifs lus, pour les besoins de ce livre. Dans un analyseur lexical de production, on écrirait simplement par exemple : return NOMBRE;
La fonction SauvegarderChaine est utilisée pour dupliquer le contenu de la variable yytext. Cela est nécessaire parce que ce contenu sera remplacé par les caractères composant le prochain terminal lu, au prochain appel à l’analyser lexical. La fonction ErreurLexicale est définie dans la librairie de support pour Lex décrite au paragraphe 6.6. 6.4
Troisième partie d’un fichier Lex
La troisième partie d’un fichier de description pour Lex, si elle est présente, contient du code de service écrit dans le langage cible. Ce code peut contenir des fonctions appelées par les actions des productions de la deuxième partie. On peut utiliser du code de service dans les actions de la deuxième partie même s’il ne figure pas dans la troisième partie du fichier, à condition de le compiler séparément et de le lier à yy.lex.c avec l’éditeur de liens. C’est le cas de la fonction ErreurLexicale dans notre analyseur lexical Formula synthétisé par Lex.
Le code de service constituant la troisième partie d’une description Lex est placée par celui-ci en dernier dans le fichier de texte synthétisé. Exemple de Formula
Dans notre description de Formula pouvant être soumise à Lex, la troisième partie contient du code C++ de gestion de chaînes de caractères et d’interface avec l’utilisateur On y trouve la définition des fonctions SauvegarderChaine et Decrire,
L’outil Lex 113
déclarées dans la première partie et utilisées dans les actions des productions de la deuxième partie. La fonction TerminalSousFormeTextuelle sert pour les besoins de Decrire : char * SauvegarderChaine (char * laChaine) // définition { // duplique "laChaine" dans la zone dynamique char * res = new char [strlen (laChaine) + 1]; if (res != NULL) { strcpy (res, laChaine); return res; } else { ErreurLexicale ("Débordement de la zone dynamique"); exit (-1); } } / / SauvegarderChaine char * TerminalSousFormeTextuelle (Terminal leTerminal) { switch (leTerminal) { case NOMBRE: return form ("Nombre case IDENT: return form ("Ident
%10.6f", yylval.fNombre ); %s", yylval.fIdent );
case PAR_GAUCHE: case PAR_DROITE:
return " return "
("; )";
case EGALE: case VIRGULE:
return " return "
="; ,";
case case case case
return return return return
" " " "
+"; -"; *"; /";
return " return "
;"; ?";
PLUS: MOINS: FOIS: DIVISE:
case POINT_VIRGULE: case INTERROGE: default: return form ( "
%s", yytext);
} // switch } // TerminalSousFormeTextuelle
114 Compilateurs avec C++
Terminal Decrire (Terminal leTerminal) { cout << TerminalSousFormeTextuelle (leTerminal) << "\n" << flush; return leTerminal; } 6.5
Fonctions importantes pour Lex
Plusieurs fonctions sont utilisées par le code synthétisé par Lex. Les plus importantes sont citées ci-dessous : •
la fonction yywrap, dont une version est fournie avec Lex, retourne un entier indiquant la conduite à tenir lors de la rencontre de la fin du fichier qu’on analyse avec l’outil synthétisé par Lex. Si la valeur retournée est 1, ce qui est le cas de la version par défaut, on arrête l’analyse à la fin du fichier. Une valeur de 0 permet de continuer l’analyse, typiquement après avoir pris des mesures pour qu’un autre fichier soit ouvert en lecture. On peut ainsi traiter plusieurs fichiers successivement en les passant comme autant d’arguments à la commande faisant exécuter l’analyseur synthétisé. C’est lorsque yywrap retourne une valeur non nulle que yylex retourne quant à elle la valeur 0, indiquant que la fin du ou des fichiers sources a été atteinte.
•
la fonction yymore, fournie par Lex, indique que les caractères acceptés lors du prochain appel à yylex doivent être appondus au contenu de yytext pour lequel cette action est exécutée. En un mot, on ne doit pas purger le contenu de yytext lors du prochain appel à yylex ;
•
l’appel yyless (unEntier) fait qu’on ne garde que les unEntier premiers caractères de yytext : les suivants, s’il en y en a, seront considérés comme non encore lus lors du prochain appel à yylex ;
•
les fonctions input et unput sont utilisées par le code synthétisé par Lex, mais elles sont également disponibles dans les actions. Elles permettent de manipuler le tampon d’entrée lors de l’analyse lexicale : l’appel input () consomme un caractère du tampon d’entrée, tandis que unput (unCaractere) y replace unCaractere. On emploie typiquement input et unput pour analyser de manière prédictive, des constructions lexicales qui ne peuvent pas être décrites par des expressions régulières ;
•
l’appel output (unCaractere) permet d’écrire unCaractere sur le dispositif de sortie lors de l’analyse lexicale.
La fonction yywrap est très importante car c’est le seul endroit où l’on est conscient d’être arrivé à la fin du fichier source : cette condition ne peut pas être testée par les expressions régulières de Lex.
L’outil Lex 115
Une version de cette fonction suffisant pour les besoins usuels est présentée au paragraphe suivant. 6.6
Une librairie de support en C++ pour Lex
Deux fonctions complémentaires au code synthétisé par Lex doivent être fournies pour obtenir un analyseur syntaxique opérationnel. Ces fonctions, qui peuvent être placées dans le code de service, sont : •
yywrap, qui contrôle l’ouverture des fichiers de texte fournis à l’analyseur synthétisé par Lex, comme mentionné au paragraphe précédent ;
•
main, qui est le programme principal de l’analyseur synthétisé.
Par commodité, nous avons placé en librairie une version de chacune de ces fonctions, suffisante pour les besoins courants. Cette librairie LexSupport contient aussi une fonction ErreurLexicale, qui peut être appelée pour produire un message adéquat en cas d’erreur lexicale. On en voit un exemple au paragraphe 6.3. Le fichier d’interface LexSupport.h contient : int
yywrap ();
void
ErreurLexicale (char * leMessage); main (int argc, char ** argv);
Le fichier d’implantation LexSupport.cp est listé en appendice, à la page 353, à l’exception des deux fonction suivantes : int yywrap () { if ((-- pArgumentsRestants) > 0) { if (freopen (pArguments [++ pArgumentCourant], "r", yyin) == NULL) { perror (pArguments [pArgumentCourant]); exit (-1); } else { yylineno = 1; return 0; } }
//
re-initialise le compteur de lignes
else // on a fini de lire { cout << "--- FIN ---\n"; return 1; } } // yywrap
116 Compilateurs avec C++
main (int argc, char ** argv) { pArguments = argv; pArgumentsRestants = argc; pArgumentCourant = 0; yywrap (); //
ouverture d'un premier fichier
while (yylex () != 0) // ; // rien } //
jusqu'à la fin du ou des fichiers
On voit que c’est la sortie du programme par exit dans yywrap qui termine l’exécution dans le programme principal main de cette librairie. D’autres choix seraient possibles. 6.7
Analyse lexicale de Formula avec Lex
Il nous reste à dire comment nous construisons l’analyseur lexical opérationnel pour Formula à partir des trois parties de la description Lex de ce langage présentées dans ce chapitre. Le fichier LexFormula.Lex contient les trois parties listées dans les trois paragraphes mentionnés ci-dessus, séparés par une ligne contenant les deux caractères %% alignés à gauche. La fin de cette ligne est ignorée par Lex. On utilise le fichier “make“ listé en appendice, à la page 355. Exemple d’analyse
En utilisant l’analyseur lexical Formula synthétisé par Lex sur le source Formula erroné : cube (y) = y * y * y; pi = 314.1592e-2; ? cube (sinus(pi)];
on obtient la trace d’analyse suivante : Ident Ident
cube ( y ) =
Ident Ident Ident Ident Nombre
y * y * y ; pi = 3.141592 ;
L’outil Lex 117
Ident Ident Ident
? cube ( sinus ( pi )
### Erreur lexicale à la ligne 4, caractère 59, du fichier: WORK•:EXEMPLES COMPILATION:LexFormula:LexFormula.dat près du caractère Ascii (93), |]|: Caractère illégal ### 6.8
Expressions régulières acceptées par Lex
Voici toutes les possibilités d’expressions régulières acceptées par l’outil Lex. On peut spécifier les caractères de différentes manières : •
x
le caractère x ;
•
"x"
le caractère x, même si c’est un marqueur prédéfini ;
•
\x
le caractère x, même si c’est un marqueur prédéfini ;
•
[xy]
le caractère x ou le caractère y ;
•
[x-z]
le caractère x, le caractère y ou le caractère z ;
•
[^x-z]
tout caractère hors de l’intervalle de x à z ;
•
.
tout caractère autre que la fin de ligne \n.
On a de plus les facilités suivantes, dans lesquelles “x“ et “y“ peuvent être n’importe quelle expression régulière : •
(x)
x;
•
^x
x au début d’une ligne ;
•
x$
x à la fin d’une ligne ;
•
x?
un x optionnel ;
•
x*
0 fois ou plus x ;
•
x+
1 fois ou plus x ;
•
x[2,5]
de 2 à 5 répétitions de x ;
•
x|y
x ou y ;
•
x/y
x seulement s’il est suivi de y ;
•
{notion}
notion telle qu’elle est définie dans la première partie du fichier.
Remarquons qu’à cause de la signification particulière du point pour Lex, un “.“ explicite dans une expression régulière, comme celui des constantes numériques, doit être écrit "." ou “\“.
118 Compilateurs avec C++
Il est possible d’utiliser des constantes octales désignant des codes Ascii, comme dans [\40-\176] qui accepte tous les caractères entre l’espace et le caractère dont le code Ascii est 176 en base 8. De telles expressions régulières sont toutefois liées au code ASCII, et ne sont pas portables telles quelles sur des machines encodant les caractères d’une autre manière. 6.9
Actions prédéfinies de Lex
Lex propose plusieurs actions prédéfinies très utiles, parmi lesquelles les deux cidessous doivent être connues : •
l’action prédéfinie “ECHO;“ est équivalente à : printf ("%s", yytext);
et a donc pour effet de reproduire sur le fichier de sortie les caractères acceptés ; •
l’action prédéfinie “REJECT;“ indique que l’on ne veut pas accepter la notion correspondante dans les cas d’ambiguïtés.
Ainsi, si le but est de compter toutes les occurrences des mots ver et laver dans un texte, y compris les occurrences de ver faisant partie du mot laver, les productions : laver ver \n .
{enregister_laver; REJECT;} {enregister_ver; REJECT;} | ;
permettent d’accepter le mot laver temporairement, jusqu’à ce que REJECT rejette les caractères correspondants et fasse passer à la production suivante. Alors la deuxième production accepte le mot ver à son tour, ce qui fait que les caractères ver ont été acceptés deux fois. Sans cela, chaque caractère n’est accepté qu’une seule fois. REJECT n’est pas très utile à l’écriture de compilateurs, mais n’oublions pas que Lex peut aussi servir à d’autres manipulations de textes. 6.10
Gestion des ambiguïtés par Lex
Le fait que la grammaire régulière décrite par les productions que l’on fournit à Lex soit ambiguë ne constitue pas une erreur. Dans ce cas, le code synthétisé par Lex lève automatiquement les ambiguïtés en préférant la plus longue chaîne acceptable en nombre de caractères : •
si une telle chaîne est unique, c’est elle qui est acceptée ;
•
s’il en existe plusieurs, le code synthétisé par Lex utilise la première règle textuellement, parmi celles qui acceptent ce même nombre de caractères, pour faire la réduction.
L’outil Lex 119
On peut illustrer cela avec le cas des mot clés des langages usuels. Avec les spécifications : end {ident}
action pour le mot clé 'end' action pour 'identificateur'
si l’on doit analyser la chaîne de caractères : ends
on accepte la notion identificateur avec 4 caractères consommés. En revanche, avec la chaîne : end
c’est le mot clé end qui est accepté : les deux possibilités end et identificateur conduisent à accepter 3 caractères, mais la production pour le mot clé end est placée en premier. Si l’on permute les deux productions ci-dessus, end sera traité comme un identificateur, et non comme un mot clé. 6.11
Analyse multilangage
On peut analyser plusieurs langages simultanément dans une même description grammaticale soumise à Lex. Ceci se fait à l’aide de la spécification %start qui introduit des start condition (condition initiale, mode). Le mode “normal“, noté 0, n’a pas besoin d’être spécifié. Ainsi, dans : %start tout_en_majuscules tout_en_minuscules %% ^#
{ ECHO; BEGIN tout_en_majuscules; }
^;
{ ECHO; BEGIN tout_en_minuscules; }
\n
{ ECHO; BEGIN 0; /* on revient au mode ’normal’ */ }
.
{ printf ("%c", toupper (yytext [0])); }
.
{ printf ("%c", tolower (yytext [0])); }
on spécifie que l’on désire reproduire le contenu du fichier analysé en convertissant le contenu des lignes commençant par # en majuscule, celui des lignes commençant
120 Compilateurs avec C++
par “;“ en minuscules, et en laissant les autre lignes inchangées, ce qui est implicite mais pourrait être spécifié par : .
ECHO;
Dans cet exemple, le mode, “normal“ au début, peut changer au début d’une ligne selon le premier caractère qu’elle contient, et il redevient toujours “normal“ en fin de ligne. 6.12
Exercices
6.1 : Commentaires imbriqués (moyen). Comme dans l’exercice 5.1, on demande d’écrire, à l’aide de Lex cette fois, un analyseur des commentaires du genre ce ceux de C, mais imbriqués, comme dans : /* niveau principal /* niveau imbriqué */ suite … */
On peut utiliser la fonction yyinput pour manipuler le tampon d’entrée.
Chapitre
7
7 Analyse syntaxique
L’analyse syntaxique contrôle la bonne forme d’une séquence de terminaux pour déterminer si elle constitue une phrase du langage. Un analyseur syntaxique (parser) est basé sur un accepteur, fonction booléenne indiquant si le texte source est conforme aux règles de grammaire définissant le langage. On notera qu’un texte de plusieurs milliers de lignes dans un langage donné est une phrase au vu de la grammaire de ce langage. Il est important de bien voir que seule la forme est prise en compte dans l’analyse syntaxique : le fond, à savoir la signification du code source, n’est quant à lui vérifié que dans l’analyse sémantique, qui fera l’objet du chapitre 8. 7.1
Le besoin d’algorithmes d’analyse
Rappelons qu’une grammaire engendre un langage, qui est l’ensemble des séquences de terminaux pouvant être obtenues par dérivation de l’axiome. Le problème de l’analyse d’une séquence de terminaux consiste à exhiber un arbre de dérivation pour cette séquence, comme sous-produit de son acceptation.
122 Compilateurs avec C++
On peut s’y prendre de différentes manières pour construire un arbre de dérivation : c’est sa structure qui est importante, peu importe dans quel ordre il est obtenu. Ainsi, soit la grammaire : G = ( {
/* Terminaux */ "a", "b", "c", "d", "+", "-", "*", "/", "(", ")", "^"
}, {
/* Non_terminaux */ expression, terme, facteur
}, { /* Productions */ expression ⇒ terme | terme "+" expression | terme "-" expression, terme ⇒ facteur | facteur "*" terme | facteur "/" terme, facteur ⇒ "a" | "b" |"c" |"d" | "(" expression ")" | facteur "^" facteur }, expression )
/* Axiome */
Pour déterminer si la séquence de terminaux : a + d
est une phrase du langage engendré par G, nous pouvons commencer à construire l’arbre comme dans la figure 7.1, où les petits nombres à côté des arcs de l’arbre indiquent l’ordre dans lequel ils ont été construits. expression 3 terme 2 facteur 1 a
4 ? +
d
7.1Essai d’analyse ascendante Nous nous rendons compte à l’étape 4 qu’il nous est impossible de continuer, bien que nous ayons manifestement affaire à une phrase du langage. Le problème dans ce cas est que nous avons fait fausse route en réduisant terme en expression déjà à l’étape 3, et qu’aucune production n’est de la forme :
Analyse syntaxique 123
notion ⇒ expression "+" … Un retour en arrière est donc nécessaire pour essayer d’autres réductions et parvenir à construire l’arbre de dérivation, ce que nous laissons comme exercice au lecteur. 7.2
Analyse ascendante et analyse descendante
La manière dont nous avons procédé pour analyser la phrase a + d dans le paragraphe précédent est une méthode ascendante. Dans une méthode ascendante (bottom-up) on construit l’arbre de dérivation en partant des feuilles que sont les terminaux composant la séquence à analyser, pour remonter par des réductions successives vers la racine de l’arbre. Rappelons que la racine de tout arbre de dérivation n’est autre que l’axiome de la grammaire. Les numéros d’ordre de création des arcs illustrent bien ce cheminement ascendant dans le paragraphe précédent.
Il est aussi possible de procéder par une méthode descendante (top-down), dans laquelle on développe l’arbre de dérivation par un enchaînement de dérivations successives depuis la racine vers les feuilles. expression 1 terme 2 3 facteur
facteur 3 ^
4 a
3 facteur
5 ? +
d
7.2Essai d’analyse descendante On voit cette méthode à l’œuvre à la figure 7.2. Les trois occurrences du nombre 3 indiquent que l’on a décidé à cette étape d’essayer la dérivation : facteur ⇒ facteur "^" facteur Là encore ce choix est malheureux car le terminal + fait que cette tentative s’avère un échec à l’étape 5, et nous devons effectuer un retour arrière pour obtenir l’arbre de dérivation.
124 Compilateurs avec C++
7.3
Les trois méthodes prédictives principales
Les travaux dans le domaine de l’analyse syntaxique ont vu l’éclosion de beaucoup de formalismes et d’algorithmes dans les années passées. Avec le temps et l’expérience, on peut dire que trois méthodes d’analyse prédictives se sont imposées et suffisent pour couvrir les besoins usuels : •
la descente récursive, qui fait partie des méthodes descendantes. Elle n’est applicable qu’à des grammaires respectant des contraintes assez fortes ;
•
la méthode LR, qui est en fait une classe de méthodes ascendantes. Elle impose également des contraintes sur les grammaires, mais qui sont moins fortes que pour la descente récursive ;
•
la méthode de priorités d’opérateurs, qui n’est en général utilisée que pour les expressions au sens de la notation algébrique usuelle.
Nous présentons ces trois méthodes dans ce chapitre. D’autres méthodes, comme la précédence simple et la précédence faible, n’ont plus aujourd’hui qu’un intérêt historique et ne sont pas traitées dans ce livre. 7.4
Descente récursive
La descente récursive est une méthode prédictive dans laquelle on écrit une fonction booléenne pour chaque notion non terminale du langage, chargée de l’accepter. Comme les grammaires sont très souvent récursives, cela conduit à un ensemble de fonctions mutuellement récursives, d’où le nom de la méthode. L’exemple classique illustrant cette particularité est la trilogie “expression - terme - facteur - expression entre parenthèses“, que nous verrons au paragraphe 7.8. On retrouve dans l’analyse syntaxique par descente récursive l’image exacte des productions grammaticales : les notions non terminales deviennent des noms de fonctions et le corps de ces notions devient le corps, c’est-à-dire les instructions, de ces fonctions. Dans la descente récursive : •
un terminal apparaissant dans un corps de production devient une lecture du prochain terminal, exécutée par l’analyseur lexical, suivie de la vérification que c’est bien le terminal attendu ;
•
un non terminal apparaissant dans un corps de production devient un appel à la fonction chargée d’accepter cette notion ;
•
le séquencement dans les corps de productions devient le séquencement dans les corps des fonctions acceptantes, soit le séparateur “;“ en C++ ;
•
l’alternative simple [ … ] devient un if ;
•
l’alternative multiple ( … | … | … ) devient un switch ;
Analyse syntaxique 125
•
la répétition libre { … } devient un while.
On notera que l’on parle d’accepteurs en tant que fonctions booléennes, et qu’en pratique on trouve parfois des procédures. Dans ce cas la poursuite sans encombre de l’analyse indique l’acceptation, alors qu’une erreur syntaxique provoque la sortie prématurée du flot du contrôle après production d’un message d’erreur. Le fait qu’on sorte prématurément ou non est donc le résultat booléen implicite de l’analyse. La méthode prédictive utilisée en analyse lexicale au chapitre 5 est un cas particulier de descente récursive. Il n’y a en fait pas de récursion dans ce cas, mais le côté descendant et déterministe est indéniable, comme le lecteur peut s’en convaincre facilement. Exemple
Pour illustrer le concept de la descente récursive, voici l’exemple de la notion non terminale Definition, définie dans la grammaire Formula du paragraphe 7.8 par : Definition ⇒ EnteteDeFonction "=" Expression ";"
Dans l’analyseur Formula présenté au paragraphe 7.9, cette notion est acceptée par la méthode : void AnalyseurDescendantFormula :: Definition () { EnteteDeFonction (); if (fTerminal != EGALE) ErreurSyntaxique ("'=' attendu dans une définition"); else Avancer (); Expression (); if (fTerminal != POINT_VIRGULE) ErreurSyntaxique ("';' attendu après une définition"); else Avancer (); cout << "\n--> Définition\n\n"; } // AnalyseurDescendantFormula :: Definition
126 Compilateurs avec C++
7.5
Grammaires LL(1)
Comme nous l’avons indiqué, la méthode de descente récursive ne peut s’appliquer que si la grammaire considérée satisfait certaines contraintes. Voyons cela de plus près. Une grammaire est LL(n) si et seulement si elle peut être analysée par descente récursive déterministe en ne disposant à chaque instant que des “n“ prochains terminaux non encore consommés. Dans le terme “LL(n)“ : •
le premier “L“ signifie “left“ (gauche) : on analyse la phrase de gauche à droite, en consommant un terminal après l'autre, dans l'ordre où ils sont produits par l'analyse lexicale ;
•
le second “L“ signifie “leftmost derivation“ (dérivation la plus à gauche) : on construit l’arbre de dérivation de gauche à droite, en dérivant en premier le non terminal le plus à gauche dans le corps d’une production. C’est ce que nous avons fait dans l’illustration de la méthode descendante du paragraphe précédent ;
•
le “n“ indique le nombre de terminaux qu’il faut avoir lus sans les avoir encore consommés pour décider quelle dérivation faire, c’est-à-dire quelle production utiliser. Ce nombre est appelé lookahead (lire en avant), et n’a pas de nom particulier en français.
En pratique, on se limite aux grammaires LL(1). En effet, l’analyse d’une grammaire LL(3) impose de gérer 3 variables contenant les 3 prochains terminaux non encore consommés à chaque instant, et de faire des permutations circulaires de deux d’entre elles lors de chaque lecture d’un terminal. Alternativement, on peut utiliser un tableau de terminaux géré circulairement. Dans tous les cas, l’emploi d’un “n“ plus grand que 1 est moins efficace que la gestion d’une variable contenant simplement le prochain terminal lu mais non encore consommé. Plus formellement, une grammaire du type 2 est dite LL(1) si et seulement si : •
lorsque plus d’une production existe pour une notion non terminale donnée, toutes les séquences non vides de terminaux dérivables par les membres droits de ces productions diffèrent par leur premier terminal. Cela est nécessaire pour qu’on puisse sur ce seul premier terminal sélectionner la production à utiliser ;
•
dans le cas ci-dessus, au plus un corps de production peut engendrer une séquence vide de terminaux. En effet toutes les règles engendrant le vide seraient sélectionnables dès que l’une le serait, ce que nous voulons éviter pour que la méthode soit déterministe ;
Analyse syntaxique 127
•
si les deux conditions ci-dessus sont remplies et que le corps d’une production peut engendrer une séquence vide de terminaux, aucun corps d’une autre production définissant la même notion non terminale ne peut engendrer une séquence de terminaux commençant par un terminal pouvant suivre cette notion au vu de la grammaire. Ce critère un peu complexe est détaillé au paragraphe suivant.
Mentionnons encore le résultat théorique suivant. La question “telle grammaire donnée est-elle LL(1) ?“ est décidable. On s’appuie pour répondre à cette question sur le calcul des ensembles de terminaux FIRST et FOLLOW, présentés au paragraphe 4.14. 7.6
Problème des notions engendrant le vide
Pour illustrer le besoin du troisième critère définissant une grammaire LL(1), considérons le cas de la notion non terminale instruction au sens de Pascal, définie par exemple par : instruction ⇒ ε | variable ":=" expression | "if" expression … | "while" expression … | "for" expression … |
On sait en effet qu’une instruction peut être vide en Pascal : c’est le cas notamment entre un “;“ et un end comme dans : begin write(35); end
que l’on pourrait schématiser par : begin write(35); ε end On sait par ailleurs qu’un “;“ peut suivre une instruction en Pascal, puisque le “;“ sert précisément à séparer les instructions dans ce langage.
Supposons maintenant qu’une instruction Pascal puisse se dériver en une séquence de terminaux commençant par un “;“, par exemple au moyen de : instruction ⇒ ε | variable ":=" expression | "if" expression … | "while" expression … | "for" expression … | instruction_bizarre instruction_bizarre ⇒ ";" "blurk" …
128 Compilateurs avec C++
Regardons comment on peut alors analyser le fragment : i := 33 ; ; …
Le second “;“ est-il le début d’une instruction_bizarre, auquel cas nous accepterons ce fragment comme : affectation ';' instruction_bizarre …
ou bien a-t-on au contraire une instruction vide entre les deux “;“, auquel cas nous accepterons ce fragment comme : affectation ';' instruction_vide ';' …
Le non respect du troisième critère définissant une grammaire LL(1) dans cet exemple conduit donc à une grammaire ambiguë, ce qu’on veut à tout prix éviter. On trouve un autre exemple du problème des notions engendrant le vide au paragraphe 9.9. Comme on le voit, la méthode de descente récursive a besoin que les notions pouvant se dériver en le vide satisfassent à des critères assez fins. On peut automatiser complètement le test d’une grammaire pour voir si elle satisfait aux critères pour être du type LL(1), puisque ce problème est décidable. Ce qui n’est pas automatisable, en revanche, est la transformation d’une grammaire non LL(1) en une grammaire LL(1). La raison en est simplement que tous les langages ne sont pas engendrables par une grammaire LL(1), tant s’en faut, et qu’il est dans ce cas illusoire d’en rechercher une grammaire LL(1). On rejoint là les limites théoriques énoncées au paragraphe 4.14. 7.7
Récursion à gauche, ambiguïté et type LL(1)
Une grammaire contenant une production récursive à gauche ne peut pas être LL(1). Considérons une production du type 2 récursive à gauche, comme : expression ⇒ expression "*" expression | autres cas …
Tout terminal membre de FIRST(expression) peut alors aussi bien : •
être considéré comme débutant l’expression figurant avant le * dans le corps de la première production définissant expression ci-dessus ;
•
être considéré comme débutant le corps de l’une des autres productions définissant expression. Cela est justifié par le fait que ce terminal appartient précisément à FIRST(expression).
On a donc deux dérivations possibles, sans qu’on puisse déterminer avec le seul prochain terminal lu mais non encore consommé laquelle choisir.
Nous verrons que les grammaires LR permettent de traiter les productions récursives à gauche. En fait, il est même plus efficace avec ces grammaires d’utiliser
Analyse syntaxique 129
la récursion à gauche que de s’en passer ! On trouvera une illustration détaillée de ce phénomène au paragraphe 7.23. Une grammaire ambiguë ne peut pas être LL(1). La raison en est évidente, puisque nous avons tout fait pour qu’une grammaire LL(1) soit non ambiguë : à chaque choix de production pour une dérivation pour un appel de procédure - une seule alternative est sélectionnable étant donné les trois critères définissant une grammaire LL(1). Il ne peut donc exister plusieurs arbres de dérivation, correspondant à plusieurs productions sélectionnables, dans ces conditions. Toute grammaire LL(1) ne peut être ambiguë, et par contraposition, toute grammaire ambiguë ne peut être LL(1).
On peut synthétiser les considérations ci-dessus en disant que les grammaires LL(1) “collent“ à l’exécution des procédures des langages procéduraux usuels : l’analyse se fait de haut en bas par les appels de fonctions, et de gauche à droite par le séquencement de ces langages. Le déterminisme est inspiré par le fait que les langages procéduraux usuels ne gèrent pas le retour arrière. La restriction sur la récursion à gauche est due au fait que dans le code : void Expression () { Expression (); … }
on a une récursion infinie directe, et donc la certitude de ne pas terminer l’analyse en un temps fini, sinon par débordement de la pile d’exécution de l’analyseur. Prolog, que nous avons utilisé dans le chapitre 3 pour implanter Markovski, gère sans peine le retour arrière, mais n’accepte pas non plus la récursion à gauche. 7.8
Une grammaire LL(1) de Formula
Nous n’avons pas encore précisé la définition syntaxique du langage Formula, dont nous n’avons montré jusqu’ici que les aspects lexicaux. Il peut être décrit par la grammaire LL(1) ci-dessous : Formula = ( { /* Terminaux */ NOMBRE, IDENT, "(", ")", "=", ",", "+", "-", "*", "/", ";", "?", }, {
},
/* Non_terminaux */ Programme, FinProgramme, Definition, Evaluation, EnteteDeFonction, Parametres, FinParametres, Expression, Terme, Facteur, AppelDeFonction, Arguments, FinArguments
130 Compilateurs avec C++
{ /* Productions */ Programme ⇒ Definition FinProgramme | Evaluation FinProgramme, FinProgramme ⇒ Definition FinProgramme | Evaluation FinProgramme | ε , Definition ⇒ EnteteDeFonction "=" Expression ";", EnteteDeFonction ⇒ IDENT "(" Parametres ")" | IDENT, Parametres ⇒ IDENT FinParametres, FinParametres ⇒ "," IDENT FinParametres | ε , Evaluation ⇒ "?" Expression ";", Expression ⇒ ( "+" | ε ) Terme FinExpression, FinExpression ⇒ ε, FinExpression ⇒ ( "+" | "-" ) Terme FinExpression, Terme ⇒ Facteur FinTerme, FinTerme ⇒ ε, FinTerme ⇒ ( "*" | "/" ) Facteur FinTerme, Facteur ⇒ NOMBRE | IDENT | "(" Expression ")" | AppelDeFonction, AppelDeFonction ⇒ IDENT "(" Arguments ")", Arguments ⇒ Expression FinArguments, FinArguments ⇒ VIRGULE Expression FinArguments | ε }, Programme )
/* Axiome */
Il manque apparemment une expression conditionnelle à Formula pour en faire un outil sympathique. Que le lecteur se rassure : une fonction Si à trois paramètres est prédéfinie dans ce langage, comme on le verra au paragraphe 8.5. 7.9
Une descente récursive pour Formula
Nous utilisons dans ce paragraphe la méthode de descente récursive pour analyser syntaxiquement le langage Formula. L’analyse lexicale est confiée à une instance de la classe AnalyseurLexicalFormula définie au paragraphe 5.6, et on avance systématiquement au prochain terminal non encore consommé. Une particularité de cet analyseur est qu’il ne peut pas contenir des fonctions locales à d’autres fonctions, puisqu’il est écrit en C++ qui n’offre pas cette facilité. Nous avons donc déclaré toutes les fonctions accepteurs comme méthodes privées de la classe AnalyseurDescendantFormula, ce qui fait qu’elles peuvent s’appeler mutuellement sans restriction. Cette technique est d’ailleurs souvent employée en C++.
Analyse syntaxique 131
Notre descente récursive pour Formula s’appuie sur le type : union DescriptionTerminal { float char };
fNombre; * fIdent;
Elle utilise aussi les champs suivants : •
fAnalyseurLexical est un pointeur sur une instance de la classe AnalyseurLexicalPredictif rencontrée au paragraphe 5.6 ;
•
fTerminal contient la description du terminal courant.
Enfin, on utilise des méthodes ayant le même nom que, et calquées sur, les notions non terminales de la grammaire du paragraphe précédent. La méthode Avancer sert à alléger l’écriture, comme nous l’avons fait dans les analyseurs lexicaux du chapitre 5. Elle est en fait implantée inline (en ligne) pour ne pas devoir payer le prix d’un appel de fonction à chaque fois : inline void AnalyseurDescendantFormula :: Avancer () { fTerminal = fAnalyseurLexical -> LireUnTerminal (); cout << fAnalyseurLexical -> TerminalSousFormeTextuelle (fTerminal) << "\n"; }
La méthode qui lance l’analyse se contente de lire un premier terminal, puis d’appeler la fonction acceptant l’axiome de la grammaire Formula : void AnalyseurDescendantFormula :: Analyser () { Avancer (); // on amorce la pompe Programme (); }
La méthode “axiome“ est définie par : void AnalyseurDescendantFormula :: Programme () { while (fTerminal != FIN) { if (fTerminal == INTERROGE) { Avancer (); Evaluation (); } else Definition (); } } // Programme
132 Compilateurs avec C++
D’autres extraits intéressants de cette descente récursive figurent en annexe, au paragraphe A.3.1. Mentionnons encore pour finir la méthode : void AnalyseurDescendantFormula :: AppelDeFonction () { // IDENT et PAR_GAUCHE ont été acceptés Arguments (); }
dont le corps n’est constitué que d’un appel à Arguments. Une bonne pratique dans l’écriture des grammaires, et donc en descente récursive, est de grouper les terminaux ouvrant et fermant une construction parenthésée dans le même corps de production. Exemple d’analyse
Si l’on analyse par la descente récursive décrite ci-dessus le texte Formula : fonct (x) = - 2.0 * x + 34; ? fonct( E + 5 );
on obtient comme résultat : Ident Ident
Nombre Ident Nombre
fonct ( x ) = 2.000000 * x + 34.000000 ; ?
--> Définition Ident Ident Nombre
fonct ( E + 5.000000 ) ;
--- FIN ----> Evaluation
La trace ci-dessus met en évidence que cet analyseur descendant a toujours un terminal d’avance. En effet, le terminal “?“ a déjà été lu lorsqu’on produit une trace
Analyse syntaxique 133
indiquant que l’on vient d’accepter une définition de fonction, avant même de sortir de la méthode Definition. 7.10
Comportement en cas d’erreurs syntaxiques
Que fait l’analyseur descendant récursif de Formula en cas d’erreur syntaxique ? Pour l’illustrer nous pouvons analyser le code source suivant, dans lequel la faute consiste en l’absence d’un facteur après * dans le corps de la fonction carre : carre (x) = x * ; ? carre (9);
La trace d’analyse est la suivante : Ident Ident Ident
carre ( x ) = x * ;
### Erreur syntaxique: NOMBRE, IDENT ou EXPRESSION parenthésée attendu comme Facteur fTerminal = ' ;' ### ? ### Erreur syntaxique: ';' attendu après une définition fTerminal = ' ?' ### --> Définition Ident
carre ( Nombre 9.000000 ) ; --- FIN ----> Evaluation
Les deux phénomènes typiques auxquels nous assistons ici sont : •
la production d’une cascade de messages d’erreurs, dont certains sont superflus (spurious messages), parce qu’ils résultent d’une erreur précédente. C’est le cas du message disant que “;“ est attendu après une définition, sur le terminal “?“ ;
•
la re-synchronisation de l’analyseur : après avoir consommé un certain nombre de terminaux, il finit par trouver ce qu’il cherchait, et la suite de
134 Compilateurs avec C++
l’analyse peut être effectuée dans problème. Dans l’exemple ci-dessus, la re-synchronisation s’effectue sur le terminal carre après le “?“. 7.11
Rattrapage d’erreurs syntaxiques
La conduite à tenir à la compilation en cas d’erreur est une question importante, en particulier en cas d’erreur syntaxique. Doit-on interrompre la compilation à la première erreur ou continuer et analyser le plus possible de texte source en un passage ? Avec le développement du travail interactif, la montée en puissance des équipements et le boom de l’informatique individuelle, la réponse de nos jours nous semble être : •
si le temps de compilation est important, il faut de toute façon analyser le plus possible en une fois ;
•
si la compilation est rapide, on peut s’arrêter aux “n“ premières erreurs.
Le cas où “n“ vaut 1, c’est-à-dire où l’on s’arrête de compiler dès la première erreur, peut conduire les gens à ne corriger que cette erreur sans chercher plus loin dans le source d’autre fautes éventuelles avant de relancer une compilation. Il semble que cela est plus admissible pour un compilateur destiné à des professionnels qu’à des débutants.
Pour pouvoir analyser le plus possible du texte source en une fois, il faut procéder à un rattrapage des erreurs (error recovery). Ainsi, dans le fragment : if (fTerminal != POINT_VIRGULE) ErreurSyntaxique ("';' attendu après une évaluation"); else Avancer ();
on reste sur le terminal erroné sans le consommer, tandis que le code : if (fTerminal != POINT_VIRGULE) ErreurSyntaxique ("';' attendu après une évaluation"); Avancer ();
fait que l’on avance tout de même dans le cas où le “;“ manque : l’effet est de remplacer le terminal erroné par celui qui est attendu. Il peut toutefois être gênant de trop consommer de terminaux car cela peut engendrer la production d’autres messages, superflus ceux-là. Une bonne re-synchronisation de l’analyseur syntaxique devrait idéalement être interactive. A défaut d’interaction, on peut utiliser des connaissances issues de l’expérience. Des études ont montré que les élèves des cours d’introduction à la programmation font des fautes typiques, sur lesquelles un rattrapage psychologique d’erreurs peut donner de bons résultats.
Analyse syntaxique 135
Un premier exemple implanté dans le compilateur Newton par l’auteur est celui du “;“ superflu après l’en-tête d’une procédure, comme dans : procedure proc; declare … do … done Ce terminal est simplement en trop en Newton, donc le rattrapage l’ignore après qu’un message d’erreur ait été produit.
Un second exemple, toujours en Newton, est celui de l’initialisation des variables non modifiables après leur initialisation, qui se fait par = et non par := comme dans le cas des autres variables. Dans le fragment : integer value limite := fonct (34); le rattrapage ad hoc remplace le := par le = qui était attendu, ce qui est sans doute la bonne manière de faire dans ce cas. Méthode des arrêteurs
On peut utiliser la méthode des arrêteurs (stoppers) employée dans le premier compilateur Pascal [Amman 75], puis dans différents autres compilateurs. L’idée est d’admettre de consommer certains terminaux en cas d’erreur, mais de forcer une réduction sur certains autres, sans les consommer parce qu’ils sont attendus dans la suite de la phrase. Voici un extrait du compilateur Pascal-S original écrit par Wirth et décrit dans [Barron 81], dans lequel on voit la procédure ifstatement chargée d’analyser les instructions if. La procédure insymbol est l’analyseur lexical. Le paramètre fsys du type symset de la procédure statement est l’ensemble des arrêteurs sur lesquels l’analyseur doit se re-synchroniser, c’est-à-dire ceux qu’il ne doit pas consommer en cas d’erreur. Cet ensemble est enrichi localement par différents terminaux. C’est le cas de then, car on sait qu’il doit être présent plus loin dans le code source si ce dernier est bien formé. En revanche, le mot clé else est optionnel dans la syntaxe de l’instruction if, et il n’apparaît donc pas dans ces enrichissements . procedure statement (fsys: symset); var i: integer; (* … … … … … … … … … … … … … *) procedure ifstatement; var x : item; lc1, lc2 : integer; begin insymbol; expression (fsys + [thensy, dosy], x);
136 Compilateurs avec C++
if not (x.typ in [bools, notyp]) then error(17); lc1 := lc; emit (11);
(* jmpc *)
if sy = thensy then insymbol else begin error (52); if sy = dosy then insymbol end ;
(* rattrapage psychologique *)
statement (fsys + [elsesy]); if sy = elsesy then begin insymbol; lc2 := lc; emit (10); code[lc1].y := lc; statement (fsys); code[lc2].y := lc end else code[lc1].y := lc end (* ifstatement *); begin (* … *) end (* statement *);
On voit là un exemple de compilation en une passe, où l’on fait à la fois l’analyse lexicale et syntaxique, et l’analyse sémantique par : if not (x.typ in [bools, notyp]) then error(17);
et la génération de code par tout le reste, comme : lc2 := lc; emit (10);
Cet exemple montre que l’on a prévu un rattrapage psychologique pour le cas où l’on trouverait do plutôt que then. L’expérience de l’auteur lors de l’emploi de la méthode des arrêteurs dans le compilateur Newton est qu’un rattrapage meilleur serait obtenu en gérant une pile d’ensembles d’arrêteurs en parallèle avec les appels aux procédures acceptantes, plutôt qu’en mettant le tout à plat dans un seul ensemble passé en paramètre.
Analyse syntaxique 137
7.12
Méthode de priorités d’opérateurs
La méthode de priorités d’opérateurs est ascendante et se base sur une hiérarchisation des opérateurs par leurs priorités, en prenant en compte leur associativité. Cette méthode s’emploie par exemple dans les analyseurs syntaxiques Prolog, langage dans lequel on peut re-définir les priorités des opérateurs prédéfinis et définir ses propres opérateurs. Elle est aussi appliquée dans les compilateurs Newton développés par l’auteur, qui la combinent avec la descente récursive. Dans la méthode des priorités d’opérateurs, l’analyse se fait avec deux piles : •
l’une contient les opérateurs en attente d’opérandes ;
•
l’autre contient les opérandes en attente d’opérateurs.
Tout terminal qui n’est pas un opérateur est consommé, et on empile une description correspondante dans la pile des opérandes. Tout opérateur est empilé s’il est plus prioritaire que celui en sommet de pile. Sinon, on désempile les opérateurs moins prioritaires que lui qui peuvent se trouver en sommet de pile, et on fait la réduction correspondante. Chaque fois que l’on réduit ainsi un opérateur, on consomme le nombre d’opérandes nécessaires sur la pile des opérandes et on empile une description du résultat sur cette même pile.
Comme toutes les méthodes ascendantes, l’analyse par la méthode de priorité des opérateurs a besoin de savoir quand l’expression est terminée. Cela se produit soit lorsqu’on tombe sur le pseudo-terminal FIN_EXPR, soit lorsque le terminal courant n’est ni un opérateur, ni le début d’un opérande. Ainsi, la parenthèse fermante ) et le crochet fermant ], mais aussi le point-virgule “;“, indiquent la fin d’une expression en Pascal et en C++. Le cas des expressions imbriquées entre parenthèses est traité tout naturellement par un appel récursif à expression, qui utilise les mêmes piles d’opérateurs et d’opérandes. L’expression en cours d’analyse est correcte si la pile des opérandes contient un élément de plus lorsqu’on rencontre la fin de l’expression que lors du début de l’analyse.
Pour que la fin de l’expression force la réduction des opérateurs encore dans la pile, compte tenu du fait que les expressions peuvent être imbriquées, on définit deux pseudo-opérateurs DEBUT_EXPR et FIN_EXPR : •
l’opérateur monadique FIN_EXPR est moins prioritaire que tous les vrais opérateurs, et on traite la fin de l’expression comme une occurrence de cet opérateur. Cela a pour effet que tous les vrais opérateurs non encore
138 Compilateurs avec C++
réduits voient leur réduction forcée par cet opérateur moins prioritaire qu’eux ; •
il ne faudrait que la réduction forcée ci-dessus vide toute la pile en cas d’expressions imbriquées entre parenthèses. A cette fin, le pseudo-opérateur DEBUT_EXPR est empilé au tout début de l’analyse de l’expression, comme marquage de la pile des opérateurs. Pour qu’il ne soit pas réduit par le pseudo-opérateur FIN_EXPR, on lui donne une priorité plus faible encore que ce dernier. C’est la réduction de FIN_EXPR qui fait que l’on sort de l’analyse avec succès.
Voici schématisé en pseudo-code l’algorithme utilisé dans la l’analyse syntaxique selon la méthode de priorités des opérateurs : DescriptionOperande AnalyseurPriopsFormula :: Expression () { on empile DEBUT_EXPR boucle infinie externe { si on a affaire à un opérande, on empile la description de ce Facteur () puis on ré-entre dans la boucle selon le terminal courant, on a un opérateur dit "opérateur courant" ou on a affaire à la fin de l'expression "FIN_EXPR" boucle infinie interne, on traite l'opérateur courant { si l'opérateur courant est plus prioritaire que le sommet de la pile des opérateurs, on sort de cette boucle on annonce que l'opérateur courant force le traitement du sommet de la pile des opérateurs si l'opérateur en sommet de pile est monadique, on réduit cet opérateur monadique sinon l'opérateur en sommet de pile est dyadique: on réduit cet opérateur dyadique } boucle infinie interne si l'opérateur courant est FIN_EXPR, l'expression est finie: { on désempile DEBUT_EXPR si la différence de hauteur de la pile des opérandes est différente de 1, on désempile tous les opérandes superflus éventuels le résultat de l'expression est le sommet de la pile des opérandes
Analyse syntaxique 139
on retourne fPileDesOperandes.Desempiler () } on empile l'opérateur courant et on avance au prochain terminal } boucle infinie externe } // AnalyseurPriopsFormula :: Expression Exemple d’analyse
L’analyse par la méthode de priorité des opérateurs du source Formula erroné : ? 5 - - 3 + 9.2 - ;
produit comme trace : Nombre Nombre
? 5.000000 3.000000 +
--> ' + ' force le traitement du sommet de pile '- ', arité 1 --> ' + ' force le traitement du sommet de pile ' - ', arité 2 Nombre
9.200000 -
--> ' - ' force le traitement du sommet de pile ' + ', arité 2 ; ### Erreur syntaxique: une expression se termine par un opérateur sans opérandes après lui fTerminal = ' ;' ### --> 'FIN_EXPR' force le traitement du sommet de pile ' - ', arité 2 --> Résultat de Expression (): ((( 5.00 - (3.00)) + --- FIN ----> Evaluation
9.20) - OPERANDE_ERRONE)
140 Compilateurs avec C++
7.13
La méthode LR
Nous présentons en détail la méthode d’analyse syntaxique LR dans la fin de ce chapitre, à la fois pour son intérêt propre et pour pouvoir utiliser l’outil Yacc. Ce dernier est présenté au chapitre 9, qui contient une grammaire LR du langage Formula. Cette méthode ascendante, due à Knuth, présente les avantages suivants : •
c’est la méthode d’analyse déterministe la plus générale connue applicable aux grammaires non ambiguës ;
•
elle détecte les erreurs de syntaxe le plus tôt possible, en lisant les terminaux successifs de gauche à droite ;
•
elle permet d’analyser toutes les constructions syntaxiques des différents langages courants.
Nous verrons au paragraphe 7.22 pourquoi elle est plus générale que la descente récursive, dans le sens qu’elle permet d’analyser plus de langages que cette dernière.
Le mauvais côté de la méthode LR est qu’elle se base sur l’utilisation d’une table d’analyse, et qu’elle n’a donc pas la lisibilité de la descente récursive. De plus, la table pouvant avoir plusieurs centaines ou même milliers d’éléments, il est impératif d’utiliser un programme pour la synthétiser, comme Yacc, que nous présentons en détail au chapitre 9. Une grammaire indépendante du contexte (du type 2) est LR(n) si elle peut être analysée par la méthode LR en ne disposant à chaque instant que des “n“ prochains terminaux non encore consommés. Comme les critères formels définissant les grammaire LR(n) sont très peu digestes et n’apportent rien en pratique, nous nous limitons à la définition indirecte suivante :
Une grammaire est dite LR si on peut construire une table d’analyse par l’algorithme spécifié dans les paragraphes suivants. Dans le terme “LR(n)“ : •
le “L“ signifie “left“ (gauche) : on analyse la phrase de gauche à droite, comme dans le cas de la descente récursive.
•
le “R“ signifie “rightmost derivation“ (dérivation la plus à droite) : on construit l’arbre de dérivation de droite à gauche, en dérivant en premier le non terminal le plus à droite dans le corps des productions ;
Analyse syntaxique 141
•
là encore, le nombre “n“ est le lookahead, c’est-à-dire le nombre de terminaux qu’il faut avoir lu, mais pas encore consommé, pour faire l’analyse.
En pratique, on se limite aux grammaire LR(1). Il serait suffisant théoriquement de se limiter au cas LR(0), mais les grammaires que l’on a tendance à écrire naturellement pour les langages informatiques sont LR(1), et il serait fastidieux d’en ré-écrire des grammaires LR(0). La taille des tables d’analyse est aussi notablement plus grande dans le cas LR(0) que dans le cas LR(1).
La méthode LR(1) contient plusieurs cas particuliers correspondant à des manières plus ou moins fines de construire la table d’analyse. Toutefois, l’algorithme d’analyse est le même dans tous les cas. On arrive ainsi à une hiérarchie contenant dans l’ordre : •
les grammaires SLR(1) où “S“ signifie “simple“ : c’est le cas le plus contraint, mais aussi celui qui couvre le moins de langages ;
•
les grammaires LALR(1) où “LA“ signifie “lookahead“ : ce cas couvre beaucoup plus de langages, tout en gardant à la table d'analyse la même taille que dans le cas SLR(1) ;
•
les grammaires LR(1) proprement dites, qui sont les plus générales, mais au prix de tables d'analyse beaucoup plus volumineuses.
Les méthodes SLR(1) et LALR(1) sont dues à DeRehmer [DeRehmer & Pennello 82].
La taille des tables pour un langage de la complexité grammaticale de Pascal est de l’ordre de la centaine dans les cas SLR(1) et LALR(1), et du millier dans le cas LR(1). On voit sur ces chiffres qu’un outil automatique pour la création des ces tables s’avère indispensable. La méthode LALR(1) est un bon compromis utilisé par l’outil Yacc. Les méthodes LR construisent l’arbre de dérivation en ordre inverse, en partant des feuilles, comme illustré à la figure 7.3. Comme on dérive en premier le non terminal le plus à droite dans le corps des productions, on ne peut pas espérer écrire aussi directement que dans la descente récursive, pour chaque notion non terminale, une procédure chargée de l’accepter. Il est nécessaire de construire à partir de la grammaire une table qui sera utilisée pour diriger l’analyse. 7.14
Positions et états d’analyse LR
On raisonne dans la méthode LR en termes de positions d’analyse (LR(0) item). Une position d’analyse est schématisée par un point “•“ placé dans le corps d’une production. Elle indique que l’on a accepté ce qui précède le point dans le corps, et qu’il reste à accepter ce qui suit le point.
142 Compilateurs avec C++
Ainsi : expression ⇒ expression • "+" terme
est une position d’analyse dans laquelle on est en train d’accepter une expression : on a déjà accepté une expression constituant le début du corps, et on est prêt à accepter le terminal + puis la notion terme. L’idée centrale de la méthode LR est, étant donnée une position d’analyse, d’obtenir par fermeture transitive toutes les possibilités de continuer l’analyse du texte source. L’analyse d’une grammaire pour déterminer si elle est LR(n) s’appuie sur le calcul des ensembles FIRST et FOLLOW définis au paragraphe 4.14. Pour pouvoir raisonner uniformément sur toutes les “vraies“ productions, mais quand même traiter spécialement le cas de l’acceptation de la phrase candidate en tant qu’axiome, on ajoute une production de la forme générale : axiome_bis ⇒ axiome
qui servira de point de départ à l’analyse. Ainsi, à la grammaire des expressions : (1) expression ⇒ expression "+" terme (2) expression ⇒ terme (3) terme ⇒ terme "*" facteur (4) terme ⇒ facteur (5) facteur ⇒ "(" expression ")" (6) facteur ⇒ ident
on ajoute la production : expression_bis ⇒ expression
L’algorithme qui suit détermine si une grammaire donnée est SLR(1). Nous verrons au paragraphe 7.18, ce qui diffère dans les cas LALR(1) et LR(1). Au début de l’analyse, on se trouve dans la position d’analyse initiale : expression_bis ⇒ • expression
c’est-à-dire que l’on a encore rien consommé, et que l’on est prêt à accepter une expression. En fait, d’après les productions définissant notre grammaire, on peut se trouver à ce stade dans l’une des positions d’analyse suivantes : état_0 : expression_bis ⇒ • expression expression ⇒ • expression "+" terme expression ⇒ • terme
Analyse syntaxique 143
terme ⇒ • terme "*" facteur terme ⇒ • facteur facteur ⇒ • "(" expression ")" facteur ⇒ • ident
C’est là que l’on voit la fermeture transitive à l’œuvre : on est prêt à accepter une expression qui peut être obtenue par deux productions, dont l’une dit que c’est un terme. On est donc prêt à accepter ce qui se trouve défini comme un terme de deux manières, dont l’une dit que c’est un facteur, lequel se trouve aussi défini par deux productions. On est donc finalement prêt à accepter une expression entre parenthèses ou un ident. En pratique, pour chaque position d’analyse de la forme : notion ⇒ préfixe • non_terminal suffixe
la fermeture transitive consiste à ajouter, pour toute production définissant la notion non_terminal : non_terminal ⇒ corps
l’état d’analyse : non_terminal ⇒ • corps
à l’état en cours de construction, pour autant que cette position n’en fasse pas déjà partie. Si corps débute lui-même par une notion non terminale, on fait la fermeture transitive pour cette dernière également, et ainsi de suite jusqu’à saturation. Dans le terme “fermeture transitive“ : •
le mot “transitive“ signifie que l’on propage la connaissance que l’on a de la position d’analyse en tenant compte des productions définissant la notion non non terminale que l’on est prêt à accepter ;
•
le mot “fermeture“ signifie que l’on fait cette propagation de toutes les manières possibles, combinatoirement. Il y a “fuite en avant“ par saturation.
On appelle “état d’analyse initial“ l’ensemble des positions d’analyse obtenues par la fermeture transitive de la position initiale. De manière plus générale : Un état d’analyse est la fermeture transitive d’une position d’analyse. C’est donc un ensemble de positions d’analyse.
144 Compilateurs avec C++
7.15
Transitions LR
A chaque pas lors de l’analyse par la méthode LR, on peut en général accepter une notion par réduction ou consommer un terminal. Dans notre exemple, cela peut se faire dans l’état initial : •
en acceptant une expression : on se retrouve alors dans l’état formé des positions d’analyse suivantes : état_1 : expression_bis ⇒ expression • expression ⇒ expression • "+" terme
signifiant que l’on vient d’accepter une expression. Si l’on est en train d’accepter une expression_bis, l’analyse est terminée et l’on conclut à l’acceptation de la phrase candidate. Si c’est une expression que l’on est en train d’accepter, on est alors prêt à accepter le terminal +, puis la notion terme ; •
en acceptant un terme : le nouvel état est formé des positions d’analyse : état_2 : expression ⇒ terme • terme ⇒ terme • "*" facteur
•
en acceptant un facteur : le nouvel état est constitué de la seule position d’analyse : état_3 : terme ⇒ facteur •
•
en consommant le terminal ( : on se retrouve dans l’état : état_4 : facteur ⇒ "(" • expression ")" expression ⇒ • expression "+" terme expression ⇒ • terme terme ⇒ • terme "*" facteur terme ⇒ • facteur facteur ⇒ • "(" expression ")" facteur ⇒ • ident
•
en consommant le terminal ident : on se retrouve dans l’état : état_5 : facteur ⇒ ident •
Remarquons que dans l’état état_4, la fermeture transitive est appliquée à la position d’analyse : facteur ⇒ "(" • expression ")"
car puisque l’on est prêt à accepter une expression, on est prêt à accepter tout aussi bien un terme qu’un facteur, comme on l’a déjà vu ci-dessus.
Analyse syntaxique 145
Les états état_1 à état_5 sont ceux que l’on peut atteindre depuis l’état initial état_0 par acceptation d’une notion terminale ou non terminale. On dit en terminologie LR que : goto goto goto goto goto
( ( ( ( (
état_0, état_0, état_0, état_0, état_0,
expression terme facteur "("
ident où goto signifie “aller à“ en anglais.
) = état_1 ) = état_2 ) = état_3 ) = état_4 ) = état_5
La table “goto“ indique une transition d’un état d’analyse LR à un autre par l’acceptation d’un non terminal ou la consommation d’un terminal. Calcul de la table des états d’analyse LR
Il nous reste à compléter l’ensemble des états d’analyse distincts, et des transitions entre eux, à partir des nouveaux états obtenus état_1 à état_5. Cela se fait pour toutes les positions d’analyse dans lesquelles la marque “•“ se trouve devant un terminal ou non terminal, telles que l’état obtenu par acceptation de ce terminal ou non terminal n’existe pas encore dans l’ensemble des états. Les positions d’analyse dans lesquelles le point “•“ se trouve tout en fin du corps ne sont pas utilisée dans cette phase. Dans notre exemple de grammaire pour expression, cela nous conduit à : goto ( état_1, "+" ) = état_6 état_6 : expression ⇒ expression "+" • terme terme ⇒ • terme "*" facteur terme ⇒ • facteur facteur ⇒ • "(" expression ")" facteur ⇒ • ident goto ( état_2, "*" ) = état_7 état_7 : terme ⇒ terme "*" • facteur facteur ⇒ • "(" expression ")" facteur ⇒ • ident goto ( état_4, expression ) = état_8 état_8 : expression ⇒ expression • "+" terme facteur ⇒ "(" expression • ")" goto ( état_6, terme ) = état_9 état_9 : expression ⇒ expression "+" terme • terme ⇒ terme • "*" facteur
146 Compilateurs avec C++
goto ( état_7, facteur ) = état_10 état_10 : terme ⇒ terme "*" facteur • goto ( état_8, ")" ) = état_11 état_11 : facteur ⇒ "(" expression ")" •
Notons que l’on n’ajoute un nouvel état que s’il n’existe pas encore dans l’ensemble des états déjà construits. Ainsi, en considérant : état_4 : facteur ⇒ "(" • expression ")" expression ⇒ • expression "+" terme expression ⇒ • terme terme ⇒ • terme "*" facteur terme ⇒ • facteur facteur ⇒ • "(" expression ")" facteur ⇒ • ident
la transition lorsque l’on accepte un terme nous conduit à l’état contenant les deux positions d’analyse : expression ⇒ terme • terme ⇒ terme • "*" facteur
dans lequel il n’y a pas d’ajout de positions d’analyse nouvelles par fermeture transitive car la marque “•“ n’apparaît pas devant un non terminal. Cet état n’est autre que état_2, obtenu par transition de état_0 lors de l’acceptation de terme. L’ordre dans lequel les positions d’analyse constituant un état d’analyse sont écrites n’a pas d’importance, puisqu’un état est un ensemble, au sens mathématique du terme, de positions d’analyse. Bien qu’un nouvel état ne soit pas créé dans le cas où un état contenant les mêmes positions d’analyse existe déjà, la transition correspondante est tout de même enregistrée dans la table goto, ce qui donne dans notre exemple : goto goto goto goto
( ( ( (
état_4, état_4, état_4, état_4,
"(" ident terme facteur
) ) ) )
= = = =
état_4 état_5 état_2 état_3
goto ( état_6, "(" ) = état_4 goto ( état_6, ident ) = état_5 goto ( état_6, facteur ) = état_3 goto ( état_7, "(" goto ( état_7, ident
) = état_4 ) = état_5
goto ( état_8, "+"
) = état_6
goto ( état_9, "*"
) = état_7
Le nom transition semblerait mieux adapté que goto dans ce contexte, mais il n’a pas été choisi historiquement.
Analyse syntaxique 147
7.16
Conduite de l’analyse LR
A ce stade, les états d’analyse et la table goto sont connus. Il nous reste à déterminer la conduite à tenir lors de l’analyse concrète d’une phrase du langage engendré par notre grammaire LR. La table “action“ indique quel comportement l’analyseur doit avoir en fonction de l’état d’analyse courant et du prochain terminal non encore consommé. Pour construire la table action, on passe en revue chaque état d’analyse, et on stocke des informations dans la table au passage. L’algorithme est le suivant : •
si l’état état_i contient un position d’analyse de la forme : notion ⇒ préfixe • terminal suffixe
et que : goto ( état_i, terminal ) = état_destination
alors on choisit : action ( état_i, terminal ) = shift état_destination qui consiste, dans l’état état_i, à consommer le terminal et à effectuer la transition vers l’état état_destination. Le verbe “to shift“ signifie “décaler“ en anglais, soit le passage au terminal suivant ;
•
si l’état état_i contient la position d’analyse : axiome_bis ⇒ axiome •
on choisit : action( état_i, FIN ) = accept qui fait que l’on accepte la séquence de terminaux analysée comme faisant partie du langage, et que l’on termine l’analyse avec succès. Toute méthode d’analyse ascendante a besoin de savoir qu’elle est arrivée à la fin de la séquence de terminaux à analyser, ce que nous dénotons par le pseudo-terminal FIN ;
•
si l’état état_i contient un position d’analyse : notion ⇒ corps •
où notion n’est pas axiome_bis, alors pour tous les terminal pouvant suivre la notion au vu de la grammaire, on fixe : action ( état_i, terminal ) = reduce ’notion ⇒ corps’
qui fait que l’on opère une réduction avec la production indiquée dans cette position d’analyse ; •
on garde le contenu de la table goto pour toutes les entrées dont le second argument est une notion non terminale ;
148 Compilateurs avec C++
•
toutes les entrées de la table action qui n’ont pas été garnies par les quatre considérations ci-dessus sont marquées par : action ( état_i, terminal_i ) = error qui fait que l’analyse produira un message indiquant que la phrase analysée est syntaxiquement incorrecte ;
L’état contenant la position d’analyse : axiome_bis ⇒ • axiome
sera l’état initial pour l’analyse. 7.17
Conflits LR
La méthode de construction des tables goto et action présentée dans les paragraphes précédents est dite SLR(1) pour “simple LR“. Le lecteur intéressé trouvera dans [Aho, Sethi & Ullman 88] le détail formel des algorithmes présentés ici. Il se peut que la construction de la table action conduise à des conflits parce qu’on devrait mettre plus d’une valeur dans certaines de ses entrées. Cela peut se produire dans deux cas : •
un conflit “consommer/réduire“ (shift/reduce) se produit lorsque, dans un état donné et pour un terminal donné, on peut aussi bien consommer ce terminal (shift) que faire une réduction (reduce). Cela se produit, par exemple, avec la grammaire G1 rencontrée au paragraphe 4.6. Un autre cas classique est celui du terminal else en Pascal. Dans l’exemple : if condition_1 then if condition_2 then instruction_1 else instruction_2
on peut accepter le else, ce qui conduit à reconnaître un if … then contenant un if … then … else, mais où l’on peut aussi faire la réduction de if condition_2 then … instruction_1, reconnaissant un if … then … else contenant un if … then. Ce phénomène est dû au fait que la grammaire “naturelle“ pour l’instruction if présentée ci-dessus est ambiguë, et qu’aucune grammaire ambiguë n’est LR(1), et a fortiori non plus SLR(1). •
un conflit “réduire/réduire“ (reduce/reduce) se produit lorsque deux réductions sont possibles pour un état donné et un terminal donné.
En cas de conflit dans l’algorithme illustré dans les paragraphes précédents, la grammaire considérée n’est pas SLR(1).
Analyse syntaxique 149
C’est le fait que l’on puisse construire la table action sans conflit par l’algorithme décrit dans les paragraphes précédents qui fait qu’une grammaire est SLR(1). On trouve des exemples de conflits LR(1) au paragraphe 9.8, et au paragraphe 9.9. 7.18
Le besoin de méthodes plus puissantes que SLR(1)
Pour illustrer le besoin d’aller au-delà de SLR(1), considérons le code C++ suivant : * fonction_retournant_un_pointeur (son_argument) = une_expression;
On peut décrire ces instructions par une grammaire contenant, par exemple : instruction ⇒ partie_gauche "=" partie_droite; instruction ⇒ partie_droite; partie_gauche ⇒ "*" partie_droite; partie_gauche ⇒ IDENT; partie_droite ⇒ partie_gauche; L’idée est que partie_gauche est une variable à laquelle on peut affecter une valeur, tandis que partie_droite est une expression. Il se trouve que l’on peut obtenir une telle variable, parfois appelée “partie à gauche“ (left value, left hand side, lhs), en parcourant un pointeur obtenu par une expression, comme dans l’exemple ci-dessus.
Les états d’analyse construits par la méthode SLR(1) pour les productions cidessus contiennent entre autres : état_0 : instruction_bis ⇒ • instruction instruction ⇒ • partie_gauche "=" partie_droite instruction ⇒ • partie_droite partie_gauche ⇒ • "*" partie_droite partie_gauche ⇒ • ident partie_droite ⇒ • partie_gauche état_1, égal à goto (état_0, instruction) : instruction_bis ⇒ instruction • état_2, égal à goto (état_0, partie_gauche) : instruction ⇒ partie_gauche • "=" partie_droite partie_droite ⇒ partie_gauche •
Sur le terminal =, l’état état_2 appelle un “consommer“ au vu de la première position d’analyse figurant dans cet état, mais aussi un“ réduire“ en la notion non terminale partie_droite au vu de la seconde position d’analyse. Cela est dû au fait que = peut suivre la notion partie_droite d’après les productions de la grammaire. On s’en rend compte lorsqu’on calcule FOLLOW(partie_droite) par fermeture transitive, étant donné qu’une partie gauche peut être constituée d’une * suivie d’une partie_droite. Il y a donc un conflit “consommer/réduire“ dans ce cas, et la grammaire cidessus n’est pas SLR(1). Cependant, elle est LALR(1) et LR(1). La clé dans cet exem-
150 Compilateurs avec C++
ple est que l’algorithme de construction de la table action pour SLR(1) ne prend pas assez d’informations en compte pour éviter ce conflit, tandis que les deux autres algorithmes de construction de la table action s’y prennent mieux. La table action résultante dans le cas LALR(1) a toujours la même taille que dans le cas SLR(1) pour un langage donné. Toutefois sa construction fait qu’elle peut s’appliquer à plus de grammaires que cette dernière, comme on le verra au paragraphe suivant. 7.19
Construction des tables pour les méthodes LR(1)
Il y a trois méthodes de construction des tables d’analyse LR(1), l’algorithme d’analyse étant toujours le même : •
la méthode SLR(1) est celle présentée dans les paragraphes précédents. Elle conduit à des tables d’une taille “raisonnable“, mais elle est un peu limitée dans les grammaires qu’elle peut traiter ;
•
la méthode LR(1), dite canonique, est la plus puissante. Elle conduit à des tables nettement plus grosses que la méthode SLR(1), mais elle permet de traiter beaucoup plus de grammaires ;
•
la méthode LALR(1) est intermédiaire : elle permet de traiter moins de grammaires que la méthode LR(1), mais plus de grammaires que la méthode SLR(1). Le nombre d’états d’analyse, donc la taille des tables, est la même que dans cette dernière.
Notons pour mémoire que si la méthode LR(1) est la plus générale des méthodes ascendantes où l’on fait l’analyse de gauche à droite, il existe des grammaires non ambiguës qui ne sont pas LR(1). Dans la méthode LR(1) canonique, on gère une information supplémentaire dans les états d’analyse (LR(1) items). L’information en question est un terminal qui ne joue un rôle que pour les états où un “réduire“ est possible : la réduction n’est faite que si le terminal à disposition est précisément celui qui figure dans cette information complémentaire. Cela permet de se rendre compte dans l’exemple du paragraphe précédent qu’on ne doit pas faire la réduction de partie_gauche en partie_droite sur le =, et donc d’éviter le conflit “réduire/réduire“. En revanche, la différentiation d’un état d’analyse simple (LR(0) item) en plusieurs états d’analyse plus riches (LR(1) item), dont l’information complémentaire est différente, conduit à l’augmentation du nombre d’états, et donc de la taille des tables d’analyse, mentionnée plus haut. Pour limiter le nombre d’états de l’analyseur, la méthode LALR(1) refusionne les états de la méthode LR(1) ayant la même partie LR(0) en faisant leur union. Cela peut conduire à des conflits “réduire/réduire“, mais pas à de nouveaux conflits “consommer/réduire“. On fournira peut-être un message d’erreur plus tard en termes de réductions, mais sans avoir consommé de terminal entre temps. C’est cette possibilité d’ajout de conflits “réduire/réduire“ par rapport à la méthode LR(1) canonique qui fait que l’on peut traiter moins de grammaires par
Analyse syntaxique 151
la méthode LALR(1). Là encore, le lecteur intéressé aux détails est renvoyé à [Aho, Sethi & Ullman 88]. Outils de création des tables LR
Yacc est l’acronyme de “Yet Another Compiler Compiler“ (voici encore un autre synthétiseur de compilateurs). C’est l’outil devenu classique pour analyser une grammaire LALR(1) et construire les tables action et goto. Il en existe de nombreuses variantes sur différentes machines. Nous présentons cet outil en détail au chapitre 9. Parmi les versions très répandues, citons byacc (Berkekey Yacc) et Bison (Yet Another Yacc), dont le code source est accessible. Les algorithmes utilisés pour compacter les tables sont les mêmes dans ces deux outils et sont les plus performants connus à ce jour. Ils sont décrits dans [DeRehmer & Pennello 82]. Benoît Garbinato a créé Trison à partir de Bison. L’idée a été de synthétiser comme code objet une sous-classe d’une classe C++ placée en librairie. Le code source de Bison, écrit en C à l’origine, a été ré-écrit et surtout restructuré en C++ au passage. Le lecteur intéressé peut se référer à [Garbinato 93]. 7.20
Algorithme d’analyse LR(1)
Nous avons dit que l’algorithme d’analyse LR(1) est le même, quelle que soit la méthode employée pour construire les tables action et goto. Voici donc cet algorithme décrit en pseudo-code : Boolean AnalyseurLR :: Analyser () { on empile l’état initial 0 on lit un premier terminal boucle infinie { actionCourante = action [sommet de la pile, terminal courant]; selon actionCourante -> fGenreAction cas de consommer: on empile l’état actionCourante -> fEtatSuivant on consomme le terminal courant et on avance au suivant cas de réduire: productionCourante = productions [actionCourante -> fProduction - 1]; on réduit le corps de production sur la pile en désempilant un nombre de symboles égal à productionCourante -> fNombreDUnitesAReduire on empile l’état goto [sommet de la pile, productionCourante -> fNonTerminal ] cas de accepter: on accepte l'expression en retournant vrai
152 Compilateurs avec C++
case de erreur: on accepte l'expression en retournant faux sinon la table d’analyse est mal formée } selon } boucle infinie } // AnalyseurLR :: Analyser
Cet algorithme, qui est le même pour toutes les méthodes LR, est montré à l’œuvre au paragraphe suivant. 7.21
Exemples d’analyse par la méthode LR
Voici un exemple d’emploi de l’analyseur LR pour les expression pour lesquelles nous avons construit les tables d’analyse SLR(1) dans les paragraphes précédents. Rappelons que les productions sont : (1) expression ⇒ expression "+" terme (2) expression ⇒ terme (3) terme ⇒ terme "*" facteur (4) terme ⇒ facteur (5) facteur ⇒ "(" expression ")" (6) facteur ⇒ ident
La trace d’exécution sur la phrase : IDENT * IDENT + IDENT FIN
est : Etat de départ: 0 IDENT --> On consomme le terminal IDENT Nouvel état: 5 * --> On réduit 1 symbole(s) avec: Facteur -> IDENT Nouvel état: 3 --> On réduit 1 symbole(s) avec: Terme -> Facteur Nouvel état: 2 --> On consomme le terminal * Nouvel état: 7 IDENT --> On consomme le terminal IDENT Nouvel état: 5 + --> On réduit 1 symbole(s) avec: Facteur -> IDENT Nouvel état: 10 --> On réduit 3 symbole(s) avec: Terme -> Terme '*' Facteur Nouvel état: 2
Analyse syntaxique 153
--> On réduit 1 symbole(s) avec: Expression -> Terme Nouvel état: 1 --> On consomme le terminal + Nouvel état: 6 IDENT --> On consomme le terminal IDENT Nouvel état: 5 FIN --> On réduit 1 symbole(s) avec: Facteur -> IDENT Nouvel état: 3 --> On réduit 1 symbole(s) avec: Terme -> Facteur Nouvel état: 9 --> On réduit 3 symbole(s) avec: Expression -> Expression '+' Terme Nouvel état: 1 --> On accepte l'expression à analyser *** Ok, expression correcte ***
L’arbre de dérivation correspondant est produit dans l’ordre indiqué par la figure 7.3. Il suffit, dans cette figure, de parcourir les nœuds dans l’ordre inverse de leur création pour retrouver le fait que la méthode LR produit l’arbre de dérivation en dérivant en premier le non terminal le plus à droite de chaque production, mais à l’envers, soit en partant des feuilles vers la racine. expression 8
8
8
expression 5 terme 4
4
4
terme
terme
2
7
facteur 1 IDENT
*
facteur
facteur
3
6
IDENT
+
IDENT
7.3Ordre d’obtention d’un arbre de dérivation LR
154 Compilateurs avec C++
Lors de l’analyse d’une phrase erronée, on obtient par exemple : Etat de départ: 0 IDENT --> On empile le terminal IDENT Nouvel état: 5 * --> On réduit 1 symbole(s) avec: Facteur -> IDENT Nouvel état: 3 --> On réduit 1 symbole(s) avec: Terme -> Facteur Nouvel état: 2 --> On empile le terminal * Nouvel état: 7 ( --> On empile le terminal ( Nouvel état: 4 + ### Erreur dans expression ### Etat courant: 4 leTerminal = + 7.22
Comparaison entre grammaires LL(1) et LR(1)
La classe des grammaires LR(1) est plus large que celle des grammaires LL(1). On démontre formellement que toute grammaire LL(1) est LR(1), mais que l’inverse n’est pas vrai. Sans entrer dans les détails de la démonstration, on peut en pressentir la raison de la manière suivante : •
pour qu’une grammaire soit LL(1), on doit pouvoir déterminer l’action à prendre dans un état donné au vu du seul prochain terminal non encore consommé ;
•
dans le cas LR(1), en revanche, on gère implicitement en plus, dans chaque état, quels sont les états intermédiaires par lesquels on est passé depuis l’état initial pour arriver à l’état considéré. Cette particularité découle de la manière dont sont déterminés les états d’analyse, comme illustré au paragraphe 7.15.
C’est le fait que l’on prend en compte l’ensemble de toutes les productions de la grammaire dans la méthode LR qui fait que l’on dispose dans ce cas d’une information plus riche pour décider de la conduite à tenir lors de l’analyse.
Analyse syntaxique 155
C’est d’ailleurs pour la même raison que les grammaires SLR(1) constituent une classe plus restreinte que les grammaires LALR(1) et LR(1), comme cela a été mentionné au paragraphe 7.18.
Plus concrètement, une grammaire récursive à gauche peut très bien être LR(1), comme celle des expressions arithmétiques du paragraphe 7.14. Néanmoins, elle ne peut être LL(1), comme on l’a vu au paragraphe 7.7. Il existe donc des grammaires LR(1) qui ne sont pas LL(1). D’un autre point de vue, la construction des tables d’analyse LR(1) pour une grammaire qui est LL(1) ne peut pas engendrer de conflits “réduire/réduire : si une réduction est possible dans un état donné, c’est nécessairement celle qui utilise la production sur laquelle est calquée la fonction d’analyse dans laquelle on se trouve à ce moment là. Il n’y a donc pas de choix possible entre plusieurs réductions. De manière analogue, il ne peut pas y avoir de conflit “consommer/réduire“ si la grammaire est LL(1) : par définition même, le prochain terminal non encore consommé dicte la conduite à tenir, sans qu’il y ait de question à se poser, sans quoi la grammaire ne serait pas LL(1). 7.23
Récursion à droite dans les méthodes LR(1)
Nous avons rappelé au paragraphe précédent que la méthode LR (1)accepte la récursion à gauche dans une grammaire qu’on lui soumet, tandis qu’une telle récursion à gauche fait qu’une telle grammaire n’est pas LL(1). Dans les méthodes d’analyse LR(1), il faut préférer la récursion à gauche et éviter la récursion à droite. Cette dernière fait que la hauteur de la pile des états LR est linéaire en fonction de la longueur du code source à analyser ! Cela est dû à ce que les réductions par les productions récursives à droite ne peuvent se faire que lorsque toute la notion correspondante, au plus haut niveau, a été acceptée. En pratique, comme les productions récursives sont beaucoup utilisées, on peut arriver à déborder la pile de l’analyseur LR.
Pour illustrer ce retard de désempilement dans la récursion à droite, voici une autre grammaire des expressions arithmétiques, avec ses tables d’analyse produites par l’outil Yacc : Productions: 1 2
EXPRESSION EXPRESSION
3 1
Expression -> Terme '+' Expression Expression -> Terme
3 4
TERME TERME
3 1
Terme -> Facteur '*' Terme Terme -> Facteur
5 6
FACTEUR FACTEUR
3 1
Facteur -> '(' Expression ')' Facteur -> IDENT
156 Compilateurs avec C++
ACTION: Etat
IDENT
+
*
(
)
FIN
0 1 2
S 4 S 4 S 4
3 4 5
S 4 R 6
R 6
R 6
S 1 R 6
R 6
R 6 ACC
6 7 8
R 2 R 4
S 2 R 4
R 2 S 3
R 2 R 4
R 2 R 4 S 9
R 2 R 4
9 10 11
R 5 R 1 R 3
R 5 R 1 R 3
R 5 R 1 R 3
R 5 R 1 R 3
R 5 R 1 R 3
R 5 R 1 R 3
S 1 S 1 S 1
GOTO: EXPRESSION TERME FACTEUR 0 5 6 7 1 8 6 7 2 10 6 7 3 11 7 4 … … … … … … … … … … … … … … … … … … … 11
La trace d’exécution de l’analyse de la phrase composée des terminaux : IDENT FOIS IDENT FOIS IDENT FOIS IDENT FOIS IDENT FOIS IDENT FIN
contient : Etat de départ: 0 IDENT *** Pile des états *** 1: 0 *** -------------- *** --> On consomme le terminal IDENT Nouvel état: 4 * *** Pile des états *** 2: 4 1: 0 *** -------------- *** --> On réduit 1 symbole(s) avec: Facteur -> IDENT Nouvel état: 7 *** Pile des états *** 2: 7 1: 0 *** -------------- ***
Analyse syntaxique 157
--> On consomme le terminal * Nouvel état: 3 IDENT … … … … … … * … … … … … … --> On consomme le terminal * Nouvel état: 3 IDENT *** Pile des états *** 5: 3 4: 7 3: 3 2: 7 1: 0 *** -------------- *** --> On consomme le terminal IDENT Nouvel état: 4 * … … … … … … IDENT FIN *** Pile des états *** 12: 4 11: 3 10: 7 9: 3 8: 7 7: 3 6: 7 5: 3 4: 7 3: 3 2: 7 1: 0 *** -------------- *** … … … … … … FIN --> On accepte l'expression à analyser *** Ok, expression correcte ***
On voit que le 5e élément de la pile, créé lors de l’acceptation du 3e IDENT, reste empilé jusqu’à ce que le terminal FIN provoque la cascade de réductions finale. La taille maximale de la pile atteint 12 sur cet exemple, qui contient… 12 terminaux y
158 Compilateurs avec C++
compris FIN. A titre de comparaison, l’analyse de cette même phrase avec la grammaire récursive à gauche du paragraphe 7.14, se fait avec une hauteur maximale de la pile des états LR de 4. Nous laissons au lecteur le soin de construire l’arbre de dérivation dans ce cas pour se rendre compte du point important suivant.
La récursion à gauche dans la méthode LR est le dual de l’optimisation des appels terminaux dans une méthode descendante. 7.24
Exercices
7.1 : Extension de la méthode de priorités des opérateurs (moyen). Modification l’algorithme d’analyse d’expressions par la méthode de priorités des opérateurs, présentée au paragraphe 7.12, pour gérer les opérateurs postfixés et les opérateurs associatifs à droite.
7.2 : Analyse lexico-syntaxique de Lisp (projet). Lisp est un langage dont la syntaxe est très simple. Une sexpr ou “symbolic expression“ (expression symbolique) est constituée soit d’un entier, soit d’une chaîne au sens de C, soit d’un atome, soit d’une séquence de sexpr entre parenthèses. Dans ce cas, les éléments entre parenthèses sont séparés par des espaces, des tabulateurs ou des fins de ligne. Eventuellement, les deux derniers éléments entre les parenthèses peuvent être séparés par un point, comme : (jean 19 “335” . marc) Les commentaires sont introduits par un “;“ et se terminent avec la fin de la ligne. Les atomes sont des séquences de caractères un peu analogues aux identificateurs en Pascal ou en C++, mais avec des règles d’écriture beaucoup souples. Ainsi j-aime_le-pain est un atome, de même que l’apostrophe ' et #'.
Ecrire une grammaire lexico-syntaxique pour Lisp, dans laquel les deux niveaux lexical et syntaxiques sont fusionnés, puis un analyseur sur ce modèle. Dans ce cas, on ne décrit pas les terminaux par une énumération, mais on travaille directement au niveau des caractères lors de l’analyse syntaxique. Quelle pourrait être la forme des graphes sémantiques décrivant les sexpr acceptées ?
On pourra tester l’analyseur ainsi écrit sur l’exemple suivant : (defun carre (n) (* n n) )
; un exemple classique
(defun mapcarr (funct args) (if (null args) nil (cons (funcall funct (car args)) (mapcarr funct (cdr args))
Analyse syntaxique 159
) ) ) (mapcarr 'carre '(1 3 4 5 9)) (listp '(a b c d . marc))
7.3 : Analyse d’une grammaire (projet). Etant donné une grammaire écrite dans le formalisme utilisé dans ce chapitre ou dans un autre formalisme à définir, écrire un analyseur permettant de construire en mémoire vive le graphe représentant cette grammaire. Les feuilles du graphe sont les terminaux du formalisme, tandis que les autres nœuds sont les notions non terminales.
Comme suite à cet exercice, on pourra utiliser le graphe ainsi construit pour différents buts, comme : •
synthétiser du code PostScript dessinant les diagrammes syntaxiques de la grammaire considérée ;
•
déterminer si elle satisfait aux critères pour être LL(1) ou LR(1).
160 Compilateurs avec C++
Chapitre
8
8
Analyse sémantique
Les aspects lexicaux et syntaxiques d’un langage ne sont qu’un support pour l’essentiel, à savoir la sémantique véhiculée par les phrases du langage. Par exemple, le fragment de code C++ : int
i = 2.567;
est correct lexicalement et syntaxiquement, mais il contient une erreur sémantique. On ne peut en effet par affecter une valeur flottante à une variable entière dans ce langage. L’analyse sémantique effectue les vérifications de sémantique, c’est-àdire de signification, sur le code source en cours de compilation. Elle se base pour cela sur la définition du langage à compiler, qui précise quelles phrases bien formées syntaxiquement ont un sens. Elle s’appuie sur des structures de données représentant le source en cours de compilation. On notera qu’il n’est pas strictement nécessaire de s’appuyer sur une forme source textuelle : il est tout à fait possible de faire l’analyse sémantique d’informations stockées dans des structures de données, sans devoir passer par analyse lexicale et analyse syntaxique d’un texte.
162 Compilateurs avec C++
Dans ce chapitre, nous présentons d’abord des exemples typiques de problèmes relevant de l’analyse sémantique, puis les principes qui sous-tendent cette analyse. Nous illustrons ce chapitre par l’implantation du langage Formula. L’analyse sémantique s’appuie sur des descriptions fines des informations glanées ici et là lors de l’analyse du code source à compiler. Avant d’entreprendre l’analyse sémantique d’un langage, il faut bien sûr que sa sémantique soit définie. Il peut être extrêmement délicat de bien préciser toute la sémantique d’un langage d’une certaine complexité. Il faut pour cela définir la signification de toutes les constructions du langage, et préciser lesquelles présentent des cas particuliers. Un exemple de tel cas particulier en Pascal figure au paragraphe suivant. La sémantique de Formula est présentée au paragraphe 8.5. Relevons d’emblée que nous parlerons simplement de“ fonction“ pour désigner une fonction définie dans le source en cours de compilation, les autres étant explicitement qualifiées de “prédéfinies“. Nous parlerons par défaut des fonctions dans ce chapitre, le cas des procédures étant mentionné lorsque cela est nécessaire. 8.1
Identité de types
Soit l’exemple Pascal suivant, illustrant un cas de types mutuellement récursifs : type element = …; noeud = record val sous_arbre_gauche, sous_arbre_droit end;
: element; (* la valeur stockée *) : ^ noeud
arbre = ^ noeud; var pommier: arbre;
La difficulté ici est qu’il y a deux emplois, textuellement, du type ^ noeud. Ces deux occurrences dénotent-elles le même type ? La notion d’identité de types est un point fondamental de la sémantique des langages, et peut être interprétée de différentes manières. On trouve deux écoles : •
l’une déclare que deux types sont identiques s’il résultent de la même déclaration textuellement ;
•
l’autre les considère identiques s’ils ont la même structure, indépendamment des déclarations et des identificateurs employés.
Analyse sémantique 163
Si l’on adopte la première alternative, l’exemple ci-dessus fait que arbre et le type de sous_arbre_gauche et sous_arbre_droit sont deux types distincts, ce qui conduira à des problèmes sémantiques si l’on tente d’affecter une valeur de l’un à une variable de l’autre. Ainsi : pommier := pommier^.sous_arbre_gauche
serait sémantiquement erroné dans ce cas. Beaucoup de compilateurs Pascal adoptent cette première alternative parce qu’elle conduit à une implantation plus simple. La seconde alternative, quant à elle, oblige le compilateur à faire une mise en correspondance (pattern matching) pour déterminer si deux types sont identiques. Cette détermination se fait de manière récursive sur la description des types dans le compilateur, comme d’ailleurs on doit le faire à la main si on veut faire ce contrôle soi-même. Ceci permettrait même par exemple de ré-écrire l’exemple ci-dessus sous la forme : type element = …; arbre_1 = ^ noeud; noeud = record val sous_arbre_gauche, sous_arbre_droit end;
: element; (* la valeur stockée *) : arbre_1
arbre_2 = ^ noeud; arbre_3 = arbre_2; (* déclaration d'identité de types *) var pommier: arbre_3; begin (* …*) pommier := pommier^.sous_arbre_gauche end
On voit ici que la déclaration d’un type Pascal par : un_type = un_autre_type
est de fait une déclaration d’identité de types : un_type et un_autre_type ne sont que deux identificateurs pour une même type. De manière plus générale, on appelle alias des noms désignant une même chose. Cette dernière formulation de la spécification d’un arbre n’est correcte que si l’on adopte la seconde alternative, alors que la première est correcte dans les deux alternatives, c’est-à-dire que deux type identiques de par leur déclaration textuelle le sont aussi par leur structure. Ce problème d’identité de type est en fait à prendre en
164 Compilateurs avec C++
compte lors de la conception d’un langage fortement typé. Cela n’avait pas été clairement précisé dans la définition initiale du langage Pascal. On trouve au paragraphe 8.27 un exemple de fonction déterminant si deux types sont identiques. 8.2
Collecte d’informations sémantiques
Il est fréquent et utile en informatique d’utiliser des noms symboliques pour décrire des informations et la façon de les manipuler. De plus, les langages de programmation modernes permettent de définir des concepts comme des types de données et des constantes. Il sera donc nécessaire de décrire de manière interne au compilateur ce que nous appellerons des identificateurs, soit des noms symboliques, et les types éventuellement associés. Un compilateur se construit une image de la sémantique du code source qu’il traite en s’appuyant sur des structures de données, décrites dans le langage d’implantation dans lequel il est lui-même écrit. Par exemple, pour compiler le programme Pascal : program semantique; const coefficient = 3.141592; type classe_d_age = (enfant, femme, homme); var i la_classe
: integer; : classe_d_age;
procedure afficher (a_l_ecran: boolean); begin (* … *) end; begin (* semantique *) write ('Veuillez fournir un entier: '); readln (i); writeln ('Le carré de ', i, ' est ', i * i); afficher (coefficient * i > 10) end. (* semantique *)
nous utiliserons : •
une description des types utilisés, comme booléen, entier, réel et chaîne_de_caractères qui sont prédéfinis en Pascal, mais aussi classe_d_age qui est défini dans le code source compilé ;
Analyse sémantique 165
•
une table des identificateurs, dans laquelle seront stockées les descriptions des identificateurs coefficient, classe_d_age, enfant et afficher , mais aussi integer, write, readln et writeln, qui sont prédéfinis en Pascal ;
•
une description des actions sémantiques comme “écrire une chaîne“, “lire un entier“, “écrire un entier“ et “multiplier deux entiers“. Ces opérations sémantiques ne sont pas toujours explicites dans le code source, comme on le verra au paragraphe suivant.
Les structures de données représentant les informations ci-dessus seront décrites et gérées en C++ si tel est le langage dans lequel nous écrivons un compilateur Pascal. Un autocompilateur Pascal décrit ces informations en Pascal lui-même. 8.3
Forme syntaxique et sémantique associée
L’exemple du paragraphe précédent illustre le fait que des formes syntaxiques similaires peuvent véhiculer des sémantiques très différentes. Les deux fragments Pascal writeln (35) et writeln ('Veuillez fournir un entier: ') sont des instruction correspondent à la syntaxe : instruction ⇒ "writeln" "(" expression ")"
mais le type de l’expression est différent dans les deux cas. Il s’ensuit que le code synthétisé par le compilateur sera très différent, puisqu’on ne s’y prend pas de la même manière pour écrire un entier ou une chaîne de caractères. De plus, chacun des deux fragments ci-dessus est constitué de deux instructions successives car l’instruction writeln (l_argument) équivaut en Pascal à “write (l_argument); writeln“. Comme autre exemple, l’opérateur Pascal * dans les deux expressions 3 * 5 et 3.0 * 5.0 dénote une fois la multiplication de deux entiers et l’autre fois celle de deux réels. Une conversion implicite est une conversion de type non explicitée syntaxiquement par le programmeur. Ainsi, dans le cas toujours en Pascal de 3.0 * 5 il y a conversion de l’entier 5 en le réel 5.0, puis multiplication de deux réels. Cela est mis en évidence dans le graphe sémantique de la figure 8.1. Bien entendu, un compilateur Pascal doit prendre en compte toutes les finesses ci-dessus pour remplir son rôle. Nous allons voir dans ce chapitre comment on peut s’y prendre pour gérer les différentes descriptions sémantiques nécessaires à la vérification sémantique d’un code source.
166 Compilateurs avec C++
multiplication_réelle
3.0
conversion_entier_réel
5
8.1Graphe sémantique avec conversion implicite 8.4
Limite entre syntaxe et sémantique
Comme nous le montrons dans le chapitre 9 au moyen de Yacc, on peut réaliser jusqu’à un certain point l’incorporation de la sémantique dans la syntaxe. L’approche moderne est basée sur le principe que la sémantique est un ensemble de contraintes sur la syntaxe. Comme illustration de ce principe, considérons le fragment C++, en admettant que UneClasse a été déclaré comme type struct ou class : UneClasse UneInstance ( liste_d_expressions_separees_par_des_virgules );
Ce fragment bien formé syntaxiquement n’est sémantiquement bien formé que si UneClasse possède un constructeur pouvant accepter comme arguments d’appel les expressions entres parenthèses. Cette acceptabilité est elle-même liée aux types et au mode de passage des paramètres formels, au fait que certains peuvent être optionnels (avec l’emploi de“…“ dans l’en-tête de la fonction), et enfin à d’éventuelles conversions implicites. Les règles employées par C++ dans ce contexte sont décrites de manière très complète dans la définition du langage. Il serait manifestement fastidieux d’écrire des règles de grammaires distinctes permettant de décrire en détail tous les cas comme celui ci-dessus, pour autant que l’on puisse y parvenir.
Une pratique courante est d’accepter syntaxiquement un sur-langage de celui que l’on veut compiler, quitte à restreindre ensuite ce que l’on peut accepter par des contrôles sémantiques. Rappelons que l’analyseur Markovski du paragraphe 3.9, est basé sur l’analyse lexico-syntaxique Prolog effectuée par le prédicat prédéfini read, Prolog étant un sur-langage de Markovski en ce qui concerne la syntaxe. La sémantique est bien entendu spécifique à Markovski, sans quoi nous n’aurions pas eu besoin d’écrire un analyseur pour ce langage : celui de Prolog aurait suffi !
Analyse sémantique 167
Comme autre exemple de l’idée de sur-langage, les différentes grammaires Formula que nous avons exhibées acceptent les listes d’arguments des appels de fonctions sans vérifier le nombre de ces arguments. Or toute fonction Formula a un nombre de paramètres formels donné, et il faut que le nombre d’arguments dans les appels soit égal au nombre de ces paramètres. L’analyse sémantique de Formula est donc chargée de vérifier cette correspondance entre le nombre des paramètres formels et le nombre les arguments d’appel des fonctions, comme on le montre au paragraphe 8.22. C’est d’ailleurs ce qui fait la différence entre l’analyseur sémantique présenté dans le présent chapitre et l’analyseur syntaxique du paragraphe 7.9. 8.5
Sémantique de Formula
Nous ne donnons pas une définition formelle de la sémantique de Formula, étant donné que cela prendrait un certain nombre de pages. C’est donc essentiellement l’implantation de ce langage qui définit sa sémantique, comme aux premiers temps de l’informatique ! Formula permet de définir et d’évaluer des fonctions dites "utilisateur" acceptant des paramètres numériques et/ou logiques et retournant des valeurs numériques, logiques ou pas de valeur du tout. Cette absence de valeur retournée est représentée par la constante prédéfinie Vide en Formula, de manière analogue au type void en C++. Cette petite extension par rapport aux fonctions mathématiques permet de définir et de manipuler des procédures au sens usuel. Ainsi, dans le programme Formula : fact (n) = Si ( InfEgale (n, 0), 1, n * fact (n - 1) ); ptt (n) = Si ( Sup (n, 0), Seq ( ptt (n - 1), EcrireNombre (n), EcrireNombre (fact (n)), EcrireFinDeLigne () ), Vide ); ?
ptt (10);
168 Compilateurs avec C++
fact est une fonction d’un nombre retournant un nombre, tandis que ptt est une procédure. Le résultat de l’analyse sémantique illustre cela par la production des messages informatifs suivants : La fonction utilisateur 'fact' du type '(Nombre) -> Nombre' La fonction utilisateur 'ptt' du type '(Nombre) -> Vide'
Lorsqu’on exécute ce programme, on obtient comme résultat : Execution... 1.000000 2.000000 3.000000 4.000000 5.000000 6.000000 7.000000 8.000000 9.000000 10.000000 ...Fin
1.000000 2.000000 6.000000 24.000000 120.000000 720.000000 5040.000000 40320.000000 362880.000000 3628800.000000
Les fonctions prédéfinies Seq et Seq1 permettent le séquencement d’expressions, comme les fonctions progn et prog1 en Lisp ou le point virgule ’;’ dans les langages descendant d’Algol 60. Formula est un langage d’expressions, soit un langage dans lequel toute expression produit une valeur, fut-elle vide. Un certain nombre de fonctions liées au contrôle sont prédéfinies en Formula, comme Si, Sup et InfEgale, pour ne pas rendre son analyse syntaxique trop complexe.
Les passages de paramètre en Formula peuvent se faire par valeur, par nom ou par besoin. Pour le détail de la signification de ces modes de passage, le lecteur est renvoyé au chapitre 10 qui les traite en détail. Les paramètres sont tous passés de la même manière aux fonctions utilisateur, pour ne pas alourdir la syntaxe du langage par des spécifications de mode de passage.Le choix du mode de passage est fait au lancement du compilateur.
Les fonctions prédéfinies Formula reçoivent en général leurs paramètres par valeur. Seules Si et les fonctions d’itération Somme, Produit et Pour reçoivent certains d’entre eux par nom. Dans les fonctions d’itération comme Somme : •
le premier argument déclare l’indice de l’itération ;
Analyse sémantique 169
•
les deux suivants, passés par valeur, indiquent l’intervalle parcouru par l’indice par pas de 1.0 ;
•
le dernier, passé par nom, est évalué successivement pour chaque valeur de l’indice.
Cela est illustré par l’exemple suivant : ?
Somme (i, 1, 5, i * i);
?
Somme ( i, 1, 5, Somme ( j, i, i * i, 1 / (i + j) ) );
et les résultats produits après compilation et exécution par la machine Pilum : Valeur: 55.000000 ================= Valeur: 4.107445 =================
Les identificateurs d’indice d’itération en Formula posent le problème du point à partir duquel un identificateur est utilisable. Cette question est traitée au paragraphe 8.13. Les évaluations Formula, introduites par le terminal “?“, sont des fonctions anonymes sans paramètres appelées implicitement sur le point de leur déclaration. Ainsi, l’évaluation : ? Sin (Pi + LireNombre ());
est sémantiquement équivalente à : anonyme = Sin (Pi + LireNombre ()); ? anonyme;
Mentionnons encore que les fonctions sans paramètres prédéfinies doivent être appelées avec une paire de parenthèses, comme LireNombre (), alors que celles qui sont définies dans le code source Formula doivent être appelées sans parenthèses, comme le montre l’exemple suivant : pi_carre = Pi * Pi; ? pi_carre;
La raison de ce choix est qu’une fonction comme pi_carre rappelle plutôt une déclaration de constante, alors qu’une fonction prédéfinie sans paramètres comme Hasard () a des effets de bords (side effects) a priori.
170 Compilateurs avec C++
8.6
Inférence de type en Formula
Avant de décrire comment est effectuée l’analyse sémantique de Formula il nous faut préciser ce qu’est l’inférence de type, qui la conditionne. La logique est l’étude du raisonnement correct, et dans ce contexte, “inférer“ signifie “déduire“, d’où la définition suivante. L’inférence de type consiste à déterminer automatiquement les types des différents identificateurs apparaissant dans un programme source d’après l’emploi qui en est fait. L’intérêt pour le programmeur est de ne pas devoir déclarer ces identificateurs. C’est grâce à cette facilité que l’écriture en Formula est si dépouillée, bien que l’on puisse manipuler des valeurs numériques et des valeurs logiques. Nous utilisons Vide, Nombre et Booléen pour désigner ces types, qui ne s’écrivent pas en Formula. Rappelons que Vide décrit les procédures, soit les fonctions qui ne retournent pas de valeur. Nous désignons de plus par Inconnu un type non encore inféré.
Avant de commencer l’inférence, les types de tous les identificateurs aparaissant dans le texte source est Inconnu. Le mécanisme d’inférence va progressivement contraindre ces types en les identifiant comme étant l’un des types Vide, Nombre ou Booléen. Le résultat de l’inférence de type est indépendant de l’ordre dans lequel les activités d’inférence élémentaires sont effectuées. L’analyse sémantique du code source Formula : carre (n) = n* n; nand (p, q) = Non (Et (p, q)); ? Si (Inf (3, 4), 5, 9 + 8); ? nand (Vrai, Faux);
fournit comme trace à la compilation : --> Définition: La fonction utilisateur 'carre' du type '(Nombre) -> Nombre' --> Définition: La fonction utilisateur 'nand' du type '(Booléen, Booléen) -> Booléen' --> Evaluation: expression -> Nombre --> Evaluation: expression -> Booléen
Le compilateur a donc inféré les types des fonctions et de leurs arguments, ainsi que ceux des évaluations. Dans le cas de la fonction : carre (n) = n * n;
Analyse sémantique 171
cela s’est fait concrètement de la manière suivante : •
en-tête de la fonction : le type de carre est Inconnu ; le type de n est Inconnu ;
•
opérande gauche de * : cet opérande doit être un nombre d’après la sémantique de Formula. On fait l’identification du type de n à Nombre ;
•
opérande droit de * : cet opérande doit aussi être un nombre. On fait le test que le type de n est Nombre : succès ;
•
le type du résultat de l’opérateur * est Nombre par définition ;
•
le type du corps de carre est donc Nombre ;
•
on conclut par l’identification du type de la fonction carre à Nombre.
La première tentative de contrainte d’un type encore Inconnu à un des type connu détermine le type considéré par identification. Les tentatives ultérieures ne sont que des tests de conformité d’un emploi d’identificateur au type déjà inféré. On voit sur l’exemple ci-dessus le “gel“ progressif des types des différents identificateurs. C’est dans cet ordre que l’analyseur sémantique Formula présenté dans ce chapitre fait les choses, mais nous aurions pu commencer par l’opérande droite de l’opérateur *, par exemple.
La sémantique de Formula impose que les deux alternatives d’un Si soient du même type. Cette contrainte est donc vérifiée par l’analyseur sémantique Formula. Voici un exemple où cette contrainte n’est pas respectée : ? Si (Inf (3, 4), Vrai, 9 + 8);
et le résultat de son analyse sémantique : ### Erreur sémantique: les deux alternatives d'un 'Si' ne retournent pas des valeurs du même type (ici, 'Booléen' et 'Nombre') fTerminal = ' )' ### --> Evaluation: expression -> -- Type Inconnu --
Cet exemple illustre ce qu’on appelle parfois la politesse d’un compilateur. Il sait ce qu’il devrait trouver, mais ne le trouve pas : plutôt que de fournir un vague
172 Compilateurs avec C++
message du genre “erreur sémantique“, autant qu’il dise très précisément ce qu’il attendait, puisqu’il le sait. Cela a d’ailleurs aussi un côté formateur pour l’utilisateur du langage. Variables logiques
L’algorithme d’inférence de type mis en place pour Formula s’appuie sur la notion de variable logique que l’on rencontre entre autres en Prolog. En logique une variable est un nom pour quelque chose. Elle est initialement libre, ce qui signifie que sa valeur est inconnue. Elle peut devenir liée par une tentative d’unification, qui correspond à ce que nous avons appelé “identification“ ci-dessus. Le test de type en Formula consiste à tenter d’unifier la variable logique décrivant le type de l’opérande considéré avec celui des types “concrets“ Vide, Nombre et Booléen qui est attendu. Unifier signifie“ rendre identique“. Cette tentative d’unification peut : •
réussir si la variable logique de type typeLogiqueTrouve était libre, auquel cas on infère au passage que l’opérande considéré a précisément le type typeAttendu ;
•
réussir si la variable logique de type typeLogiqueTrouve était déjà liée à la valeur typeAttendu, auquel cas l’emploi de l’opérande considéré est conforme à son type déjà inféré ;
•
échouer si la variable logique de type typeLogiqueTrouve était déjà liée à une autre valeur que typeAttendu, auquel cas l’emploi de l’opérande considéré donne lieu à une erreur sémantique. Pour ne pas produire de messages superflus, nous ne produisons effectivement ce message que si le type trouvé est différent de gTypeInconnu.
La première tentative d’unification d’une variable logique de type avec un des type “vrais“ détermine le type de l’opérande considéré. Les tentatives ultérieures ne sont que des tests de conformité sémantique. Réalisation de l’inférence de type pour Formula
Deux classes permettent de gérer un type encore libre dans l’analyse sémantique de Formula. La première est TypeLogLIBRE, qui indique le type “non encore connu“ : class TypeLogLIBRE : public Type { public: TypeLogLIBRE (); }; extern const TypePtr
gTypeLogLIBRE;
Analyse sémantique 173
Une valeur de ce type est utilisée pour décrire initialement les fonctions déclarées par l’utilisateur et leurs paramètres. L’analyse sémantique doit déterminer ces types, donc lier les variables logiques de types aux valeurs que sont le type nombre et le type booléen. L’implantation de cette classe est formée de : TypeLogLIBRE :: TypeLogLIBRE () : Type (kTypeLogLIBRE, "-- TYPE LIBRE --") {} static const TypePtr
gTypeLogLIBRE = new TypeLogLIBRE;
La seconde classe employée pour gérer l’inférence de type est le type VarLogType lui même, soit une variable logique pouvant prendre un type comme valeur. Elle s’appuie sur : •
le champ fLiaisonAutreVariable, pointeur sur une instance de VarLogType. Une valeur NULL pour ce champ indique la fin de la chaîne des liaisons des variables entre elles ;
•
le champ fValeurType, pointeur sur une instance de Type ou de l’une de des descendantes, contient la valeur de liaison de la variable logique en bout de chaîne.
Citons encore les quatre variables logiques de type globales suivantes, chacune étant l’instance unique de son type. Elles sont liées au début de la compilation : static const VarLogTypePtr
gTypeLogInconnu = new VarLogType (gTypeInconnu);
static const VarLogTypePtr
gTypeLogNonPrecise = new VarLogType (gTypeNonPrecise);
static const VarLogTypePtr
gTypeLogNombre = new VarLogType (gTypeNombre);
static const VarLogTypePtr
gTypeLogBooleen = new VarLogType (gTypeBooleen);
L’analyse sémantique de Formula utilise des variables logiques de type pour décrire les identificateurs, leur valeur de liaison étant un TypePtr autre que gTypeLogLIBRE dès qu’ils sont liés. On trouve en appendice, au paragraphe A.4.1 des extraits de l’implantation des variables logiques de type. L’algorithme d’inférence de type utilisé pour SML, décrit dans [Milner 78], est plus complexe à cause de la richesse de ce dernier langage en matière de types et du fait de ses possibilités de traitement de listes.
174 Compilateurs avec C++
8.7
Description des types
On représente les types du langage que l’on compile par des structures de données dans le langage d’implantation. Ces structures de données décrivant les types du langage que l’on compile s’appuient donc sur des types dans le langage d’implantation. Cette circularité apparente n’est réelle que dans le cas d’un autocompilateur.
Les différents types des langages usuels se décomposent en : •
des types simples, comme entier et booléen ;
•
des types structurés, comme les enregistrements, les classes, les tableaux, les pointeurs et les fichiers.
La technique souvent employée est de décrire par un type énuméré les différents genres de types, puis de préciser dans des enregistrements à variantes les différents cas rencontrés. Un type tableau est décrit par les types des indices et des éléments ainsi que par les bornes inférieures et supérieurs des indices, qui doivent être des constantes en Pascal, mais qui peuvent être des expressions constantes en C++. La description des fonctions et des enregistrements et classes fait intervenir la gestion des niveaux de déclarations, qui est présentée au paragraphe 8.10.
La description des types d’un langage comme Pascal ou C++ est un ensemble de graphes orientés acycliques (Directed Acyclic Graph, DAG). On trouve un exemple au paragraphe 8.27. Les arcs sont en général des pointeurs du langage d’implantation. Le cas où l’on utilise des indices dans des tableaux au lieu de pointeurs n’est pas de nature différente. Les graphes décrivant les types prédéfinis sont créés au début de l’exécution du compilateur. Ceux décrivant les types définis dans le programme en cours de compilation sont créés lors de l’analyse sémantique.
On utilise souvent un type caché interne au compilateur, indiquant qu’une construction du langage est erronée. Cela permet, une erreur ayant déjà été signalée, de ne pas produire d’autres messages d’erreurs sémantiques. Ainsi, dans le fragment Formula : f (n) = EcrireNombre (i * i);
Analyse sémantique 175
à la suite du message : ### Erreur sémantique: l'identificateur 'i' n'a aucune déclaration accessible fTerminal = 'Ident i' ###
il est inutile de produire quelque chose du genre de : Erreur: "EcrireNombre" n’a pas un argument d’un type admissible
ce qui constituerait un cas typique de message superflu, et un manque de politesse de la part du compilateur. Pour cela, il suffit d’enregistrer l’identificateur i comme ayant été déclaré du type caché erroné, pour qu’on n’ait que le seul message vraiment utile. En fait, même la seconde occurrence de i ne donne pas de message dans cet exemple avec le compilateur Formula. On peut décrire les types dans la classe Type de manière ré-utilisable au moyen de trois champs : •
le champ fNbReferences sert à savoir quand une description de type peut être détruite ;
•
le champ fDescriptionType quant à lui n’est utilisé que pour l’agrément de l’utilisateur dans les messages sémantiques éventuels.
•
enfin, le champ fGenreType est du type short pour pouvoir y stocker divers types énumérés selon les besoins des sous-classes de Type.
L’implantation est faite par le code suivant : Boolean Type :: EstIdentiqueA (TypePtr autreType) { return autreType == this; } Boolean Type :: ALaMemeStructureQue (TypePtr autreType) { return EstIdentiqueA (autreType); } Boolean Type :: AccepteAvecConversionImplicite ( TypePtr autreType) { return ALaMemeStructureQue (autreType); }
Pour les besoins de l’analyse sémantique de Formula, nous décrirons par des sous-classes de Type : •
les types TypeNombre, TypeBooleen et TypeVide, propres à Formula ;
•
TypeInconnu, pour décrire les constructions erronées ;
•
TypeNonPrecise, pour les cas de surcharge sémantique tels que ceux des fonctions prédéfinies Si et Seq, qui peuvent tout aussi bien retourner un nombre qu’un booléen ;
•
TypeLogLIBRE, utilisé comme valeur distinctive d’une variable logique de type libre, c’est-à-dire non encore liée à l’un des types ci-dessus par le mécanisme d’inférence de type.
176 Compilateurs avec C++
Cela se traduit par le type énuméré suivant : enum GenreTypesFormula { kTypeInconnu, kTypeNonPrecise,
//
pour les surcharges sémantiques
kTypeLogLIBRE,
// //
pour la gestion de variables logiques de type
kTypeNombre, };
kTypeBooleen,
kTypeVide
A cela correspondent des descriptions des types Formula par des classes du genre de : class TypeInconnu : public Type { public: TypeInconnu (); };
La seule fonction membre spécifique à ces descriptions de type est un constructeur chargé de préciser l’état initial de l’instance unique qui va en être créée. Ainsi le constructeur de TypeNombre a la forme : TypeNombre :: TypeNombre () : Type (kTypeNombre, "Nombre") {} où l’on voit l’appel au constructeur de la superclasse Type, avec des arguments précisant le genre du type et son nom. 8.8
Description des constantes autodéfinies
Un compilateur doit construire, lors de l’analyse lexicale, une description des constantes autodéfinies rencontrées dans le source en cours de compilation afin de pouvoir s’en servir lors de la phase de synthèse de la forme objet. Cela est bien sûr nécessaire pour que la forme objet ait la même sémantique que la forme source. On peut construire une table de toutes les constantes rencontrées. Une optimisation de la taille du code objet facile à mettre en œuvre est alors de reconnaître les occurrences multiples d’une même constante. Dans l’analyse sémantique de Formula ces constantes sont des feuilles des graphes sémantiques, et elles ne sont pas gérées de manière particulière. 8.9
Description des identificateurs
Pour les besoins des contrôles sémantiques, un compilateur doit se construire une représentation des identificateurs déclarés dans le programme en cours de compilation. Comme certains identificateurs dénotent des constantes ou des types, on se réfère dans ces cas à une description de type ou de constante. On utilise typiquement des variantes pour décrire les identificateurs. Les fonctions prédéfinies font l’objet d’une variante particulière : en effet, leur traitement est
Analyse sémantique 177
différent de celui des procédures et fonctions utilisateur. Par exemple, si on définit la fonction carre par : carre (t) = t * t;
le code synthétisé avec passage des paramètres par valeur pour l’expression : carre( 3.141592 )
est très différent de celui synthétisé pour l’appel à la fonction prédéfinie : Racine( 3.141592 )
bien que les contrôles sémantiques effectués soient les mêmes dans les deux appels. La description des identificateurs s’appuie typiquement sur la description des niveaux de déclarations, présentée au paragraphe suivant. Une description ré-utilisable des identificateurs dans la classe Ident peut être basée sur trois champs : •
le champ fNom est la chaîne de caractères constituant l’identificateur ;
•
le champ fGenreIdent indique à quel genre d’identificateur on a affaire. Nous utilisons le type short pour le champ fGenreIdent afin de ne pas présumer des genres d’identificateurs dont nous aurons besoin pour compiler un langage particulier ;
•
le champe fTypeIdent est un pointeur sur la description du type de l’identificateur ;
•
Le champ fNbUtilisations permet de fournir un avertissement dans le cas où un identificateur déclaré n’est pas utilisé.
Les identificateurs Formula tombent dans l’une des catégories suivantes : •
constante prédéfinie, comme Vrai, Pi et Vide ;
•
fonction prédéfinie, comme Sin et Non ;
•
fonction définie par l’utilisateur dans le code source compilé ;
•
paramètre formel d’une fonction utilisateur ;
•
indice d’une itération Pour, Somme ou Produit ;
•
non identificateur non déclaré, enregistré dans la table des symboles lors de son premier emploi.
Cela se traduit par les types énumérés suivants : enum GenreIdentsFormula { kIdentNonDeclare, kIdentConstPredef, kIdentFonctUtilisateur,
//
rattrapage d'erreurs semantiques
kIdentFonctPredef, kIdentParamFormel,
178 Compilateurs avec C++
kIdentIndiceIteration };
La classe IdentFormula est déclarés de la manière suivante : class IdentFormula : public Ident { typedef IdentFormula * IdentFormulaPtr; public: IdentFormula ( char GenreIdentsFormula VarLogTypePtr
* leNom, leGenre, laVarLogType );
VarLogTypePtr
VarLogType ();
Boolean
RecupererLeTypeInfere ();
protected: VarLogTypePtr fVarLogType; }; // IdentFormula
Toutes les descriptions d’identificateurs Formula présentées à la suite dans ce chapitre sont des sous-classes de IdentFormula. Le champ variable logique de type fVarLogType est utilisé dans l’inférence de type. La méthode RecupererLeTypeInfere fait que la composante Ident d’une instance de la classe IdentFormula puisse recevoir le type inféré. Elle est listée en appendice au paragraphe A.4.2. Les identificateurs non déclarés sont décrits au moyen de la classe : class IdentNonDeclare : public IdentFormula { typedef IdentNonDeclare * IdentNonDeclarePtr; public: IdentNonDeclare ( char VarLogTypePtr virtual char
* leNom, laVarLogType );
* SousFormeDeChaine ();
virtual void PurgerIdent (short lIdentation); }; // IdentNonDeclare
Dans la méthode PurgerIdent, on renonce à produire un message d’erreur si le type d’un identificateur non déclaré n’a pu être inféré.
Analyse sémantique 179
Les constantes prédéfinies Formula sont décrites par un champ en plus de ceux hérités de IdentFormula, nommé fConstante, et qui est du type énuméré : enum GenreConstPredef { kVrai, kFaux, kPi,
kE,
kVide };
Les fonctions prédéfinies en Formula, quant à elles, sont définies par un champ en plus de ceux hérités de IdentFormula. Il est nommé fFonction, et il est du type énuméré : enum GenreFonctPredef { kEgale, kDifferent, kInf, kSup,
kInfEgale,
kNon, kPair,
kEt,
kOu,
kLireNombre, kEcrireNombre,
kLireBooleen, kEcrireBooleen,
kEcrireFinDeLigne,
kRacine, kSin, kLog,
kHasard, kCos, kExp,
kArcTan,
kSi,
kSeq,
kSeq1,
kSomme, };
kProduit,
kPour
kSupEgale,
Là encore, il y a peu de commentaires à faire. Le constructeur est implanté par : FonctPredef :: FonctPredef ( char * leNom, GenreFonctPredef laFonction, VarLogTypePtr laVarLogType ) : IdentFormula (leNom, kIdentFonctPredef, laVarLogType) { fFonction = laFonction; }
On trouve au paragraphe 8.27 un autre exemple de description des identificateurs dans un compilateur. Récupération des types Formula inférés
Tout le mécanisme d’inférence de types pour Formula s’appuie sur le champ fVarLogType de IdentFormula, mais le type décrivant chaque identificateur dans Ident est déconnecté de ces variables, donc des résultats de l’inférence de type. En ce qui concerne les identificateurs prédéfinis, leur type est précisé lors de la création de leur description, comme illustré au paragraphe 8.19, et il n’y a pas là de problème particulier. En revanche, les types des fonctions définies dans le code source compilé, de leurs paramètres et des évaluations Formula doivent être récupérés dans les valeurs de liaison des variables logiques correspondantes. Dans le cas des paramètres formels des fonctions utilisateur cela est fait par la méthode RecupererTypesParams listée en appendice, à la paragraphe A.4.2.
180 Compilateurs avec C++
Les descriptions de type des fonctions utilisateur et de leurs paramètres sont ainsi stockées dans les descriptions d’identificateurs correspondantes. Cela permet par la suite de contrôler sémantiquement les appels à ces fonctions. 8.10
Description des niveaux de déclarations
Chaque niveau de déclarations doit être décrit par une structure de données adéquate. Cela permet de vérifier que le source en cours de compilation satisfait aux règles du langage que l’on implante, et aussi de vérifier, s’il y a lieu, que les identificateurs soient déclarés avant d’être utilisés. Un niveau de déclarations est décrit par une table des identificateurs déclarés dans ce niveau, que nous appelons un dictionnaire. Il y a aussi lieu de créer au début de la compilation un tel dictionnaire pour les identificateurs prédéfinis du langage. Un dictionnaire d’identificateurs peut être structuré de n’importe quelle manière, pourvu que l’on puisse y insérer des identificateurs et les rechercher ensuite par leur nom. Ainsi, une table linéaire, un arbre de recherche ou une table associative (hash-table) peuvent faire l’affaire. Dans le cas de Formula, un arbre de recherche est utilisé. 8.11
Structure de la table des symboles
La table des symboles (symbol table) est formée de l’ensemble des dictionnaires contenant les identificateurs déclarés. Le dictionnaire des identificateurs prédéfinis figure aussi dans cette table. Dans certains langages, comme Fortran, on compile indépendamment un programme (program), une procédure (subroutine) ou une fonction (function). Il n’y a donc à chaque instant qu’un niveau de déclarations propre au source en cours de compilation en plus des identificateurs prédéfinis et des zones de communs (common), visibles dans tous les cas. Selon la complexité de l’analyse sémantique du langage considéré, il peut être nécessaire de construire explicitement la table de tous les dictionnaires comme structure de données dans le langage d’implantation, afin de l’utiliser ultérieurement pour les contrôles sémantiques. Le paragraphe 8.14 cite un cas de telle construction explicite. Dans les cas usuels, on se contente de traverser la table des dictionnaires sans la construire explicitement comme structure de données dans le compilateur. Cependant, les dictionnaires doivent dans tous les cas être construits pour pouvoir y insérer et rechercher les identificateurs.
Analyse sémantique 181
Dans l’implantation de Formula, la table des dictionnaires n’est pas construite explicitement. L’autocompilateur Newton original en six passes ne construisait pas explicitement la table des dictionnaires. Il la parcourait dans la passe d’analyse syntaxique, en plaçant dans le fichier de sortie des informations permettant de re-construire chaque dictionnaire dans la passe d’analyse sémantique. Ainsi cette table des dictionnaires était parcourue à nouveau dans cette dernière passe. La même question de construction ou traversée se pose pour les graphes sémantiques, comme on le verra au paragraphe 8.15. Le cas des langages à structure de blocs
Dans les langages à structure de blocs, l’analyse sémantique doit s’appuyer sur l’imbrication des blocs. Rappelons que la règle est la suivante. Un identificateur est visible dans le bloc où il est déclaré depuis le point de sa déclaration et dans les blocs imbriqués, pour autant qu’il n’y fasse pas l’objet d’une déclaration masquant la précédente. On trouve au paragraphe 9.15 un exemple de masquage d’une déclaration par une autre plus locale en Formula. On notera que le with de Pascal rend visibles temporairement les champs de la variable suivant le with dans le texte de l’instruction contrôlée par ce motclé. Cela est illustré au paragraphe suivant.
Les identificateurs prédéfinis sont considérés comme déclarés dans un bloc qui englobe celui de tout le code source que l’on compile. Les modules importés par un source en cours de compilation au moyen de uses en Pascal ou Modula-2, ou de #include en C++, sont traités de la même manière que le with de Pascal, puisque l’importation rend les identificateurs concernés visibles pour la durée de la compilation du code source qui fait l’importation. La règle des langages à structure de blocs pose un problème en Pascal où l’on doit parfois prédéclarer des identificateurs dans des cas de dépendance mutuelle, ce qui s’avère nécessaire dans deux cas : •
on veut que deux procédures/fonctions puissent s’appeler l’une l’autre et que les deux soient appelables depuis le bloc contenant leur déclaration. On utilise alors une prédéclaration avec le mot clé forward ;
182 Compilateurs avec C++
•
on veut décrire des types mutuellement récursifs au moyen de structures chaînées par des pointeurs, comme illustré au paragraphe 8.1.
Dans les langages à structure de blocs, l’imbrication des niveaux de déclarations fait que la table des symboles est un arbre de dictionnaires. Dans la cas où l’on traverse cet arbre sans le construire, on est amené à gérer une pile de dictionnaires. Une autre justification de l’emploi d’une pile de dictionnaires, outre qu’elle est typique de la traversée d’un arbre, est que la fin des blocs est rencontrée en ordre inverse de leur ouverture. On le voit sur l’exemple du paragraphe suivant. 8.12
Exemple de table des symboles
Voici un exemple en Pascal, dans lequel nous donnons en commentaire à chaque déclaration de i ou pi un nom comme pi_2 pour les distinguer : program symboles; const pi = 3.14;
(* pi_1 *)
type struct = record i : boolean; pi : real end;
(* i_2 *) (* pi_2 *)
var i une_struct
: integer; : struct;
procedure proc (i: char); var pi : string [12]; begin (* proc *) write (i + pi ); with une_struct do write (i + pi ); write (une_struct.i + pi) end; (* proc *)
(* i_1 *) (* i_3 *) (* pi_3 *) (* i_3 + pi_3 *) (* i_2 + pi_2 *) (* i_2 + pi_3 *)
function fonct: real; var r: real; begin r := random; funct := r end; begin (* identificateurs *) writeln (i + pi); with une_struct do write (i + pi ); write (une_struct.i + pi); write (blark (pi)) end. (* symboles *)
(* i_1 + pi_1 *) (* i_2 + pi_2 *) (* i_2 + pi_1 *) (* ERREUR *)
Analyse sémantique 183
On peut visualiser l’imbrication des blocs de cet exemple sous la forme de l’arbre de la figure 8.2. Dans l’instruction : prédéfinis
boolean real integer char string random write
program “symboles“
pi struct i une_struct proc funct enregistrement “struct“
procédure “proc“
i pi
fonction “funct“
i pi
r
imbrication des niveaux de déclarations dictionnaire des champs d’une structure
8.2Exemple de structure de la table des identificateurs en Pascal with une_struct do write (i + pi );
(* i_2 + pi_2 *)
on accède aux champs de l’enregistrement une_struct au moyen de l’instruction with, ce qui fait que c’est dans le niveau de déclaration des champs de la structure que l’on cherche en premier, puis depuis le niveau où se trouve le with. En d’autres termes, le niveau de déclaration des ces champs n’est accessible que dans un with et lors de l’accès par la notation pointée. On remarque dans cet exemple que l’identificateur blark, utilisé dans : write( une_struct.i + blark(pi) )
(* "i_2" *) (* "pi_1" *)
184 Compilateurs avec C++
n’a aucune définition accessible depuis le corps du programme principal. Il y a donc là une faute de sémantique statique qui sera signalée par le compilateur par un message du genre “identificateur blark non non déclaré“. On notera qu’une déclaration de cet identificateur dans proc, par exemple, ne résoudrait rien car elle ne serait pas visible en ce point du code. Comme on peut s’en rendre compte, les blocs sont bel et bien fermés en ordre inverse de leur ouverture dans cet exemple. 8.13
Point de déclaration d’un identificateur
A partir de quel point une déclaration d’identificateur permet-elle d’utiliser cet identificateur dans la suite du texte source ? Pour illustrer cette question, considérons l’exemple Formula : ? Somme (i, 1, i, i * i);
Rappelons que la sémantique des fonctions prédéfinies d’itération de Formula est : •
le premier argument sert à déclarer l’indice de l’itération, qui est toujours un nombre ;
•
le deux paramètres suivants sont les bornes inférieure et supérieure de variation de cet indice, qui parcourt cet intervalle par pas de 1 ;
•
le quatrième paramètre est une expression re-calculée à chaque passage dans l’itération.
La sémantique de Formula fait qu’un indice d’itération n’est utilisable que dans le quatrième argument d’appel. En clair :
Le point de déclaration d’un indice d’itération Formula est placé juste après la virgule séparant le troisième argument d’appel du quatrième. L’évaluation ci-dessus est donc sémantiquement à rejeter. L’analyseur sémantique produit les message suivants : ### Erreur sémantique: l'identificateur 'i' n'a aucune déclaration accessible fTerminal = 'Ident i' ### ### Avertissement sémantique: la définition de l'indice d'iteration 'i' masque une autre declaration fTerminal = 'Ident i' ###
Analyse sémantique 185
ainsi que le graphe sémantique : Somme indice i 1.000000 --- VALEUR INCONNUE --Fois indice i indice i -----------------
Il y a clairement deux i dans la table des symboles : l’un est celui qui n’est pas déclaré, apparaissant dans le troisième argument d’appel à Somme, tandis que l’autre i est l’indice déclaré de cette même somme. Le fait d’enregistrer un identificateur non déclaré dans le dictionnaire en sommet de pile constitue un rattrapage d’erreur sémantique dans l’analyseur sémantique Formula. L’avertissement sémantique indiquant que le i indice d’itération masque une autre déclaration est une conséquence de ce rattrapage. Il n’est pas superflu puisqu’il attire l’attention de l’utilisateur sur la présence de ces deux i différents.
Comme autre exemple de situation où le message d’avertissement sémantique ci-dessus est bien utile, voici : ? Somme ( k, 1, 5, Somme ( k, k, k * k, 1 / (k + k) ) );
Ce programme est correct et produit à la compilation le message d’avertissement : ### Avertissement sémantique: la définition de l'indice d'iteration 'k' masque une autre declaration fTerminal = 'Nombre 1.000000' ###
Les deux bornes de variation du second indice k sont donc bien calculées en fonction de la valeur courante du premier k. Comme la valeur 1 / (k + k) est, quant à elle, calculée en fonction de la valeur courante du second indice k, le résultat produit par cette évaluation est : Valeur: 3.346161 ================= 8.14
Construction ou traversée de la table des symboles
Une technique fréquemment employée est de faire une première passe lexico-syntaxique construisant la table des identificateurs, mais ne faisant aucun contrôle sémantique. En ce sens, on accepte donc un sur-langage du langage à compiler.
186 Compilateurs avec C++
Ensuite, une seconde passe relit le source au niveau des terminaux en s’appuyant sur cette table des identificateurs existante pour vérifier sémantiquement tous les emplois des identificateurs. Comme la table des dictionnaires peut être grosse en mémoire et que les cas de dépendances mutuelles sont malgré tout assez rares, on peut aussi pratiquer ainsi : •
on écrit une procédure : compiler (Boolean premiere_passe)
dont le paramètre formel booléen indique s’il l’on effectue la première passe ou non ; •
on appelle cette procédure depuis le programme principal du compilateur avec l’argument “vrai“ ;
•
s’il n’y a pas de références en avant, la procédure se termine normalement ;
•
sinon, elle se rappelle récursivement avec l’argument “faux“ ;
•
dans le corps de cette procédure, si premiere_passe est “vrai“, on se contente d’enregistrer les déclarations des identificateurs. S’il est “faux“, on se contente de contrôler l’emploi de ces identificateurs.
Ce n’est que dans le cas où il n’y a pas de références en avant dans le code source compilé que cette méthode impose un surcoût par rapport à une traversée de la table des identificateurs sans construction explicite. En effet, il est superflu dans ce cas de conserver la table des identificateurs en entier pour toute la durée de l’appel à compiler. 8.15
Graphes sémantiques
Pour pouvoir analyser les opérandes de toutes les opérations et déterminer leur sémantique, le compilateur doit connaître tous les cas de figures admis. Cela peut se faire par l’une ou l’autre technique suivante, voire par la combinaison des deux : •
l’une utilise des tables, soit des structures de données, décrivant les opérateurs et les opérandes qu’elles acceptent ;
•
l’autre utilise du code faisant les vérifications sémantiques détaillées des types des opérandes selon les opérations.
On peut représenter la sémantique des instructions et expressions d’un langage par un graphe acyclique orienté, que nous appelons simplement graphe sémantique. On emploie parfois le terme de syntaxe abstraite (abstract syntax) par opposition à la syntaxe concrète, qui est la vraie syntaxe au sens des chapitres précédents. Ce terme nous semble mal choisi puisqu’on ne décrit pas la forme de ce qu’on analyse au premier niveau, mais bien la signification véhiculée. Celle-ci n’est pas directement “visible“ puisqu’il faut une analyse sémantique pour l’extraire.
Analyse sémantique 187
Rappelons que certaines informations apparaissent dans un graphe sémantique, mais pas au niveau syntaxique. Il en va ainsi des conversions implicites de type que nous avons illustrées à la figure 8.1. C’est aussi le cas des accès à la valeur des variables, qui sont distingués des accès à leur adresse au moyen de l’opérateur sémantique valeur_de dans la figure 8.3. Dans un graphe sémantique les feuilles sont des opérandes élémentaires, tandis que les nœuds sont des opérateurs. Les arcs rattachent les opérateurs à leurs opérandes. Les appels de procédures et fonctions sont des opérations particulières. On pourrait penser que des arbres sémantiques suffisent à représenter la sémantique d’une construction comme i := i * 4 - 4, qui peut être décrite par l’arbre sémantique de la figure 8.3. On a besoin de graphes, et non pas simplement d’arbres, dans le cas où l’on fait de la reconnaissance d’expressions communes, comme cela est illustré dans cette même figure. Nous parlerons donc systématiquement de graphes sémantiques dans la suite de ce livre. affectation
affectation
-
-
i * valeur_de
4 4
*
valeur_de
i
4
i
8.3Arbres et graphes sémantiques Dans le cas où l’on désire construire explicitement les graphes sémantiques, il faut déclarer des types dans le langage d’implantation qui permettront de construire ces graphes. Les arcs du graphe sont alors typiquement des pointeurs sur les descriptions des sous-expressions.
188 Compilateurs avec C++
Nous déclarons la classe DescrSemantique de manière ré-utilisable de la manière suivante : class DescrSemantique { typedef DescrSemantique
* DescrSemantiquePtr;
public: virtual void };
//
Ecrire (short lIndentation = 1) = 0; // virtuelle pure DescrSemantique
La méthode Ecrire n’est utile que pour présenter les graphes sémantiques dans ce livre. Aucune implantation n’est faite de cette classe puisqu’elle est abstraite, c’està-dire que toutes ses méthodes sont virtuelles pures. La hiérarchie des classes décrivant les nœuds sémantiques Formula a pour racine DescrSemFormula, elle-même sous-classe de DescrSemantique. Elle est illustrée à la figure 8.4. On y trouve en plus des champs hérités de cette dernière un champ fTypeLogique, variable logique de type, ainsi que la méthode concrète : void DescrSemFormula :: Ecrire (short lIndentation) { Indenter (lIndentation); }
On voit là un cas typique de structuration des données avec des classes, les superclasses servant à factoriser ce qui est commun à leurs sous-classes. Les constructeurs des différents niveaux de la hiérarchie appellent peu de commentaires. Citons simplement : Si :: Si ( DescrSemFormulaPtr DescrSemFormulaPtr DescrSemFormulaPtr VarLogTypePtr
laCondition, laValeurSiVrai, laValeurSiFaux, leTypeLogique )
: DescrSemFormula (leTypeLogique) { fCondition = laCondition; fValeurSiVrai = laValeurSiVrai; fValeurSiFaux = laValeurSiFaux; }
qui illustre comment on construit un nœud du graphe sémantique étant donné ses sous-graphes. L’argument leTypeLogique est ici vital pour inférer le type retourné par ce nœud sémantique Si en fonction de celui de laValeurSiVrai et laValeurSiFaux. Le type de laCondition doit néanmoins nécessairement être booléen.
Analyse sémantique 189
DescrSemantique DescrSemFormula OperateurZeroaire Hasard EcrireFinDeLigne LireNombre, LireBooleen OperateurUnaire Non, Pair MoinsUnaire Racine, Sin, Cos, ArcTan, Log, Exp EcrireNombre, EcrireBooleen OperateurBinaire Et, Ou Plus, Moins, Fois, DivisePar Inf, Egale, Sup, InfEgale, SupEgale, Different Seq, Seq1 ValeurInconnue ValeurNombre ValeurLogique ValeurVide EmploiParam EmploiParamParValeur EmploiParamParNom EmploiParamParEsseux AppelDeFonctionSi EmploiIndiceIter Iteration Somme, Produit, Pour
8.4Hiérarchie des classes décrivant les graphes sémantiques pour Formula Comme toutes les constructions du langage, même erronées, doivent donner lieu à un graphe sémantique, nous avons aussi déclaré la variable globale : extern const DescrSemFormulaPtr
gDescrSemFormulaInconnue;
qui est initialisée par : static const DescrSemFormulaPtr
gDescrSemFormulaInconnue = new ValeurInconnue ();
190 Compilateurs avec C++
Gestion des graphes sémantiques
On peut construire explictement les graphes sémantiques comme structures de données dans le langage d’implantation, par exemple pour les utiliser ensuite dans une passe ultérieure de compilation. Alternativement, on peut se contenter de traverser les graphes sémantiques sans vraiment les construire : on passe toutefois par chacun des nœuds. Le but à atteindre dicte le choix entre les deux approches : •
si l’on désire faire des optimisations poussées, on doit en général faire une compilation en plusieurs passes. Cela fait que l’on doit construire explicitement le graphe sémantique des expressions et instructions que l’on compile ;
•
nous avons choisi de construire explicitement les graphes sémantiques de Formula, ce qui nous permettra de procéder à leur exécution directe au chapitre 10 ;
•
une traversée simple, sans construction explicite, est typique d’une compilation où l’analyse sémantique et la synthèse du code objet se font dans la même passe.
Un encodage particulier des graphes sémantique est parfois réalisé par des tableaux, un peu à la manière de tables relationnelles. On parle alors dans la littérature de triplets (triples) ou de quadruplets (quadruples), selon le cas. 8.16
Exemple de graphes sémantiques non construits
Le compilateur DiaLog, utilisé pour le paragraphe 2.4, s’appuie sur une descente récursive programmée en Pascal. Au lieu de construire les graphes sémantiques en tant que structures de données Pascal, de manière analogue à ce que nous faisons dans ce livre en C++ pour Formula, on se contente de traverser ces graphes car on ne fait qu’une passe de compilation. Les procédures réalisant la descente récursives peuvent être naturellement imbriquées en Pascal. Chacune retourne par référence à l’appeleur une description d’un opérande du type opd_descr, analogue à DescrSemFormula et à ses descendantes que nous avons utilisées pour Formula. Toutefois, le graphe sémantique n’est pas manipulé dans son entier : il n’est pas une structure de données. On utilise : type opd_kind = ( const_opd, field_opd, var_opd, expr_opd, page_format_opd, test_program_opd); opd_descr = record opd_type: type_ptr; case opd_tag : opd_kind of field_opd: (opd_field: field_ptr) end;
Analyse sémantique 191
L’analyse sémantique des identificateurs n’est pas détaillée ici faute de place, car elle fait intervenir la gestion des quantificateurs. Voici un extrait du compilateur DiaLog : procedure term (var term_res_opd: opd_descr); var right_opd : opd_descr; mul_op : symbol_kind; procedure factor (var factor_res_opd: opd_descr); begin (* … … … *) end; begin (* term *) factor (term_res_opd); while fetch_symbol in [star_sy, idiv_sy, mod_sy] do begin mul_op := curr_symbol; factor (right_opd); case mul_op of star_sy: (* integer multiplication *) begin with term_res_opd do if opd_type <> bi_integer_type then expected_type_error ( opd_type, bi_integer_type, 'left operand of "*"'); with right_opd do if opd_type <> bi_integer_type then expected_type_error ( opd_type, bi_integer_type, 'right operand of "*"'); emit (int_times_instr) end; idiv_sy: begin (* … … … *) end; mod_sy: begin (* … … … *) end end; (* case *) with term_res_opd do begin opd_tag := expr_opd; opd_type := bi_integer_type end end; (* while *) symbol_ready := true end; (* term *)
192 Compilateurs avec C++
Le préfixe bi_ signifie “built-in“ (prédéfini). L’emploi de la variable symbol_ready illustre la lecture des caractères contrôlée par un booléen. Comme on le voit sur cet exemple, c’est l’analyse récursive des expressions qui fait que l’on parcourt le graphe sémantique sans le construire. La finesse de description des opérandes est moindre que dans le cas de Formula, mais elle suffit pour les besoins. Ce cas est typique d’une compilation en une passe.
Nous verrons au chapitre 10 que la construction explicite des graphes sémantiques permet d’évaluer les expressions directement. 8.17
Graphes sémantiques et forme postfixée
Nous avons déjà insisté sur le fait que la forme postfixée est fondamentale, parce qu’on ne peut pas éxécuter une opération tant que tous ses opérandes ne sont pas disponibles. Cela est vrai même dans le cas de passage de paramètre par besoin, qui est présenté au paragraphe 10.4. Par ailleurs, les graphes sémantiques sont incontournables, même si on ne les contruit pas explicitement : ils décrivent la sémantique du code source en cours de compilation, et cette sémantique doit être maintenue invariante entre la forme source et la forme objet. Le point clé est le suivant.
La notation postfixée est une écriture linéaire d’un graphe sémantique. Voici un exemple Formula : maFonction (n) = n * n + 2 * (n - 1); ? maFonction (3);
et la sémantique associée : --> Définition: La fonction utilisateur 'maFonction' du type '(Nombre) -> Nombre' Graphe sémantique: + * n n * 2.0000 n 1.0000 ------------------> Evaluation: expression -> Nombre
Analyse sémantique 193
Graphe sémantique: maFonction 3.0000 -----------------
Le lecteur est invité à comparer le graphe sémantique du corps de la fonction maFonction avec le code Pilum produit par le compilateur Formula pour le même exemple, avec un passage des paramètres par valeur : 0: Sauter
16
1: Commentaire:
'Début du corps de 'maFonction''
2: EmpilerValeur 3: Commentaire:
0,-2 'Par valeur n (no 1)'
4: EmpilerValeur 5: Commentaire:
0,-2 'Par valeur n (no 1)'
6: FoisFlottant 7: EmpilerFlottant 8: EmpilerValeur 9: Commentaire:
2.000000 0,-2 'Par valeur n (no 1)'
10: EmpilerFlottant
1.000000
11: MoinsFlottant 12: FoisFlottant 13: PlusFlottant 14: RetourDeFonction 15: Commentaire:
1 'Fin du corps de 'maFonction''
16: Commentaire: 17: EcrireFinDeLigne
'Début d'une évaluation'
18: EmpilerChaine 19: EcrireChaine 20: EcrireFinDeLigne
Valeur:
21: EmpilerFlottant 22: AppelDeFonction 23: Commentaire:
3.000000 1 'maFonction'
24: EcrireFlottant 25: EcrireFinDeLigne 26: EmpilerChaine 27: EcrireChaine 28: EcrireFinDeLigne
=================
194 Compilateurs avec C++
29: Commentaire:
'Fin d'une évaluation'
30: Halte
L’opérateur principal d’une expression est la racine de son graphe sémantique. Il vient en dernier dans la forme postfixée. On voit au paragraphe 11.18, dans le même exemple écrit en C++, que la notation postfixée est présente également en cas de code objet utilisant des registres.
L’évaluation directe des graphes sémantiques au chapitre 10 montre aussi l’équivalence entre graphe sémantique et forme postfixée. 8.18
Analyse sémantique de Formula
Nous allons montrer dans la suite de ce chapitre comment réaliser l’analyse sémantique de Formula en enrichissant le code de l’analyse syntaxique par descente récursive présentée au chapitre 7. L’analyse de la sémantique des phrases Formula acceptées syntaxiquement est ainsi faite “au passage“. L’analyse lexicale est faite par la méthode prédictive présentée au chapitre 5. Nous faisons en une passe les analyses lexicale, syntaxique et sémantique de Formula, produisant en sortie un graphe sémantique pour chaque définition de fonction et évaluation. Nous verrons au chapitre 9 la même analyse sémantique greffée sur une grammaire LALR(1) traitée par Yacc.
Les contrôles de type sont effectués par la méthode suivante : void AnalyseurFormula :: TesterTypeAttendu ( TypePtr typeAttendu, VarLogTypePtr typeLogiqueTrouve, char * entite ) { if (! typeLogiqueTrouve -> UnifierValeur (typeAttendu)) if ( typeAttendu != gTypeInconnu && typeLogiqueTrouve -> ValeurLiaison () != gTypeInconnu ) ErreurSemantique ( form ( "un(e) %s du type '%s' est attendu,\n\t" "une valeur du type '%s' a été trouvée", entite,
Analyse sémantique 195
} //
typeAttendu -> DescriptionType (), typeLogiqueTrouve -> DescriptionTypeLogique () )); AnalyseurFormula :: TesterTypeAttendu
Nous utilisons en cas d’erreur sémantique une forme de rattrapage psychologique d’erreurs, qui consiste à s’adapter à ce qui a été écrit par le programmeur. Cela permet de fournir des messages plus fins à l’utilisateur, mais surtout d’éviter des cascades de messages, ce qui est une forme de politesse pour un compilateur. 8.19
Création des identificateurs Formula prédéfinis
Lors de l’exécution du compilateur, le dictionnaire des identificateurs prédéfinis est créé et empilé le premier dans la pile de dictionnaires, accessible par le champ fPileDeDictionnaires de la classe AnalyseurFormula. La description de ces identificateurs est créée par la méthode InsererIdentsPredefinis qui insère chacun avec des appels du genre de : InsererConstPredef ("Vrai", kVrai, gTypeLogBooleen); InsererFonctPredef ("Non", kNon, gTypeLogBooleen); InsererFonctPredef ("EcrireFinDeLigne", kEcrireFinDeLigne, gTypeLogVide); InsererFonctPredef ("Si", kSi, gTypeLogNonPrecise); // surcharge sémantique InsererFonctPredef ("Seq", kSeq, gTypeLogNonPrecise); // surcharge sémantique InsererFonctPredef ("Somme", kSomme, gTypeLogNonPrecise); // surcharge sémantique
Le travail est délégué aux méthodes InsererFonctPredef et InsererConstPredef listées en appendice, à la paragraphe A.4.3. Les fonctions comme Si, Seq et Somme, qui retournent plusieurs types distincst selon leurs argumets, sont dites surchargées sémantiquement. Comme il faut bien indiquer un type lors de la prédéfinition, on utilise la variable gTypeLogNonPrecise dédiée à ce besoin. 8.20
Description sémantique des fonctions Formula
Chaque identificateur de fonction déclarée dans un programme source Formula est décrit par une instance de la classe FonctUtilisateur, elle-même sous-classe de IdentFormula. Aux champs hérités de cette dernière, elle ajoute : •
le champ fDictParams, pointeur sur le dictionnaire des identificateurs des paramètres formels de cette fonction ;
196 Compilateurs avec C++
•
le champ fListeParams, pointeur sur la liste des descriptions des paramètres telles que présentées ci-dessous, pour permettre la vérification des appels à la fonction concernée. Dans le cas de Formula, une liste ordonnée suffit pour implanter cette table. Le contrôle des appels à une fonction se fait par position, chaque argument d’appel devant pouvoir être accepté pour le paramètre formel de même numéro relatif que lui ;
•
et enfin le champ fCorps, pointeur sur une instance de DescrSemFormula ou de l’une de ses descendantes, qui est le graphe sémantique du corps de la fonction.
Les identificateurs des paramètres formels des fonctions sont décrits, quant à eux, par la classe ParamFormel. En plus des champs hérités de sa superclasse IdentFormula, celle-ci dispose du champ fDescrParam : il décrit plus finement le paramètre formel correspondant et on l’utilise pour vérifier l’emploi des paramètres formels dans le corps des fonctions où ils sont déclarés. La classe DescrParam correspondante s’appuie sur quatre champs : •
le champ fParamFormel est une référence croisée à l’identificateur du paramètre formel correspondant ;
•
le champ fPassageParams, du type énuméré : enum GenrePassageParams { kParValeur, kParNom,
kParEsseux };
indique le mode de passage de ce paramètre ; •
le champ fNumeroDeParametre est un entier indiquant le numéro d’ordre de ce paramètre dans l’en-tête de la fonction. Il permet de se référer à chaque paramètre par sa position dans le bloc des paramètres dans les graphes sémantiques ;
•
enfin le champ fParametreSuivant est un pointeur permettant de gérer une liste à simple sens de ces descriptions de paramètres.
La structure de la description des paramètres et des fonctions est illustrée à la figure 8.5. Les différents modes de passages de paramètres sont reflétés par des sous-classes de DescrParam comme : class DescrParamParEsseux : public DescrParam { typedef DescrParamParEsseux * DescrParamParEsseuxPtr;
Analyse sémantique 197
public: };
//
DescrParamParEsseux ( short leNumeroDeParametre ); DescrParamParEsseux
Cette description succinte des fonctions et de leurs paramètres sera enrichie dans les chapitres suivants pour permettre l’évaluation directe des graphes sémantiques et la synthèse de code Pilum.
198 Compilateurs avec C++
fFonctUtilisateur fonct (m, n) = m * 2 + n; ? fonct (3 + 5, 8 - 2);
fNom : “fonct“ fCorps + -
EmploiParValeur
EmploiParValeur
2
fDictParams “m“ kIdentParamFormel fTypeIdent fDescrParam “n“ kIdentParamFormel fTypeIdent fDescrParam
“Type Nombre“ kTypeNombre
fListeParams fParamFormel
fParamFormel
kParValeur, no 1
kParValeur, no 2
fParamFormelSuivant
fParamFormelSuivant
fArgumentsDAppel (1) (2) + 3 8 pointeur simple pointeurs dans les deux sens
5
2
objet et ses champs
8.5Description sémantique d’un appel à une fonction Formula
Analyse sémantique 199
8.21
Analyse d’une définition de fonction Formula
On crée un dictionnaire pour stocker les descriptions des arguments de chaque fonction déclarée dans le source compilé. On y stocke aussi les identificateurs non déclarés utilisés dans son son corps. Dans un langage permettant des déclarations locales dans les fonctions et procédures, le même dictionnaire est utilisé pour y insérer les identificateurs locaux. C’est lors de la création de la description de la fonction par : lIdentFonction = new FonctUtilisateur ( nomDeLaFonction, new VarLogType, dictParams );
que l’on crée la variable logique désignant son type. Elle restera libre jusqu’à ce qu’elle soit liée au type du corps par : TypePtr
typeFonction = RecupererLeType (leCorps, leMessage);
dans la méthode AnalyseurFormula :: Definition. Une variable logique de type est créée pour chaque paramètre formel. Elle est initialement libre et sera liée lors de l’emploi du paramètre correspondant comme argument d’une fonction prédéfinie ou d’une fonction définie par l’utilisateur, comme illustré au paragraphe suivant. 8.22
Description des appels aux fonctions Formula
Le graphe sémantique d’un appel à une fonction Formula définie dans le code source compilé est une instance de la classe AppelDeFonction. Cette dernière, sous-classe de DescrSemFormula, ajoute aux champs hérités : •
le champ fFonctUtilisateur, pointeur sur la description de l’identificateur de la fonction appelée ;
•
le champ fArgumentsDAppel, tableau de pointeurs sur des instances de DescrSemFormula ou de ses descendantes. La création de ce tableau est illustrée au paragraphe suivant.
Chacun des éléments de fArgumentsDAppel pointe sur la racine du graphe sémantique de l’argument d’appel correspondant. Soit le source Formula suivant : fonct (m, n) = m * 2 + n; ? fonct (3 + 5, 8 - 2);
La figure 8.5 contient le graphe sémantique que le compilateur Formula construit pour cet appel à la fonction fonct avec un passage par valeur. On y retrouve tous les champs importants pour la structuration de la table des symboles.
200 Compilateurs avec C++
8.23
Analyse des appels aux fonctions prédéfinies Formula
Les paramètres des fonctions prédéfinies ne sont pas décrits par des structures de données pour Formula. L’analyse sémantique des arguments d’appel de ces fonctions est faite directement dans le code de l’analyseur. Le squelette de la fonction réalisant cette analyse est présenté en appendice, au paragraphe A.4.9. Voici la méthode réalisant l’analyse sémantique de la fonction Si, qui illustre le traitement de la surcharge sémantique : DescrSemFormulaPtr AnalyseurFormula :: InstrSi () { DescrSemFormulaPtr condition = Expression (); TesterTypeAttendu ( gTypeBooleen, condition -> TypeLogique (), "condition" ); TesterTerminal (VIRGULE, "après la condition d'un 'Si'"); DescrSemFormulaPtr
valeurSiVrai = Expression ();
TesterTerminal (VIRGULE, "après la partie 'alors' d'un 'Si'"); DescrSemFormulaPtr
valeurSiFaux = Expression ();
if ( ! valeurSiFaux -> TypeLogique () -> UnifierAutreVariable (valeurSiVrai -> TypeLogique ()) ) { ErreurSemantique ( form ( "les deux alternatives d'un 'Si' ne " "retournent pas des valeurs du même type\n" "\t(ici, '%s' et '%s')", valeurSiVrai -> TypeLogique () -> DescrTypeLogique (), valeurSiFaux -> TypeLogique () -> DescrTypeLogique () )); return gDescrSemFormulaInconnue; } else return new Si ( condition, valeurSiVrai, valeurSiFaux, valeurSiFaux -> TypeLogique () ); } // AnalyseurFormula :: InstrSi
C’est la tentative d’unification des variables logiques de type décrivant respectivement valeurSiFaux et valeurSiVrai qui implante la contrainte sémanti-
Analyse sémantique 201
que imposant que les deux alternatives d’un Si retournent des valeurs d’un même type. La surcharge sémantique de la fonction Si fait qu’elle peut retourner aussi bien une valeur du type nombre qu’une valeur du type booléen. Cela est fait dans le code ci-dessus par l’emploi, dans la construction du nœud décrivant le Si, de valeurSiFaux -> TypeLogique. On pourrait aussi utiliser valeurSiFaux -> TypeLogique, puisque si l’unification réussit, elle ne sont que deux noms pour une seule et même valeur. Le lecteur appréciera ici la puissance d’expression des variables logiques. Nous la mettons aussi à l’œuvre au paragraphe 12.15. D’autres exemples d’analyse sémantique des appels aux fonctions Formula prédéfinies sont présentées au paragraphe A.4.9. 8.24
Analyse des appels aux fonctions utilisateur Formula
Comme les fonctions définies dans le code source compilé peuvent avoir un nombre quelconque d’arguments, nous avons choisi de représenter les appels à ces fonctions par un nœud sémantique ayant un tableau dynamique de sous-graphes. Le nombre d’éléments du bloc d’arguments d’un appel est égal au nombre de paramètres formels de la fonction appelée. Il ne s’agit pas là du bloc de mémoire contenant les valeurs des arguments à l’exécution du code, mais bien d’un tableau de graphes sémantiques décrivant chacun un de ces arguments à la compilation. Ce bloc d’arguments est construit par la fonction AppelDeFonctUtilisateur listée ci-dessous.
La technique employée pour diriger l’analyse sémantique consiste à itérer sur les paramètres formels au fur et à mesure de la consommation des arguments de l’appel. Cela se fait avec un itérateur sur la liste des paramètres : IterateurParamsPtr
iter = new IterateurParams (laListeParams);
Ce dernier est créé dynamiquement et détruit par la suite. Cela permet, lors d’une faute dans le programme, de s’adapter en se mettant à itérer sur une autre liste de paramètres. On voit là un emploi du champ fListeParamsInconnus contenant une liste circulaire d’un seul élément. Le lecteur remarquera que de grands efforts sont faits pour récupérer les fautes dans les appels de fonctions définies par l’utilisateur. Cela permet de continuer l’analyse dans les cas où il manque des arguments dans un appel et lorsqu’il y en a trop, réalisant là encore un rattrapage psychologique d’erreurs.
202 Compilateurs avec C++
La figure 8.5 montre le graphe sémantique d’un appel de fonction. La méthode qui en fait l’analyse sémantique est : DescrSemFormulaPtr AnalyseurFormula :: AppelDeFonctUtilisateur ( FonctUtilisateurPtr laFonctUtilisateur ) { // IDENT a été accepté char
* nomFonction = laFonctUtilisateur -> Nom ();
ListeParamsPtr
laListeParams = laFonctUtilisateur -> ListeParams ();
Boolean
fonctionParametree = ! laListeParams -> Vide ();
DescrSemFormulaPtr* blocDArguments = NULL; if (fTerminal == PAR_GAUCHE) { if (! fonctionParametree) ErreurSyntaxique ( form ( "'(' inattendu dans un appel à la fonction %s", nomFonction )); Avancer (); blocDArguments = Arguments ( nomFonction, fonctionParametree ? laListeParams : & fListeParamsInconnus ); if (fTerminal != PAR_DROITE) { if (fonctionParametree) ErreurSyntaxique ( form ( "')' attendu après les arguments " "d'un appel à la fonction %s", nomFonction )); } else Avancer (); } / / if else if (fonctionParametree) ErreurSyntaxique ( form ( "'( arguments )' attendu dans un appel à la fonction %s", nomFonction )); return new AppelDeFonction (laFonctUtilisateur, blocDArguments); } // AnalyseurFormula :: AppelDeFonctUtilisateur
Analyse sémantique 203
8.25
Exemples d’analyse sémantique de Formula
Pour illustrer le comportement de l’analyseur sémantique décrit dans les paragraphes précédents, voici un premier exemple concret. Lors de l’analyse sémantique d’un premier source Formula contenant : CarrePlus (x, y) = x * x + y; ? CarrePlus (LireNombre (), 6);
on obtient le résultat détaillé suivant : On empile le dictionnaire 'Idents Prédéfinis' On empile le dictionnaire 'Fonctions Utilisateur' Ident CarrePlus ( Ident x , Ident y ) = On empile le dictionnaire 'CarrePlus' Ident Ident Ident
x * x + y ;
On désempile le dictionnaire 'CarrePlus' On purge le DictionnaireArbre 'CarrePlus', contenant: Le paramètre formel 'x': 'Nombre' Le paramètre formel 'y': 'Nombre' ? --> Définition: La fonction utilisateur 'CarrePlus': '(Nombre, Nombre) -> Nombre' Graphe sémantique: Plus Fois x x y ----------------Ident
CarrePlus
On empile le dictionnaire 'Evaluation_1' Ident
Nombre
( LireNombre ( ) , 6.000000
204 Compilateurs avec C++
) ; --- FIN ----> Evaluation: expression -> Nombre Graphe sémantique: CarrePlus LireNombre 6.000000 ----------------On désempile le dictionnaire 'Evaluation_1' On purge le DictionnaireArbre 'Evaluation_1', vide On désempile le dictionnaire 'Fonctions Utilisateur' On purge le DictionnaireArbre 'Fonctions Utilisateur', contenant: On purge le DictionnaireArbre 'CarrePlus', contenant: Le paramètre formel 'x': 'Nombre' Le paramètre formel 'y': 'Nombre' La fonction utilisateur 'CarrePlus': '(Nombre, Nombre) -> Nombre' On désempile le dictionnaire 'Idents Prédéfinis' On purge le DictionnaireArbre 'Idents Prédéfinis', contenant: La fonction prédéfinie 'ArcTan': 'Nombre': non utilisé(e) … … … … … La fonction prédéfinie 'LireBooleen': 'Booleen': non utilisé(e) La fonction prédéfinie 'LireNombre': 'Nombre' … … … … … La fonction prédéfinie 'Log': 'Nombre': non utilisé(e) La constante prédéfinie 'Vrai': 'Booleen': non utilisé(e)
Les graphes sémantiques sont présentés en indentant vers la droite les sous-arbres. Le lecteur peut suivre en détail les opérations successives sur la table des symboles. Exemples d’erreurs sémantiques
Voici maintenant un second exemple, illustrant les messages d’erreurs qui peuvent être produits par l’analyseur sémantique Formula. Le code source : funct (z, funct) = funct + 45; ? funct (3); ? funct (3, 7, Vrai); ? 3 * jj;
donne lieu aux messages d’erreurs sémantiques suivants : ### Erreur sémantique: le type du parametre formel 'z' n'a pas pu être inféré fTerminal = ' ;' ### --> Définition: La fonction utilisateur 'funct': '(-- Inconnu --, Nombre) -> Nombre'
Analyse sémantique 205
### Erreur sémantique: il y a trop peu d'arguments dans un appel de fonction utilisateur paramétrée 'z' et 'k' ont besoin d'une valeur fTerminal = ' )' ### ### Erreur sémantique: il y a trop d'arguments dans un appel de fonction utilisateur paramétrée fTerminal = 'Ident Vrai' ### ### Erreur sémantique: l'identificateur 'jj' n'a aucune déclaration accessible fTerminal = 'Ident jj' ### 8.26
Remarque importante
NOUS POURRIONS NOUS ARRETER ICI EN CE QUI CONCERNE LA COMPILATION DE FORMULA ! En effet, nous avons obtenu une autre forme ayant la même sémantique que le code source compilé, à savoir un graphe sémantique. Nous verrons d’ailleurs, au chapitre 10, que nous pouvons utiliser l’évaluation directe des graphes sémantiques pour évaluer des expressions Formula, plutôt que d’en créer du code pour une machine virtuelle ou réelle. Toutefois, la vitesse d’exécution directe des graphes sémantiques est moindre que celle d’un code objet ciblé pour un processeur virtuel ou réel. C’est pourquoi nous présenterons la synthèse du code objet au chapitre 12. Il est aussi possible d’exécuter les graphes sémantiques en les modifiant au fur et à mesure de l’exécution. On voit dans [Leler 88] comment cela est fait pour certains langages de programmation par contraintes. 8.27
Exemple de description de types structurés
Le compilateur Newton original, écrit en Pascal en une passe, utilise les définitions de types suivantes pour décrire les types. Nous avons présenté les pointeurs en gras, pour montrer la structure des graphes décrivant les types structurés récursivement, et éliminé des détails secondaires : id_descr_pt ty_descr_pt
= ^ id_descr; = ^ ty_descr;
ty_descr = record ref_count:
integer;
case ty: info_type of
206 Compilateurs avec C++
scalar_ty: ( scalar_nbr: max_value: const_head: );
/* type énuméré *( integer; integer; id_descr_pt
object_ty, process_ty, module_ty: ( ocpm_nbr: integer; decl_completed: boolean; attr_tree: id_descr_pt; index_descr: id_descr_pt; name_temp: integer );
/* classes */
set_ty, row_ty, stack_ty, queue_ty, table_ty: ( base_type: ty_descr_pt; );
/* structures */
else: () end; /* ty_descr */
Les paramètres des fonctions et procédures définies par le programmeur sont décrites dans le compilateur par : par_descr_pt = ^ par_descr; par_descr = packed record par_ty: ty_descr_pt; par_form: info_form; par_addr: displacement; next_par: par_descr_pt end;
Enfin, les identificateurs eux-mêmes sont représentés par : id_descr = record id_name: left: right:
string; id_descr_pt; id_descr_pt;
pack: packed record used: boolean; usage_list: usage_pt; id_type: ty_descr_pt; case form: info_form of sproced_form, sfunct_form: /* procédure ou fonction prédéfinie */ ( id_act: special_action ); const_form: /* constante */ ( id_val: word; case scalar_constant: boolean of true: ( next_const: id_descr_pt; type_exported:boolean; ); else: () );
Analyse sémantique 207
proced_form, funct_form: /* procédure ou fonction utilisateur */ ( code_addr: labels; par_head: par_descr_pt; keep_pars: boolean ); end end; /* id_descr */
La fonction indiquant si deux types sont les mêmes est la suivante : function same_type (tupe_a, tupe_b: ty_descr_pt): boolean; begin with tupe_a ^ do if ty = tupe_b ^. ty then case ty of scalar_ty: same_type := scalar_nbr = tupe_b ^. scalar_nbr; object_ty, process_ty, module_ty: same_type := ocpm_nbr = tupe_b ^. ocpm_nbr; set_ty, row_ty, stack_ty, queue_ty, table_ty: same_type := same_type (base_type, tupe_b ^. base_type); else: same_type := true end /* case */ else same_type := (tupe_a = undecl_std) or (tupe_b = undecl_std) end; /* same_type */ Nous avons mis en gras l’appel récursif traitant les types eux-mêmes récursifs. Le type caché interne au compilateur undecl_std joue le même rôle dans ce contexte que gTypeInconnu dans l’analyse sémantique de Formula présentée dans ce chapitre. 8.28
Exercices
8.1 : Traitement de listes en Formula (projet). Etendre le langage Formula au traitement de listes. Il faut pour cela : •
disposer d’une notation pour la liste vide ;
•
disposer d’une notation pour les constantes de paires pointées et de listes de manière équivalente à qu’on écrit par exemple, en Lisp : (34.9 Vrai 19.3 (14.5 Faux) . 5.2)
•
disposer de fonctions prédéfinies, comme Longueur(une_liste).
Comment adapter la gestion de l’inférence de type à ces nouvelles possibilités sémantiques ?
208 Compilateurs avec C++
Chapitre
9
9 L’outil Yacc
Ainsi que nous l’avons vu au chapitre 7, la construction des tables d’analyse pour les méthodes LR est laborieuse et ne peut être faite à la main dès que la grammaire comporte un certain nombre de productions. L’outil Yacc est un générateur d’analyseurs syntaxiques développé dans la mouvance de C et Unix pour vérifier si une grammaire est LALR(1) et construire les tables d’analyse correspondantes si c’est le cas. La première version a été réalisée par Johnson. Le nom Yacc signifie “Yet Another Compiler Compiler“ (voici encore un autre générateur de compilateurs). Yacc est donc le pendant, au niveau syntaxique, de Lex, qui a été présenté au chapitre 6.
Yacc compile une grammaire LALR(1) et produit le texte source d’un analyseur syntaxique du langage engendré par cette grammaire. Il est possible de décorer la grammaire pour faire une analyse sémantique ou toute autre activité lors de l’analyse syntaxique. L’intérêt de Yacc est que cet outil permet de se concentrer sur l’aspect grammatical de l’analyse syntaxique, sans trop s’occuper des détails du fonctionnement de l’analyseur synthétisé. On peut ainsi se consacrer aux problèmes les plus intéressants qui sont ceux liés à la sémantique, à la synthèse du code objet, et à l’optimisation de ce dernier.
210 Compilateurs avec C++
Le langage cible de Yacc est celui dans lequel le code de l’analyseur syntaxique synthétisé est écrit. Il s’agit en général de C , mais Yacc s’accommode très bien de code C++.
Le fichier obtenu s’appelle y.tab.c, avec des variations selon le système d’exploitation. La fonction réalisant l’analyse syntaxique s’appelle yyparse, et elle retourne la valeur 0 si la séquence de terminaux à analyser a été acceptée, ou 1 sinon. yyparse impose que l’analyseur lexical du langage soit une fonction nommée yylex, pour que l’emploi conjoint de Lex soit simple. On peut demander à Yacc de créer un fichier y.tab.h définissant les constantes représentant les terminaux du langage. Le fichier y.tab.h peut être inclus par #include dans lex.yy.c au cas où l’on compile ce dernier fichier séparément de y.tab.c. On voit un exemple de contenu de y.tab.h au paragraphe 9.2. Nous donnons une spécification Yacc du langage Formula à titre d’exemple dans ce chapitre, avec le langage cible C++. Yacc peut être utile pour toute analyse syntaxique de texte, et pas seulement pour écrire des compilateurs. 9.1
Qui fait quoi avec Yacc ?
Yacc analyse un fichier de texte contenant une description des aspects syntaxiques du langage pour lequel on désire synthétiser un analyseur. Cette description est écrite dans une syntaxe propre à Yacc, décrite dans les paragraphes suivants. Un fichier de description pour Yacc se compose de trois parties, séparées comme en Lex par une ligne ne contenant que %% aligné à gauche, selon le schéma suivant : déclarations %% productions %% code de service
Seuls le premier séparateur %% et la deuxième partie sont obligatoires, puisque cette dernière contient les productions de la grammaire. La description minimale que l’on peut fournir à Yacc est donc : %% productions
L’outil Yacc 211
Des commentaires encadrés par /* et */, comme en C, peuvent être placés partout où un identificateur au sens C peut être placé.
Il existe plusieurs versions de Yacc qui se distinguent par la qualité des algorithmes de création et de compactage des tables d’analyse LALR(1). Certaines supportent d’autres langages cibles que C. Nous avons mentionné au chapitre 6 que les outils Lex et Yacc ont été pensés pour être utilisés ensemble. On peut toutefois adjoindre à l’analyseur syntaxique synthétisé par Yacc n’importe quelle fonction yylex réalisant l’analyse lexicale, même si elle n’a pas été synthétisée par Lex. Particularités de la synthèse de code C++
Yacc, tout comme Lex, a été pensé pour créer du code C. Là encore les actions sont recopiées telles quelles sur le fichier y.tab.c, et on peut les écrire en C++. La version de Yacc que nous avons utilisée produit certaines déclarations de fonctions qui ne sont pas correctes sémantiquement pour C++. Nous devons donc modifier le code source synthétisé par Yacc par un script dans l’environnement de développement utilisé, qui a pour effet de remplacer : extern int yylex(); extern yyerror(); extern int write();
par : extern int yylex (); extern int yyerror (char *); extern int yylook (); extern int yyback (int *, int); extern "C" int write (int, char*, int); D’autres versions de Yacc pourraient conduire à une “adaptation“ un peu différente, et il en existe maintenant qui créent directement du code C++ valide. Exemple de Formula
La figure 9.1 montre la division du travail entre Yacc, le compilateur CPlus et l’éditeur de liens Link dans le cas de la construction de l’analyseur syntaxique de Formula présenté dans ce chapitre. Il est important de bien comprendre les actions indiquées par les flèches dans cette figure : •
la commande Yacc compile le fichier de description LexFormula.Yacc et produit le fichier y.tab.c, contenant le code C++ de l’analyseur lexical de Formula synthétisé ;
•
lex.yy.cp est le code source de l’analyseur lexical Formula obtenu avec Lex au chapitre 6. Son code source est simplement inclus dans celui synthétisé par Yacc, comme on le voit au paragraphe 9.4 ;
•
la commande Rename renomme le fichier y.tab.c en y.tab.cp, conformément à la convention du compilateur C++ utilisé ;
212 Compilateurs avec C++
•
le fichier LexYaccSupport.cp contient les fonctions complémentaires dont l’analyseur lexical synthétisé par Lex a besoin (les mêmes que dans LexSupport.cp), ainsi que celles dont l’analyseur syntaxique synthétisé par Yacc a lui-même besoin, comme indiqué au paragraphe 9.5 ;
•
les commandes CPlus compilent du source C++ en code objet ;
•
la commande Link construit le code de l’analyseur syntaxique dans le fichier exécutable YaccFormula ;
•
l’exécution de YaccFormula analyse syntaxiquement le fichier source Formula qu’on lui donne en argument. L’analyse lexicale produit au passage une séquence de terminaux, qui sont imprimés sur la sortie standard pour les besoins de ce livre. L’analyse syntaxique, quant à elle, accepte ou rejette le code source Formula.
Comme Lex, Yacc procède par instanciation d’un modèle de texte en plaçant aux endroits adéquats le code réalisant l’analyse syntaxique d’après la spécification qu’on lui soumet. On peut placer des fragments de code en langage cible en certains endroits de la spécification grammaticale soumise à Yacc. Eux aussi se retrouvent “où il faut“ dans le fichier texte résultant. 9.2
Première partie d’un fichier Yacc
La première partie d’une description pour Yacc, si elle est présente, peut contenir : •
des spécifications écrites dans le langage cible, encadrées par %{ et %}, chacun de ces marqueurs étant aligné à gauche sur une ligne. Ces fragments de code se retrouveront au début du fichier synthétisé, donc globalement au corps de toutes les fonctions, et en particulier à yyparse. Il peut s’agir de déclarations ou définitions et d’inclusions de fichiers “.h“. Les déclarations correspondantes peuvent alors être utilisées dans les productions et le code de service. Ces fragments de code en langage cible sont placés par Yacc après ses propres globaux au début du texte synthétisé ;
•
la spécification des terminaux du langage par %token, (jeton) ;
•
une description du terminal courant par %union ;
•
des informations sur l’associativité et la priorité des opérateurs ;
•
la déclaration de l’axiome de la grammaire par %start ;
L’outil Yacc 213
LexYaccFormula.Yacc
Yacc
y.tab.c commande Rename
#include lex.yy.cp
y.tab.cp
LexYaccSupport.cp
y.tab.cp
compilateur CPlus
LexYaccSupport.cp.o
compilateur CPlus
y.tab.cp.o
éditeur de liens Link
code source Formula
LexYaccFormula
acceptation ou rejet
9.1Partage du travail lors de l’emploi de Yacc pour Formula •
des spécifications sur les valeurs éventuelles décrivant les notions acceptées, par exemple pour construire des graphes sémantiques. Nous en verrons des exemples au paragraphe 9.14.
Yacc recopie tout ce qui se trouve entre %{ et %} tel quel sur le fichier de sortie. Exemple de Formula
Voici la première partie d’une description pour Yacc de Formula : %{ #include <stream.h> %}
214 Compilateurs avec C++
%union
/* Description du terminal courant */ { char * fIdent; float fNombre; }
/* Les terminaux du langage */ %token %token %token %token %token
NOMBRE PAR_GAUCHE EGALE PLUS POINT_VIRGULE
IDENT PAR_DROITE VIRGULE MOINS INTERROGE
FOIS
DIVISE
/* L'axiome du langage */ %start Programme
Contrairement au cas de l’emploi de Lex décrit au paragraphe 6.3, il n’y a de pseudo-terminal FIN dans ce cas. En effet, les terminaux introduits par %token ont des numéros commençant à 257, comme on peut s’en rendre compte en faisant créer par Yacc le fichier y.tab.h, ce qui donne : #define #define #define #define #define #define #define #define #define #define #define #define #define #define
NOMBRE IDENT ITERATEUR PAR_GAUCHE PAR_DROITE EGALE VIRGULE PLUS MOINS FOIS DIVISE POINT_VIRGULE INTERROGE YYMAXTOK
257 258 259 260 261 262 263 264 265 266 267 268 269 269
Une union est en C++ une structure de données à variantes pouvant contenir l’un ou l’autre des champs qui y sont déclarés. L’union introduite par %union est un type décrivant les différents terminaux du langage, comme le dernier identificateur ou la dernière constante numérique lues. La variable yylval est déclarée implicitement de ce type %union. L’analyseur lexical yylex est responsable de placer la description du dernier terminal lu dans yylval, qui peut être consultée dans les actions et le code de service.
L’outil Yacc 215
9.3
Deuxième partie d’un fichier Yacc
La deuxième partie d’une description pour Yacc contient : •
des déclarations et/ou définitions éventuelles de constantes, variables et fonctions, le tout encadré par %{ et %} placés chacun sur une ligne, alignés à gauche. Ces déclarations peuvent être utilisées dans les productions et le code de service ;
•
les productions de la grammaire du langage pour lequel on veut synthétiser un analyseur syntaxique.
Cette deuxième partie ne peut être vide pour des raisons évidentes. Les productions s’écrivent sous la forme générale : notion_non_terminale : corps_1 { action_sémantique_1 } | corps_2 { action_sémantique_2 } | … … … … … … … … … … … … … … … … | corps_n { action_sémantique_n } ; où l’on a factorisé la tête de toute les productions définissant une même notion non terminale, et placé leurs corps respectifs à droite du “:“, séparées par une barre verticale “|“, le tout étant suivi d’un “;“.
Les corps des productions s’écrivent simplement de gauche à droite, sur plusieurs lignes si nécessaire, comme dans : Programme : |
DefinitionOuEvaluation Programme DefinitionOuEvaluation ;
Les symboles décrivant les terminaux sont distingués des non terminaux par leur déclaration dans la première partie de la description Yacc du langage. Il est aussi possible de placer dans un corps de production une chaîne entre apostrophes, jouant le rôle de terminal, sans lui donner un nom symbolique à l’aide de %token. Ainsi, on peut écrire ; Expression: Expression '+' Terme ;
au lieu de : Expression: Expression PLUS Terme;
216 Compilateurs avec C++
Tous les non non terminaux doivent être décrits par une production au moins. Il n’est pas nécessaire que les productions décrivant une même notion soient contiguës dans la spécification soumise à Yacc. Les actions sémantiques sont du code source écrit dans le langage cible, ce qui fait que Yacc traite des grammaires décorées. Les actions sémantiques permettent de compléter l’action de l’analyseur syntaxique, typiquement pour des tâches relevant de l’analyse sémantique. On en trouve différents exemples dans ce chapitre. Si l’on n’a pas spécifié l’axiome de la grammaire dans la première partie de la description par : %start un_non_terminal c’est la première production de la deuxième partie qui définit par défaut l’axiome. Exemple de Formula
Voici la deuxième partie d’une description pour Yacc de Formula : /* Les notions non terminales du langage */ Programme : | DefinitionOuEvaluation : |
DefinitionOuEvaluation Programme DefinitionOuEvaluation ; Definition Evaluation ;
Definition: EnteteDeFonction EGALE Expression POINT_VIRGULE { cout << "\n--> Définition\n\n"; } ; EnteteDeFonction : |
IDENT PAR_GAUCHE Parametres PAR_DROITE IDENT ;
: |
IDENT Parametres VIRGULE IDENT ;
Parametres
Evaluation: INTERROGE Expression POINT_VIRGULE { cout << "\n--> Evaluation\n\n"; } ; Expression : | | |
MOINS Terme Expression PLUS Terme Expression MOINS Terme Terme ;
L’outil Yacc 217
Terme : | |
Terme FOIS Facteur Terme DIVISE Facteur Facteur ;
: | | |
NOMBRE IDENT PAR_GAUCHE Expression PAR_DROITE AppelDeFonction ;
Facteur
AppelDeFonction: IDENT PAR_GAUCHE Arguments PAR_DROITE ; Arguments : |
Expression Arguments VIRGULE Expression ;
Les deux seules actions sémantiques dans cette grammaire sont : { cout << "\n--> Définition\n\n"; }
et : { cout << "\n--> Evaluation\n\n"; } qui servent à donner une information sur la notion non terminale ayant été acceptée dans ces deux cas.
On peut placer n’importe quel code dans les actions sémantiques, comme la construction d’un graphe sémantique ou la synthèse de code. 9.4
Troisième partie d’un fichier Yacc
La troisième partie de la description pour Yacc, si elle est présente, contient du code de service écrit dans le langage cible. Ce code peut contenir des fonctions utilisées par les actions sémantiques de la deuxième partie du fichier de description. Si la troisième partie est vide, on peut tout de même utiliser du code de service dans les actions sémantiques de la deuxième partie, à condition de le compiler séparément et de le lier à y.tab.c avec un éditeur de liens.
Le code de service constituant la troisième partie de la description soumise à Yacc est placé par celui-ci en dernier dans fichier texte synthétisé Exemple de Formula
La troisième partie de notre description de Formula pour Yacc contient : /* On doit fournir l'analyseur lexical */ #include "lex.yy.cp "
Cela fait que l’analyseur lexical, est lui-même synthétisé par Lex, sera compilé en même temps que y.tab.cp dans notre exemple. Comme les seules actions séman-
218 Compilateurs avec C++
tiques dans les productions sont des entrées-sorties, il n’y a pas besoin d’autre code de service. 9.5
Une librairie de support en C++ pour Yacc
Trois fonctions complémentaires au code synthétisé par Yacc doivent être fournies pour obtenir un analyseur syntaxique opérationnel. Ces fonctions, qui peuvent être placées dans le code de service, sont : •
yylex, qui est l’analyseur lexical. Un cas typique est celui où l’on inclut le source de cette fonction au moyen de : #include "lex.yy.c"
lorsqu’elle a été elle-même synthétisée par Lex ; •
main, qui est le programme principal de l’analyseur synthétisé ;
•
yyerror, qui affiche des messages décrivant les erreurs syntaxiques éventuelles.
Il est possible de ne pas préciser ces trois fonctions dans la troisième partie de la description du langage, auquel cas elles doivent être compilées séparément et liées au code objet résultant de la compilation du code produit par Yacc. Par commodité, nous avons placé dans la librairie LexYaccSupport une version de ces fonctions, suffisante pour les besoins courants. En plus de celles de la librairie LexSupport, présentées au paragraphe 6.6 pour Lex, mentionnons simplement la fonction privée : static void Erreur (char * genreDAnalyse, char * leMessage) qui est utilisée aussi bien pour les erreurs lexicales que les erreurs syntaxiques. Elle est listée en appendice, au paragraphe A.5.1.
L’interface LexYaccSupport.h contient : int void int
yywrap (); ErreurLexicale (char * leMessage); yyerror (char * leMessage);
Boolean
LexYaccAnalyser (int argc, char ** argv);
main (int argc, char ** argv); L’implantation de cette librairie dans le fichier LexYaccSupport.cp est listée en appendice, au paragraphe A.5.1.
L’outil Yacc 219
9.6
Mise sous forme postfixée
La production de code postfixé se fait en traversant le graphe sémantique de bas en haut et de gauche à droite. On traite les sous-graphes avant de traiter le nœud auquel ils sont rattachés. En pratique, on procède ainsi : •
un opérande feuille du graphe sémantique est produit tout de suite ;
•
un opérateur est écrit après que son ou ses opérandes ont été traités.
Dans le cas d’analyse d’expressions, les opérateurs sont traités par : Expression:
MOINS Terme { cout << "MOINS_UNAIRE \n"; } ;
Expression:
Expression PLUS Terme { cout << "PLUS \n"; } ;
Une Expression entre parenthèses a la même forme postfixée que si l’expression n’était pas parenthésée, d’où : Facteur:
PARENTHESE_GAUCHE Expression PARENTHESE_DROITE {} ;
L’analyse de la phrase : 13.7 - h2_so4 * (- eau * 5 + 2)
produit comme résultat : REEL IDENT IDENT ENTIER FOIS MOINS_UNAIRE ENTIER PLUS FOIS MOINS
13.7000 h2_so4 eau 5 2
--> Expression bien formée *** Analyse bien terminée ***
On voit sur cet exemple que l’on traverse le graphe sémantique sans le construire explicitement, comme cela a été indiqué au chapitre 8. Nous verrons, au paragraphe 9.14, comment construire le graphe sémantique des Expression acceptées.
220 Compilateurs avec C++
Il serait même possible de reproduire l’expression acceptée sur la sortie standard en déplaçant l’instruction d’écriture pour reproduire les opérateurs juste après leur acceptation, plutôt qu’après leur(s) argument(s), comme dans : Expression:
9.7
MOINS { cout << "MOINS_UNAIRE \n"; } Terme ;
Gestion des conflits LR par Yacc
Dans les cas de conflits “consommer/réduire“ et “réduire/réduire“, Yacc utilise des règles pour lever l’ambiguïté (disambiguating rules). Par défaut, les deux règles appliquées sont : •
en cas de conflit “consommer/réduire“, “consommer“ est choisi : on avance dans la séquence de terminaux, plutôt que de rester sur place et de réduire un corps de production en sa tête ; L’idée derrière ce choix est la même que celle qui conduit à consommer le plus possible d’un identificateur au niveau lexical.
•
en cas de conflit “réduire/réduire“, Yacc choisit de “réduire“ avec la production placée en premier dans le fichier de description.
Les règles ci-dessus permettent de traiter avec Yacc des grammaires non LALR(1), et même des grammaires ambiguës. On peut obtenir dans le fichier y.output le détail des productions et des conflits éventuels, avec l’option dite “mode verbeux" (verbose mode) de Yacc. Nous verrons que l’on peut spécifier dans une grammaire Yacc des priorités et associativités pour contrôler plus finement les conflits LALR(1).
La grammaire Formula utilisée pour illustrer Yacc au début de ce chapitre ne conduit à aucun conflit LALR(1). C’est d’ailleurs aussi le cas de celle utilisée au paragraphe 9.16 pour réaliser l’analyse syntaxico-sémantique de Formula. 9.8
Exemple de conflits ”consommer/réduire”
Avec la grammaire des expressions arithmétiques suivante : Expression:
MOINS Expression { cout << "MOINS_UNAIRE \n"; } ;
Expression:
Expression PLUS Expression { cout << "PLUS \n"; } ;
Expression:
Expression MOINS Expression { cout << "MOINS \n"; } ;
L’outil Yacc 221
Expression:
Expression FOIS Expression { cout << "FOIS \n"; } ;
Expression:
Expression DIVISE_PAR Expression { cout << "DIVISE_PAR \n"; } ;
Expression:
PARENTHESE_GAUCHE Expression PARENTHESE_DROITE {} ;
Expression:
IDENT { … } ;
Expression:
ENTIER { … } ;
Expression:
REEL { … } ;
Yacc en mode verbeux produit dans le fichier y.output : 1 Racine
:
Expression
2 Expression: MOINS Expression 3 | Expression PLUS Expression 4 | Expression MOINS Expression 5 6
| |
Expression FOIS Expression Expression DIVISE_PAR Expression
7 8 9 10
| | | |
PARENTHESE_GAUCHE Expression PARENTHESE_DROITE IDENT ENTIER REEL
State 12 contains 4 shift/reduce State 15 contains 4 shift/reduce State 16 contains 4 shift/reduce State 17 contains 4 shift/reduce State 18 contains 4 shift/reduce puis le détail des états d’analyse.
conflicts. conflicts. conflicts. conflicts. conflicts.
En gommant la hiérarchie implicite des opérateurs arithmétiques de la trilogie “Expression, Terme, Facteur“, nous nous trouvons en présence de conflits LR. L’état 15 est décrit par : state
15
Expression Expression Expression Expression Expression
-> -> -> -> ->
shift shift shift
PLUS MOINS FOIS
3 4 5
Expression Expression Expression Expression Expression
. PLUS Expression PLUS Expression . . MOINS Expression . FOIS Expression . DIVISE_PAR Expression
222 Compilateurs avec C++
shift
6
DIVISE_PAR
reduce
3
$default
On voit qu’on arrive dans l’état 15 après avoir accepté une Expression, mais qu’il y a ambiguïté sur l’action à prendre, avec des conflits “consommer/réduire“. Si le prochain terminal non encore consommé est PLUS : •
on peut légitimement consommer ce PLUS et chercher l’opérande droit au vu de la première position d’analyse listée dans cet état ;
•
on peut tout aussi légitimement faire la réduction en une Expression, car PLUS peut suivre une Expression au vu de l’ensemble des productions qui définissent cette notion non terminale.
Les mêmes remarques s’appliquent aux trois autres opérateurs arithmétiques.
Dans cette situation, le terminal PLUS provoquera une avancée (consommer) pour aller en l’état 3, et tout terminal autre qu’un des quatre opérateurs ($default) provoquera une réduction (réduire) par la production 3. C’est le cas entre autres de la parenthèse droite. En analysant le texte source : 13.7 - h2_so4 * (- eau * 5 + 2)
on obtient comme trace : REEL 13.7000 IDENT h2_so4 IDENT eau ENTIER 5 ENTIER 2 PLUS FOIS MOINS_UNAIRE FOIS MOINS --> Expression bien formée *** Analyse bien terminée ***
Le lecteur est invité à dessiner le graphe sémantique de la phrase ci-dessus, pour voir comment les opérandes ont été associés aux opérateurs. Cet exemple montre que le réglage fin d’une grammaire Yacc peut être délicat. Nous verrons qu’on peut jouer sur les priorités relatives et les associativités pour mieux contrôler le langage engendré.
L’outil Yacc 223
9.9
Exemple de conflits ”réduire/réduire”
Soit le problème d’écrire une grammaire décrivant des données textuelles extraites d’une base de données. Elles contiennent plusieurs nombres décimaux par ligne, séparés par des tabulateurs. Ce sont des clés identifiantes internes à la base de données, et le but est de faire des statistiques sur la fréquence relative des combinaisons non ordonnées de ces clés. Un exemple de telles données est : 801 805 801 805 804 801 801 801 801 801 801
801 804 805
805 805
Une première tentative conduit aux productions suivantes, présentées dans le format du fichier y.output : 1 Lignes 2
: |
UneLigne AutresLignes
3 AutresLignes: FinDeLigne UneLigne AutresLignes 4 | FinDeLigne 5 | 6 UneLigne 7 8 9 10 Nombre
: | | |
Nombre Nombre Nombre Nombre Nombre Nombre
:
ENTIER TAB
11 FinDeLigne : 12 | State State
EOLN LF EOLN
0 contains 1 reduce/reduce conflict. 2 contains 1 reduce/reduce conflict.
La description de l’état d’analyse initial 0 contient : state S'
0 ->
. Lignes
shift
6
ENTIER
reduce reduce
2 9
$eof $default
1 4 7 15
Nombre UneLigne Lignes S'
goto goto goto goto
224 Compilateurs avec C++
Les deux réductions possibles dans cet état sont celle utilisant la production 2, signifiant “Lignes est vide“, et celle utilisant la production 9, signifiant “UneLigne est vide“. Le problème dans cette spécification est clairement un excès de possibilités de faire des réductions en ne consommant aucun terminal. Pour supprimer ces conflits “réduire/réduire“, on peut supprimer la notion non terminale auxiliaire AutresLignes et utiliser la récursion à gauche, toujours préférable dans les méthodes LR : Lignes : Lignes UneLigne FinDeLigne | /* vide */ ; UneLigne – – – – –
L’ajustement d’une grammaire Yacc pour qu’elle engendre le langage désiré en présentant le moins de conflits possible est parfois long et fastidieux. 9.10
Priorités relatives et associativités
Pour éviter le problème des conflits avec la grammaire des expressions arithmétiques, on peut préciser la priorité relative et l’associativité des opérateurs arithmétiques au moyen de : %left PLUS MOINS %left FOIS DIVISE_PAR placés dans la première partie du fichier soumis à Yacc. Ces spécifications déclarent que la priorité de PLUS et MOINS est la même, et que tous deux sont moins prioritaires que FOIS et DIVISE_PAR. De plus, ces opérateurs sont associatifs à gauche.
De manière analogue, il est possible d’indiquer qu’un opérateur est associatif à droite avec la spécification : %right
un_ou_plusieurs_opérateurs
Il serait également possible de déclarer la priorité relative d’un opérateur non associatif au moyen de : %nonassoc
un_ou_plusieurs_opérateurs
Ainsi modifiée, la grammaire se prête à l’analyse du source : 13.7 - h2_so4 * (- eau * 5 + 2)
avec les même résultats que précédemment. Il est aussi possible de préciser la priorité relative d’une production en plaçant une spécification %prec à la fin de son corps, comme dans : Expression:
MOINS Expression %prec FOIS { cout << "MOINS_UNAIRE \n"; } ;
L’outil Yacc 225
qui indique que la priorité de cette production est la même que celle de l’opérateur FOIS. La grammaire ainsi modifiée produit un analyseur syntaxique qui accepte le source ci-dessus, mais produit comme trace : REEL IDENT
13.7000 h2_so4
IDENT MOINS_UNAIRE ENTIER FOIS ENTIER PLUS FOIS MOINS
eau 5 2
--> Expression bien formée *** Analyse bien terminée ***
Bien entendu, il aurait fallu spécifier : Expression: MOINS Expression %prec PLUS pour obtenir l’effet usuel en notation algébrique.
Yacc permet de contrôler les conflits LALR(1) avec des priorités relatives et des associativités. Les règles de gestion utilisées par Yacc sont : •
par défaut, la priorité relative et l’associativité d’une production sont celles de son dernier symbole, terminal ou non ;
•
une spécification %prec employée à la fin d’une production remplace les valeurs par défaut ;
•
il n’est pas nécessaire que tout symbole ait une priorité relative et une associativité ;
•
en cas de conflit “consommer/réduire“ ou “réduire/réduire“, et si le prochain terminal non encore consommé ou la production en cours d’acceptation n’ont ni priorité relative ni associativité, les règles de résolution du conflit présentées au paragraphe 9.7, sont appliquées ;
•
en cas de conflit “consommer/réduire“, si le terminal suivant et la production en cours d’acceptation ont tous deux une priorité relative et une associativité connues, Yacc applique les règles suivantes : • le conflit est résolu au profit de l’action “consommer“ ou “réduire“ qui a la plus haute priorité relative ; • si les priorités relatives sont égales, l’associativité du terminal sur lequel on pourrait faire un shift est utilisée pour résoudre le conflit : • si elle est à gauche, on fait un “réduire“ ;
226 Compilateurs avec C++
• •
si elle est à droite, on fait un “consommer“ ; sinon, Yacc signale une erreur dans la spécification.
Le lecteur pourra se convaincre assez facilement que les règles ci-dessus sont naturelles. Les conflits résolus à l’aide des informations de priorité relative et d’associativité fournies dans la grammaire n’apparaissent malheureusement pas dans le décompte produit dans le mode verbeux sur le fichier y.output. 9.11
Gestion des erreurs de syntaxe
Soit le texte source Formula : fact (n) = Si ( InfEgale (n, 0), 1 n * (fact n - 1)) )? ? fact (6)(
Il contient quatre fautes syntaxiques : •
il manque une virgule après le 1, second argument du Si ;
•
il manque une parenthèse après fact dans l’expression entre parenthèses ;
•
les “;“ suivant la définition de la fonction fact et l’évaluation de fact (6) ont été remplacés par “?“ et “(“ respectivement.
Le comportement de l’analyseur Formula produit par Yacc est le suivant : Ident Ident
fact ( n ) =
Ident Ident Ident Nombre
Nombre Ident
Si ( InfEgale ( n , 0.000000 ) , 1.000000 n
### Erreur syntaxique à la ligne 5, caractère 43, du fichier: WORK•:EXEMPLES COMPILATION:LexYaccFormula:LexYaccFormula.err près du caractère Ascii (110), |n|
L’outil Yacc 227
Le message ci-dessus est précis quant à la position, ce qui est garanti par la technique d’analyse LR, mais il ne donne aucune information sur la nature de l’erreur. De plus l’analyse s’arrête dès que ce premier message est fourni. Le rattrapage d’erreurs syntaxiques peut être contrôlé dans la spécification Yacc au moyen du pseudo-terminal error. Le comportement de l’analyseur en cas d’erreur est le suivant : •
en l’absence de error dans les productions, l’analyse s’arrête après production d’un message par yyerror ;
•
si le terminal error figure dans les productions, l’analyseur créé par Yacc désempile des états jusqu’à ce qu’il en rencontre un dans lequel error est acceptable, et fait comme si le prochain terminal non encore consommé était error. Le terminal courant est ensuite rétabli à la valeur qu’il avait lors de la détection de l’erreur.
Les désempilages des états de la pile d’analyse LR correspondent en fait à des réductions forcées par les productions correspondantes. Pour éviter des cascades de messages d’erreur après les réductions forcées par error, l’analyseur créé par Yacc reste dans un mode spécial jusqu’à ce que 3 terminaux aient été consommés et acceptés après le point où l’erreur a été détectée. Si une erreur se produit dans cet état spécial, aucun message n’est produit, et on avance simplement au prochain terminal. 9.12
Rattrapage d’erreurs syntaxiques avec Yacc
Nous pouvons modifier la grammaire Formula de la manière suivante : Definition : EnteteDeFonction EGALE Expression POINT_VIRGULE { cout << "\n--> Définition\n\n"; } | EnteteDeFonction EGALE Expression error { cout << "';' attendu après une" " définition \n"; cout << "\n--> Définition\n\n"; } ; Evaluation : INTERROGE Expression POINT_VIRGULE { cout << "\n--> Evaluation\n\n"; }
228 Compilateurs avec C++
| INTERROGE Expression error { cout << "';' attendu après une évaluation \n"; cout << "\n--> Evaluation\n\n"; } ; AppelDeFonction : IDENT PAR_GAUCHE Arguments PAR_DROITE | IDENT error { cout << "'(' attendue avant des arguments \n"; } Arguments PAR_DROITE ; Arguments : Expression | Arguments VIRGULE Expression | Arguments error { cout << "',' attendue entre des arguments \n"; } Expression ;
toutes choses restant identiques par ailleurs. Le fait d’ajouter des productions contenant le pseudo-terminal error peut créer des conflits. La compilation par Yacc de la grammaire ainsi modifiée produit le message : LexYaccFormula.Yacc contains 1 shift/reduce conflict.
alors que la grammaire originale ne contenait aucun conflit. Si l’on utilise le mode verbeux de Yacc, le fichier y.output produit contient : … … … … … … … … … State
23 contains 1 shift/reduce conflict.
… … … … … … … … … state 23 Facteur -> IDENT . AppelDeFonction -> IDENT . PAR_GAUCHE Arguments PAR_DROITE AppelDeFonction -> IDENT . error $act1 Arguments PAR_DROITE shift shift
15 7
error PAR_GAUCHE
reduce
21
$default
L’outil Yacc 229
Le comportement du nouvel analyseur sur le source erroné du paragraphe précédent est : Ident Ident
fact ( n ) =
Ident Ident Ident
Si ( InfEgale ( n ,
Nombre
0.000000 ) ,
Nombre Ident
1.000000 n
### Erreur syntaxique à la ligne 5, caractère 43, du fichier: WORK•:EXEMPLES COMPILATION:LexYaccFormula:LexYaccFormula.err près du caractère Ascii (110), |n| ',' attendue entre des arguments
Ident Ident
* ( fact n
### Erreur syntaxique à la ligne 5, caractère 53, du fichier: WORK•:EXEMPLES COMPILATION:LexYaccFormula:LexYaccFormula.err près du caractère Ascii (110), |n| ',' attendue entre des arguments Nombre
1.000000 ) )
### Erreur syntaxique à la ligne 5, caractère 59, du fichier: WORK•:EXEMPLES COMPILATION:LexYaccFormula:LexYaccFormula.err près du caractère Ascii (41), |)| ';' attendu après une définition de fonction --> Définition
Ident Nombre
) ? ? fact ( 6.000000 ) (
230 Compilateurs avec C++
--- FIN --*** Analyse bien terminée ***
On remarque au passage que la quatrième erreur syntaxique n’est pas signalée dans cet exemple. 9.13
Actions prédéfinies de Yacc
Si l’on ne désire pas laisser l’analyseur dans le mode spécial qui suit la production d’un message par yyerror parce qu’on estime que l’on s’est bien rattrapé, on peut utiliser l’instruction yyerrok dans une action sémantique pour replacer l’analyseur synthétisé par Yacc dans son mode normal. De plus, mentionnons que yyclearin permet d’oublier le terminal sur lequel on se trouvait lors de la détection d’une erreur syntaxique. On ne se replace pas sur ce terminal après avoir fait comme si le terminal courant avait été error : au lieu de cela, yylex sera appelé pour obtenir le prochain terminal. Cette possibilité est utile dans les cas où une action sémantique a elle-même consommé différents terminaux à titre de récupération d’erreurs. Dans un tel cas, on s’arrête typiquement sur un terminal déterminé par la stratégie de récupération d’erreur, et on ne veut pas reconsidérer le terminal sur lequel l’erreur a été détectée.
Le schéma d’emploi de ces deux actions sémantiques est typiquement : notion_non_terminale: … … … … … error { se synchroniser sur un terminal adéquat; yyerrorok; yyclearin; } ;
Yacc offre aussi les deux actions prédéfinies YYACCEPT et YYERROR. La première fait sortir de la fonction yyparse en retournant 0, soit en forçant l’acceptation d’une notion non terminale. La seconde fait que l’analyseur synthétisé se comporte comme si une erreur de syntaxe avait été détectée. 9.14
Valeurs retournées par les productions Yacc
Nous avons vu, au paragraphe 9.6, que les règles grammaticales Yacc peuvent être décorées par des actions. Celles-ci peuvent être utilisées pour compléter l’action de l’analyseur syntaxique, typiquement pour des tâches relevant de l’analyse sémantique. Par défaut, tous les terminaux et actions sémantiques sont décrits lors de l’analyse par une valeur entière. Il est possible de spécifier un autre type par une spécification %type.
L’outil Yacc 231
Dans l’exemple de Formula, au paragraphe 9.16, l’union : %union
/* Description du terminal courant */ { char * fIdentCourant; int fEntierCourant; float fReelCourant; DescrSemFormulaPtr fNoeudSemantiqueCourant; }
permet les spécifications de type suivantes pour les notions non terminales, en plaçant entre < et > l’identificateur de l’un des champs de l’union : %type %type %type
Expression Terme Facteur
Il est possible de combiner la déclaration du type de la valeur retournée par l’acceptation d’un terminal avec celle du terminal lui-même ou de ses caractéristiques comme dans : %token
REEL
Les actions sémantiques dans les productions sont du code écrit dans le langage cible, placé entre accolades. Elle comptent pour un symbole dans le corps, comme les terminaux et les notions non non terminales. Dans les actions sémantiques, on peut utiliser les pseudo-variables suivantes : •
$i est l’attribut du symbole terminal ou non terminal portant le numéro i dans le corps de la production, soit la valeur retournée par ce symbole ;
•
$$ permet de retourner une valeur lors de l’acceptation d’une notion non terminale comme attribut de toute la production.
En l’absence d’action sémantique affectant $$, la valeur retournée par la réduction d’un corps de production en sa tête est, par défaut, celle retournée par le premier symbole figurant dans le corps. Il y a donc dans ce cas implicitement : $$ = $1;
Yacc traite les actions sémantiques placées entre deux notions en insérant une production pour une notion non terminale anonyme se dérivant en le vide. Toute action sémantique compte donc comme un symbole dans le décompte des $i. Ainsi dans la spécification : Evaluation: INTERROGE { gAnalyseurFormula -> TraiterDebutEvaluation (); } Expression POINT_VIRGULE
232 Compilateurs avec C++
{ gAnalyseurFormula -> TraiterFinEvaluation ($3); } ;
l’attribut de Expression est accessible par $3 dans la fin de la production. Yacc contrôle que chaque terminal ou non non terminal référencé par $$ ou $i a un type connu, donc qu’il sait quel champ de l’union décrite par %union est son attribut. S’il n’est pas possible à Yacc de déterminer le type retourné par un terminal ou un non terminal, il produit un message d’erreur du genre de : File "LexYaccFormula.Yacc"; line 274 # error: $$ must be typed Pour des besoins particuliers, on peut renoncer à spécifier le %type d’une notion , et indiquer à Yacc par la notation $$ quel attribut utiliser .
Yacc traite les attributs des notions de la manière suivante : •
si l’on utilise une clause %type, l’attribut $$ décrivant la notion est le champ correspondant de l’union YYSTYPE, sinon il est du type int ;
•
yylval, du type YYSTYPE, décrit le terminal courant ;
•
yyval, du type YYSTYPE, reçoit la valeur de $$ pour la notion en cours d’acceptation ;
•
les notions acceptées en cours d’analyse voient leur attribut empilé sur une pile de valeurs du type YYSTYPE, évoluant de manière parallèle à la pile des états. Cette pile est manipulée de la manière suivante : • lors d’un “consommer“, la valeur de yylval est empilée ; • lors d’un “réduire“, c’est la valeur de yyval, décrivant la notion réduite, qui est empilée ;
•
yyvp est un pointeur dans la pile des valeurs du type YYSTYPE, utilisé comme un tableau C++, permettant d’accéder à $i comme champ de l’élément d’indice i - 1.
Dans le cas de l’exemple du paragraphe 9.16, les spécifications de type suivantes pour les notions non terminales : %type %type %type
font que le fragment d’action sémantique : $1 -> Ecrire (); delete $1;
Expression Terme Facteur
L’outil Yacc 233
devient dans le code synthétisé : yyvp [0].fNoeudSemantiqueCourant -> Ecrire (); delete yyvp [0].fNoeudSemantiqueCourant;
tandis que : { $$ = new Plus ($1, $3); }
devient : yyval.fNoeudSemantiqueCourant = new Plus ( yyvp [0].fNoeudSemantiqueCourant, yyvp [2].fNoeudSemantiqueCourant ); 9.15
Interaction entre analyses lexicale et sémantique
Comme nous l’avons mentionné, les itérateurs Formula ont un premier argument bien particulier : il s’agit d’un simple identificateur, qui est déclaré comme paramètre du type Nombre et qui n’est accessible que dans le quatrième argument de l’itérateur concerné. Les choses se compliquent un peu du fait que nous n’avons pas figé les itérateurs Somme, Produit et Pour comme des mots clés réservés. Ce sont des identificateurs prédéfinis comme Sin et Vrai, et ils sont donc sujets à masquage par redéfinition plus spécifique. Ainsi, dans le source Formula : fonct (Somme) = Somme + 3; ? Somme (Somme, 1, 3, fonct (Somme)); l’identificateur Somme apparaissant dans la définition de la fonction fonct est celui d’un paramètre du type Nombre, et non l’itérateur Somme, fonction prédéfinie à quatre paramètres.
L’évaluation ci-dessus n’est pas un modèle de bon choix d’identificateurs, mais c’est du Formula correct, et doit donc être reconnu comme tel par le compilateur ! Il en est d’ailleurs de même dans un programme Pascal comme : programme particulier; type integer = (trois, douze, writeln); var real:integer; begin real := writeln; end.
La description que nous allons faire de la grammaire sémantique de Formula à l’aide de Yacc au paragraphe suivant manque d’une certaine souplesse. Dans celle qui a été basée sur la descente récursive au chapitre 8, on voit clairement que l’ana-
234 Compilateurs avec C++
lyse syntaxique est dirigée par la description sémantique des identificateurs dans la table des symboles : switch (laFonctionPredef) { case kSomme: case kProduit: case kPour: res = InstrIteration (laFonctionPredef); break; … … … …… } // switch // … … … …… DescrSemFormulaPtr AnalyseurFormula :: InstrIteration ( GenreFonctPredef laFonctionPredef ) { DescrSemFormulaPtr res = NULL; char * nomDeLIndice = "Indice inconnu"; if (fTerminal != IDENT) ErreurSyntaxique ("IDENT attendu comme indice d'iteration"); else nomDeLIndice = SauvegarderChaine ( fAnalyseurLexical -> DernierIdentLu () ); // car on ne s'en sert qu'apres Avancer (); … … … … … return res; } // AnalyseurFormula :: InstrIteration
Il n’y a pas d’équivalent direct possible à cette grammaire dynamique, dépendant d’une structure de données à l’exécution, dans Yacc. Cet outil nous oblige à aiguiller le choix des productions par de pures considérations lexicales. Nous devons dépister les itérateurs Formula Somme, Produit et Pour au niveau lexical à l’aide de la table des symboles, pour aiguiller l’analyse syntaxique dans les productions Yacc. Il y a donc interaction entre les niveau lexical et sémantique pour pouvoir faire l’analyse syntaxique, ce qui peut paraître choquant. Cette interaction est faite au moyen d’une spécification Lex particulière, utilisée en complément à la description pour Yacc de Formula présentée au paragraphe suivant. La partie de cette spécification qui diffère de celle du paragraphe 6.3, est : {ident} (yytext);
{ yylval.fDescrIdent.fNom = SauvegarderChaine
L’outil Yacc 235
Boolean
iterateur = gAnalyseurFormula -> IdentEstUnIterateur ( yylval.fDescrIdent ); return Decrire (iterateur ? ITERATEUR : IDENT); }
La décision entre identificateur et itérateur est faite par : Boolean AnalyseurFormula :: IdentEstUnIterateur (DescrIdent & laDescrIdent) { DictionnairePtr leDictionnaire; laDescrIdent.fIdent = fPileDeDictionnaires.RechercherLeNom ( laDescrIdent.fNom, leDictionnaire ); if ( laDescrIdent.fIdent != NULL && laDescrIdent.fIdent -> GenreIdent () == kIdentFonctPredef ) { FonctPredefPtr
laFonctPredef = FonctPredefPtr (laDescrIdent.fIdent);
switch (laFonctPredef -> Fonction ()) { case kSomme: case kProduit: case kPour: return true; break; default: return false; } // switch } // if else return false; } // AnalyseurFormula :: IdentEstUnIterateur 9.16
Une grammaire sémantique Yacc de Formula
Nous enrichissons la grammaire Yacc présentée au début de ce chapitre, pour faire l’analyse sémantique de Formula de manière analogue à celle réalisée dans le chapitre 8. Dans cette dernière, la descente récursive utilisait des passages de paramètres dans les fonctions acceptant les différentes notions non terminales pour gérer la sémantique, mais cela n’est pas possible dans les productions Yacc. Nous devons utiliser des variables globales pour communiquer des informations en entrée au notions Yacc qui en ont besoin.
236 Compilateurs avec C++
Afin d’alléger l’écriture de la grammaire, nous avons choisi d’avoir une variable globale unique gAnalyseurFormula, pointeur sur une instance du type AnalyseurFormula, à laquelle nous envoyons des messages de la forme Traiter… pour réaliser les traitements sémantiques. En revanche, le retour d’une valeur lors de l’acceptation d’une notion non terminale est tout à fait analogue à ce qui se passe dans la descente récursive. Le lecteur notera le traitement syntaxique différencié des appels aux fonctions d’itération prédéfinies de Formula et de ceux aux autres fonctions, prédéfinies ou non. Cela est rendu possible par la spécification Lex présentée au paragraphe précédent. La notion IdentOuIterateur, définie par : IdentOuIterateur : IDENT | ITERATEUR ;
est nécessaire pour pouvoir accepter l’en-tête de fonction utilisateur : fonct (Somme) = …;
dans lequel l’analyse lexicale voit Somme comme l’itérateur prédéfini, avant qu’il ne soit déclaré comme paramètre de la fonction fonct au niveau sémantique. La même remarque s’applique à l’évaluation : ? Somme (Somme, 1, 3, fonct (Somme));
La description Yacc décorée de Formula est présentée en appendice, au paragraphe A.5.2. 9.17
Analyse sémantique de Formula avec Yacc
En compilant avec le compilateur décrit au paragraphe précédent le source : fonct (Somme) = Somme + 3; ? fonct (Pi + 11);
on obtient comme résultat : On empile le dictionnaire 'Idents Prédéfinis' On empile le dictionnaire 'Fonctions Utilisateur' Ident fonct ( Iterateur Somme ### Avertissement sémantique: la définition du parametre formel 'Somme' masque une autre declaration terminal courant= 'Somme' ### )
L’outil Yacc 237
On empile le dictionnaire 'fonct' = Ident Somme + Nombre 3.000000 ; On désempile le dictionnaire 'fonct' On purge le DictionnaireArbre 'fonct', contenant: Le paramètre formel 'Somme': 'Nombre' --> Définition: La fonction utilisateur 'fonct': '(Nombre) -> Nombre' ? On empile le dictionnaire 'Evaluation_1' Ident
fonct (
On construit une DescrAppelFonctUtilisateur pour fonct On empile une DescrAppelFonct Ident Nombre
Pi + 11.000000 )
On désempile une DescrAppelFonct ; --> Evaluation: expression -> Nombre On désempile le dictionnaire 'Evaluation_1' On purge le DictionnaireArbre 'Evaluation_1', vide On désempile le dictionnaire 'Fonctions Utilisateur' On purge le DictionnaireArbre 'Fonctions Utilisateur', contenant: On purge le DictionnaireArbre 'fonct', contenant: Le paramètre formel 'Somme': 'Nombre' La fonction utilisateur 'fonct': '(Nombre) -> Nombre' On désempile le dictionnaire 'Idents Prédéfinis' On purge le DictionnaireArbre 'Idents Prédéfinis', contenant: La fonction prédéfinie 'ArcTan': 'Nombre': non utilisé(e) … … … … … … … … … … … … La constante prédéfinie 'Pi': 'Nombre' … … … … … … … … … … … … La constante prédéfinie 'Vrai': 'Booleen': non utilisé(e) 9.18
Exercices
9.1 : Analyseur Lex et Yacc pour Markovski (facile). Ecrire à l’aide des outils Lex et Yacc un analyseur lexical et syntaxique Markovski.
238 Compilateurs avec C++
L’outil Yacc 239
240 Compilateurs avec C++
Page blanche
Chapitre
10
10 Évaluation et paramètres
Nous avons vu, au chapitre 8, comment on peut construire le graphe sémantique des expressions du langage que l’on compile. Avant de passer à la structure de l’environnement d’exécution et à la synthèse du code, nous consacrons le présent chapitre à la question de l’évaluation des expressions ainsi qu’aux différents modes de passage des paramètres dans les appels de fonctions. Les paramètres formels des fonctions sont définis dans leur en-tête. Les arguments d’appel sont fournis dans les appels à ces fonctions. Dans les langages n’admettant pas de paramètres facultatifs, chaque argument d’appel doit correspondre à un paramètre formel. 10.1
Passage de paramètres courants
Dans les langages les plus répandus, on rencontre : •
le passage par valeur (call by value) : le paramètre formel est une variable locale initialisée par la valeur de l’argument d’appel ;
•
le passage par référence (call by reference) : le paramètre formel est une référence sur la variable fournie dans l’appel. Tout emploi de la référence est implicitement déréférencé, et c’est toujours la variable fournie par l’appeleur qui est utilisée. Le paramètre formel est donc dans ce cas un alias permettant d’accéder à une variable existant par ailleurs, mais sous un autre nom ;
242 Compilateurs avec C++
•
le passage par nom (call by name) : tout se passe comme si le paramètre formel était remplacé textuellement par l’argument d’appel.
•
le passage par valeur-résultat (call by value-result), qui est proche du passage par référence : le paramètre formel est une variable locale initialisée par la valeur de la variable fournie comme argument d’appel, et sa valeur est recopiée dans cette même variable juste avant le retour à l’appeleur. La différence réside dans le fait que dans le corps de la fonction, c’est la variable locale qui est manipulée, sans effet sur la variable argument d’appel. Cela est important dans un contexte de programmation concurrent ;
•
le passage par besoin (call by need) est une optimisation du passage par nom. On ne calcule dans ce cas qu’une fois la valeur de l’argument d’appel si elle est nécessaire. Ce mode de passage lié à l’évaluation paresseuse, présentée au paragraphe 10.4. Il n’y a pas de restriction particulière comme celle rencontrée dans le passage par référence.
Le passage par nom joue un rôle théorique très important, comme nous le verrons au paragraphe 10.3. On en trouve un exemple au paragraphe suivant. Algol 60 n’offrait que les passages par nom et par valeur. On devait donc réaliser le passage par référence dans ce langage à l’aide du passage par nom, ce qui nuisait à l’efficacité. Nous verrons pourquoi plus loin. Le passage par nom est très différent de la macro-expansion réalisée par le pré-processeur C++, par exemple. Il n’y a dans ce dernier cas qu’un simple réécriture lexicale, sans aucun contrôle ni syntaxique, ni sémantique.
Le passage par valeur est très répandu, c’est en particulier le seul disponible en C. C’est celui qui est utilisé par Pascal en l’absence du mot clé var dans la spécification du paramètre formel. Le passage par référence est lui aussi répandu. C’était le seul disponible en Fortran initialement. La présence du mot clé var le caractérise en Pascal.
Le passage par référence est une optimisation du passage par nom dans lequel l’argument d’appel doit être une variable, et non pas une expression quelconque. Le passage par valeur-résultat est d’emploi relativement récent. Il a été préféré en Ada au passage par référence pour des questions de propreté sémantique. Il est spécifié par inout dans ce langage. Le passage par adresse tel qu’on le trouve en C++ n’est pas un mode de passage en soi : il s’agit simplement du passage par valeur d’une adresse. Cela fait que
Évaluation et paramètres 243
l’on peut modifier la variable ou même la zone en mémoire dont on a reçu l’adresse, avec une déréférenciation explicite comme dans : void carre (int * n) { *n = *n * *n; }
/* C ou C++ */
La différence avec le passage par référence est que dans ce dernier cas, il y déréférenciation implicite de la référence reçue en argument : void carre (int & n) { n = n * n; }
// C++ seulement
On remarque que l’écriture avec passage par référence en C++ est l’équivalent direct de ce qu’on pourrait écrire en Pascal, par exemple. 10.2
Exemple de passage par nom
La définition d’Algol 60 précise que dans le passage par nom, tout se passe comme si le paramètre formel était remplacé textuellement par le fragment de code qui est fourni comme argument d’appel, éventuellement entouré de parenthèses pour la correction syntaxique selon le contexte d’emploi du paramètre formel. Comme les implantations d’Algol 60 deviennent rares, voici un exemple en Simula 67. Ce langage est une extension presque stricte d’Algol 60, et a été le premier historiquement à permettre la programmation orientée objets à l’aide de classes. Simula 67 ne diffère pas d’Algol 60 pour ce qui est du passage par nom.
Voici à titre d’exemple l’équivalent du programme Formula : ? Somme (i, 1, 5, i * i);
écrit avec un passage par nom en Simula 67 : begin comment /* exemple de passage par nom en Algol-60 */; integer procedure somme (indice, borneInf, borneSup, expression); name indice, comment /* un passage par référence suffirait ici */; expression; comment /* le passage par nom sur lequel on joue */; integer integer integer integer
indice; borneInf; borneSup; expression;
begin integer accu; accu := 0; indice := borneInf;
244 Compilateurs avec C++
while indice <= borneSup do begin accu := accu + expression; indice := indice + 1; end while; somme := accu; end somme; begin comment /* bloc interne, pour le plaisir */; integer i; outint (somme (i, 1, 5, i * i), 2); outimage; end end
Le résultat imprimé par l’exécution de ce programme est : 55
On joue dans cet exemple sur la ré-évaluation du paramètre expression passé par nom à chaque fois que sa valeur est nécessaire dans le corps de somme. Dans le cas de l’appel ci-dessus, c’est toute l’expression i * i qui est ainsi ré-évaluée à chaque fois. Un paramètre passé par nom est évalué dans le contexte de l’appel à la fonction, et non pas dans le contexte du corps de cette fonction. variable i est modifiée “à distance“ par l’instruction indice := indice + 1 à chaque itération, car tout se passe comme si l’on exécutait en fait i := i + 1. C’est donc un i incrémenté de 1 à chaque fois dont on calcule le carré. Un passage par référence aurait suffi pour indice : La
cela illustre le fait que ce mode de passage est une optimisation du passage par nom, permettant de faire la même chose plus efficacement. On appréciera la concision du programme Formula par rapport à celui écrit en Simula 67, grâce à la prédéfinition de la fonction Somme.
Le cas de Pascal et Modula-2, qui permettent de passer le nom d’une fonction ou procédure en paramètre, est une restriction du passage par nom général tel que nous le voyons ici. 10.3
Exemple de non-terminaison d’une évaluation
On appelle stratégie d’enchaînement d’opérations la manière de choisir quelle opération est effectuée à chaque étape. Les différents modes passages de paramètres conduisent à des stratégies distinctes, et qui ne sont pas équivalentes : •
la stratégie “passage par valeur“ pour l’évaluation des appels de fonction est incomplète : elle peut ne jamais se terminer, bien que l’expression soit
Évaluation et paramètres 245
calculable. Cela se traduit par ce qu’on appelle usuellement une “récursion infinie“ ; •
la stratégie “passage par nom“ est complète : elle garantit de trouver la valeur de l’expression à évaluer si elle est calculable ;
•
l’évaluation paresseuse est une optimisation intéressante du passage par nom qui ne souffre pas de l’incomplétude du passage par valeur. Elle est présentée au paragraphe suivant.
L’évaluation d’une expression conduit à la traversée d’un arbre des appels. Le cas de non-terminaison correspond à un arbre ayant une branche infinie. Voici un exemple qui peut être exécuté avec un passage par nom ou par besoin, mais pas avec un passage par valeur. Le “truc“ est que le second argument de funct n’est pas calculable dans l’évaluation que nous allons faire, mais il n’est pas utilisé pour calculer la valeur de cette fonction : il apparaît dans le troisième argument d’un Si, qui est non stricte. Il ne sera donc jamais évalué avec un passage par nom ou par besoin, tandis que le passage par valeur va conduire à une séquence infinie d’appels de fonction. Le code source Formula de cet exemple est : blark (n) = 1 + blark (n + 1); funct (m, n) = Si ( Inf (m, 100), m + 12, n * 4 ); ? funct (3 + 5, blark (7)); Evaluation par nom
Avec un passage de paramètres par nom le résultat obtenu pour l’évaluation ci-dessus est : Valeur: >>> Appel à 'funct' (contexte 1) avec comme paramètres: 1: par nom: Plus 3.000000 5.000000 2: par nom: blark 7.000000 ... On évalue le par nom 'm', no 1, contexte 1 ... la valeur est (8.000000 , vrai)
246 Compilateurs avec C++
... On évalue le par nom 'm', no 1, contexte 1 ... la valeur est (8.000000 , vrai) <<< Résultat = (20.000000 20.000000 -----------------
, vrai)
L’expression funct (3 + 5, blark (7)) est donc calculable et vaut 20. On voit que les arguments sont évalués à chaque fois que leur valeur est nécessaire, mais que blark (7) n’est jamais évalué ! Evaluation par valeur
Avec passage des paramètres par valeur la même évaluation donne : Valeur: >>> Appel à 'blark' (contexte 2) avec comme paramètres: 1: par valeur: (7.000000 , vrai) ... On consulte le par valeur 'n', no 1, contexte 2 ... la valeur est (7.000000 , vrai) >>> Appel à 'blark' (contexte 3) avec comme paramètres: 1: par valeur: (8.000000 , vrai) ... On consulte le par valeur 'n', no 1, contexte 3 ... la valeur est (8.000000 , vrai) >>> Appel à 'blark' (contexte 4) avec comme paramètres: 1: par valeur: (9.000000 , vrai) ... On consulte le par valeur 'n', no 1, contexte 4 ... la valeur est (9.000000 , vrai) … … … … … … … … ### ON COUPE LES FRAIS ###
Le problème ici est que l’on veut évaluer blark(7), dont on n’a pas besoin en fait pour obtenir la valeur de l’expression à évaluer. Comme chaque appel à blark conduit à un autre tel appel, sans qu’aucune possibilité existe d’arrêter cette quête sans fin, on finit par tomber sur le garde-fou mis en place pour limiter le nombre d’appels à AppelDeFonction :: Evaluer. On voit dans cet exemple une récursion infinie, qui se traduit par une vague d’appels de plus en plus indentés vers la droite, sans vague de retours de fonction.
C’est l’empressement à vouloir évaluer tous les arguments d’appels avant d’entrer dans le corps d’une fonction, même s’ils ne seront pas nécessaires, qui rend la stratégie “passage par valeur“ incomplète.
Évaluation et paramètres 247
10.4
Evaluation paresseuse et passage par besoin
Une manière de bénéficier des avantages des stratégies d’évaluation par valeur et par nom est le passage par besoin (call by need), qui consiste à : •
passer à la fonction appelée les arguments non évalués, comme dans le passage par nom, pour garantir l’obtention de la valeur de l’expression si elle est calculable ;
•
n’évaluer la valeur du paramètre que la première fois qu’elle est nécessaire, en s’en rappelant pour les besoins ultérieurs, pour garantir une certaine efficacité comme dans le cas du passage par valeur.
C’est cette seconde caractéristique qui donne le nom d’évaluation paresseuse (lazy evaluation) à cette stratégie : on ne se précipite pas à tout évaluer avant de savoir si on en a vraiment besoin.
Le passage par besoin est une optimisation du passage par nom qui se comporte essentiellement comme un passage par valeur, en évitant le piège de la non terminaison dans certains cas. Avec un passage de paramètres par besoin, le résultat obtenu pour l’évaluation du paragraphe précédent est : Valeur: >>> Appel à 'funct' (contexte 1) avec comme paramètres: 1: par besoin non encore évalué: Plus 3.000000 5.000000 2: par besoin non encore évalué: blark 7.000000 ... On évalue le par besoin 'm', no 1, contexte 1 ... la valeur est (8.000000 , vrai) ... On consulte le par besoin 'm', no 1, contexte 1 ... la valeur est (8.000000 , vrai) <<< Résultat = (20.000000 , vrai) 20.000000 -----------------
L’expression funct (3 + 5, blark (7)) est donc calculable de manière paresseuse. On voit que l’on n’évalue qu’une fois au plus les arguments si l’on a besoin de leur valeur. Là encore, blark (7) n’est jamais évalué. Intérêt du passage par besoin
L’évaluation paresseuse permet de manipuler des structures de données infinies. Cela se fait non pas en les construisant explicitement, bien sûr, mais en construisant chaque élément lorsqu’on a besoin de lui, ce qui fait qu’on traverse ces structures.
248 Compilateurs avec C++
C’est ainsi que l’on peut manipuler des listes infinies, comme celle de tous les entiers, qui pourrait être retournée dans une version de Formula enrichie pour le traitement de listes par la fonction : listeEntiers (debut) = Cons ( debut, listeEntiers (debut + 1) ); ? Car (Cdr (listeEntiers (0)));
Dans ce cas, l’opérateur Cons devrait être non strict, c’est -à-dire qu’il ne devrait pas évaluer ses arguments. Au lieu de chercher à construire tout de suite la liste potentiellement infinie, il ne retournerait que le moyen de construire les éléments successifs de la liste en question lorsqu’ils deviendraient nécessaires. C’est cette promesse (promise) qui serait réalisée - exécutée - par les fonctions Car et Cdr pour produire leur résultat. On pourrait ainsi imprimer les 7 premiers éléments d’une telle liste sans problème. En revanche, le renversement d’une liste infinie est condamné à échouer, ce qui illustre un cas d’expression non calculable !
Un des buts derrière tout cela est la programmation sans variables, dans laquelle on se contente d’appliquer des fonctions à des structures de données. Le lecteur intéressé trouvera de plus amples détails dans [Abelson & Sussman 84], qui introduit le terme de flot (stream) pour ces listes infinies construites de manière paresseuse. L’exercice 8.1 consiste à greffer le traitement des listes sur Formula, de manière à gérer les listes infinies. 10.5
Des graphes sémantiques à la forme postfixée
La forme postfixée d’une expression s’obtient par la traversée de bas en haut et de gauche à droite de son graphe sémantique. Dans le cas de Formula, il nous suffit d’enrichir les descriptions sémantiques en ajoutant une méthode PostFixer à la classe DescrSemFormula, présentée au paragraphe 8.15, ainsi qu’à toutes ses descendantes. Pour la classe DescrSemFormula elle-même, ce est fait au moyen de : virtual void
PostFixer () = 0; // virtuelle pure
La traversée remontante se fait par des appels récursifs à PostFixer, dans l’ordre adéquat. Par exemple, tous les cas de fonctions prédéfinies Formula ayant
Évaluation et paramètres 249
zéro, un ou deux arguments sont traités respectivement par héritage des méthodes suivantes : void OperateurZeroaire :: PostFixer () { cout << form ("%s\n", "fNom"); } void OperateurUnaire :: PostFixer () { fOperande -> PostFixer (); cout << form ("%s\n", fNom); } void OperateurBinaire :: PostFixer () { fOperandeGauche -> PostFixer (); fOperandeDroit -> PostFixer (); cout << form ("%s\n", fNom); }
Les feuilles simples des graphes sémantiques sont traitées par exemple par : void ValeurNombre :: PostFixer () { cout << form ("%-16.6f\n", fValeurNombre); } void ValeurLogique :: PostFixer () { cout << form ("%s\n", fValeurLogique ? "vrai" : "faux"); }
Les appels aux fonctions définies dans le source compilé sont traités par : void AppelDeFonction :: PostFixer () { short nombreDeParametres = fFonctUtilisateur -> ListeParams () -> NombreDeParametres (); for (short i = 0; i < nombreDeParametres; ++ i) fArgumentsDAppel [i] -> PostFixer (); cout << form ("%s\n", fFonctUtilisateur -> Nom ()); }
La fonction prédéfinie Si est mise en forme postfixée par la méthode : void Si :: PostFixer () { fCondition -> PostFixer (); fValeurSiVrai -> PostFixer (); fValeurSiFaux -> PostFixer (); cout << form ("%s\n", "Si"); }
Enfin, les fonctions d’itération Formula sont traitées au moyen de : void Iteration :: PostFixer () { fBorneInf -> PostFixer (); fBorneSup -> PostFixer ();
250 Compilateurs avec C++
cout << form ("indice %s\n", fIndice -> Nom ()); fExpression -> PostFixer (); cout << form ("%s\n", fTypeIteration); }
Comme on le voit, la mise sous forme postfixée à partir du graphe sémantique construit explicitement en mémoire est très facile. 10.6
Evaluation des graphes sémantiques Formula
Notre but en décidant d’implanter Formula était de pouvoir exécuter les programmes écrits dans ce langage. Cela est possible en évaluant directement les expressions sous leur forme “graphe sémantique“, ainsi que nous allons le montrons cidessous. Comme Formula manipule des valeurs numériques et logiques, nous a utilisons le type ValeurFormula défini par : typedef
float
union ValeurFormula { typedef
Nombre;
ValeurFormula
void
Ecrire ();
Nombre Boolean };
fNombre; fBooleen;
* ValeurFormulaPtr;
On peut se demander pourquoi on ne distingue pas concrètement les deux types de valeur : l’union ci-dessus peut en effet contenir un Nombre ou un Boolean, mais on ne sait pas lequel des deux ! C’est le typage statique du langage Formula et les contrôles faits lors de l’analyse sémantique qui font que les opérations dans les graphes sémantiques manipulent des valeurs dont le type est nécessairement correct, sans qu’on doive gérer ces types dynamiquement. Dans l’implantation des langages typés dynamiquement, on est conduit à être beaucoup plus laxiste lors de l’analyse sémantique, ce qui est le cas, par exemple, en Lisp. Dans ce dernier langage, une variable n’a pas de type statique : seul le type de la valeur qu’elle contient dynamiquement fait foi. Une variable donnée peut contenir successivement un entier et une liste au cours de sa vie. En revanche, il faut que les opérations sur de telles variables soit conformes au type de leur valeur courante, ce qui implique des tests dynamiques de type. L’évaluation des graphes sémantiques s’appuie sur des contextes d’évaluation passés en paramètres aux méthodes Evaluer.
Évaluation et paramètres 251
La technique employée est très similaire à celle employée dans les auto-interprètes Lisp décrits dans la littérature, comme dans [Winston & Horn 84].
Les méthodes Evaluer sont employées de manière polymorphique, à partir ce celle déclarée au niveau de la classe DescrSemFormula par : virtual ValeurFormula
Evaluer (ContexteEvalPtr leContexte) = 0; // virtuelle pure Le code des méthodes Evaluer procède bien sûr à l’évaluation des opérateurs de manière postfixée, implantant ainsi la sémantique propre de chaque type de nœud du graphe sémantique. Contexte d’évaluation
Un contexte d’évaluation est une association entre les arguments d’un appel de fonction, repérés par leur numéro d’ordre, et leur valeur. La classe qui décrit un contexte d’évaluation est déclarée par : class ContexteEval { typedef ContexteEval
* ContexteEvalPtr;
public: ContexteEval ( EvalArgPtr long ContexteEvalPtr EvalArgPtr
* BlocDEvaluations ();
long long
Indentation (); NumeroContexte ();
ContexteEvalPtr
ContexteContenant ();
* leBlocDEvaluations, lIndentation, leContexteContenant );
protected: EvalArgPtr
* fBlocDEvaluations;
long
fIndentation; // pour la trace d'exécution fNumeroContexte; // pour la trace d'exécution
long
//
un tableau
ContexteEvalPtr fContexteContenant; }; // ContexteEval
Pour les évaluations au niveau principal, on utilise un contexte vide : static ContexteEval
gContexteEvalVide (NULL, 0, NULL); // il a le numéro 0
static ContexteEvalPtr
gContexteEval = & gContexteEvalVide;
Seuls les appels aux fonctions définies dans le source compilé créent de nouveaux contextes d’évaluation. Les autres méthodes Evaluer ne font qu’utiliser un
252 Compilateurs avec C++
pointeur sur contexte d’évaluation reçu en paramètre, comme on le voit au paragraphe suivant. La gestion de l’évaluation des arguments des fonctions utilisateur est confiée aux sous-classes de la classe EvalArg, détaillée au paragraphe 10.8. 10.7
Evaluation des graphes sémantiques simples
Voici quelques exemples illustrant la manière dont est réalisée l’évaluation directe des graphes sémantiques de Formula dans les cas simples. L’évaluation des appels aux fonctions définies dans le code source compilé et de leurs arguments fait l’objet des paragraphes suivants : ValeurFormula ValeurInconnue :: Evaluer (ContexteEvalPtr leContexte) { cout << "\n### ERREUR: ON NE PEUT EVALUER UNE VALEUR INCONNUE ###\n"; exit (18); } ValeurFormula ValeurNombre :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res; res.fNombre = fValeurNombre; return res; } ValeurFormula ValeurLogique :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res; res.fBooleen = fValeurLogique; return res; } ValeurFormula LireNombre :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res; res.fNombre = FLireFlottant ("Veuillez taper un nombre: "); return res; } ValeurFormula Non :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res; res.fBooleen = ! fOperande -> Evaluer (leContexte).fBooleen; return res; } ValeurFormula EcrireBooleen :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res;
Évaluation et paramètres 253
cout << form ( "%s", fOperande -> Evaluer (leContexte).fBooleen ? "vrai" : "faux" ); return res; }
Le cas des opérateurs logiques binaires peut se prêter à une optimisation par court-circuit : on arrête l’évaluation si le résultat peut être déterminé en fonction du seul premier argument, sans évaluer le second. Un opérateur à court-circuit n’est pas strict puisque le second argument n’est pas toujours évalué. En Ada le court-circuit est obtenu avec les opérateurs spécifiques and_then (et alors) et or_else (ou sinon), distincts de and (et) et or (ou). En C++, les opérateurs && et || doivent être implantés ainsi. On peut ainsi écrire : if (unPointeur && unPointeur -> unChamp > 9) action;
Si unPointeur est égal à 0 on n’évalue pas le second argument unPointeur -> unChamp > 9 , car “faux et …“ est toujours faux en logique. Si unPointeur n’est pas nul on peut valablement accéder à unChamp par la notation pointée. Voici comment évaluer le Et de Formula sans court-circuit : ValeurFormula Et :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res; Boolean Boolean
valeurOperandeGauche = fOperandeGauche -> Evaluer (leContexte).fBooleen; valeurOperandeDroit = fOperandeDroit -> Evaluer (leContexte).fBooleen;
res.fBooleen = valeurOperandeGauche && valeurOperandeDroit; return res; } // Et :: Evaluer
Pour réaliser le court-circuit on peut s’appuyer sur celui réalisé par l’opérateur && de C++, ce qui donne tout simplement : ValeurFormula Et :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula res; res.fBooleen = fOperandeGauche -> Evaluer (leContexte).fBooleen && fOperandeDroit -> Evaluer (leContexte).fBooleen; return res; } // Et :: Evaluer
254 Compilateurs avec C++
La division des nombres doit prendre garde au cas où le diviseur est nul : ValeurFormula DivisePar :: Evaluer (ContexteEvalPtr leContexte) { Nombre valeurOperandeDroit = fOperandeDroit -> Evaluer (leContexte).fNombre; if (valeurOperandeDroit == 0) { cout << "\n### ERREUR: division par 0! ###\n"; exit (17); } ValeurFormula
res;
res.fNombre = fOperandeGauche -> Evaluer (leContexte).fNombre / valeurOperandeDroit; return res; } // DivisePar :: Evaluer
Les cas de séquencement se règlent très simplement par : ValeurFormula Seq :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula valeurGauche = fOperandeGauche -> Evaluer (leContexte); ValeurFormula
valeurDroite = fOperandeDroit -> Evaluer (leContexte);
return valeurDroite; }
et : ValeurFormula Seq1 :: Evaluer (ContexteEvalPtr leContexte) { ValeurFormula valeurGauche = fOperandeGauche -> Evaluer (leContexte); ValeurFormula
valeurDroite = fOperandeDroit -> Evaluer (leContexte);
return valeurGauche; }
On voit qu’on “jette“ simplement la valeur de celui des deux arguments dont la valeur n’est pas retournée comme valeur de la séquence. Enfin, la fonction prédéfinie Si est évaluée très naturellement par : ValeurFormula Si :: Evaluer (ContexteEvalPtr leContexte) { return (fCondition -> Evaluer (leContexte).fBooleen) ? fValeurSiVrai -> Evaluer (leContexte) : fValeurSiFaux -> Evaluer (leContexte); }
Évaluation et paramètres 255
On voit bien là qu’un seul parmi les deuxième et troisième arguments est évalué, confirmant le caractère non strict de la conditionnelle. 10.8
Evaluation des arguments d’appel
Pour gérer l’évaluation des paramètres formels en Formula, nous enrichissons la classe DescrParam avec la méthode : virtual EvalArgPtr
CommentEvaluer ( long DescrSemFormulaPtr ContexteEvalPtr // virtuelle pure
leNumeroContexte, laValeur, leContexteEval ) = 0;
Plutôt que d’augmenter le langage par des indications du mode de passage de chaque paramètre, nous avons préféré fixer un mode unique pour tous les paramètres au niveau de l’analyseur sémantique avec le champ : GenrePassageParams
fPassageParams;
La description de la manière d’évaluer les arguments eux-mêmes est faite par la classe abstraite EvalArg : class EvalArg { typedef EvalArg
* EvalArgPtr;
public: EvalArg ( char short long
* leNom, leNumero, leNumeroContexte );
long
NumeroContexte ();
virtual void
Ecrire (short lIndentation);
virtual ValeurFormula Evaluer (short lIndentation) = 0; // virtuelle pure protected: char short long }; //
* fNom; fNumero; fNumeroContexte; EvalArg
Les modes de passage disponibles en Formula sont décrits par des sous-classes concrètes de EvalArg, illustrées dans les paragraphes suivants.
256 Compilateurs avec C++
10.9
Evaluation des arguments par valeur
Le moyen d’évaluer l’argument d’appel correspondant à une paramètre passé par valeur est décrit par la classe EvalParValeur, sous-classe de EvalArg. En plus des champs hérités de cette dernière, on y trouve le champ fValeur du type ValeurFormula, utilisé pour stocker la valeur de l’argument, puisque ce dernier est évalué avant d’entrer dans le corps de la fonction. La méthode Evaluer correspondante est simplement définie par : ValeurFormula EvalParValeur :: Evaluer (short lIndentation) { Indenter (lIndentation); cout << form ( "... On consulte le par valeur '%s', no %d, contexte %d\n", fNom, fNumero, fNumeroContexte ); Indenter (lIndentation); cout << "... la valeur est "; fValeur.Ecrire (); cout << "\n"; return fValeur; }
L’essentiel du corps de cette méthode est destiné à la trace d’exécution, que l’on voit ci-dessous. Exemple
Soit le fichier source Formula : fact (n) = Si ( InfEgale (n, 0), 1, n * fact (n - 1) ); ? fact (2);
En le compilant avec passage des paramètres par valeur, on obtient : --> Définition: La fonction utilisateur 'fact': '(Nombre) -> Nombre' --> Evaluation: expression -> Nombre Valeur: >>> Appel à 'fact' (contexte 1) avec comme paramètres: 1: par valeur: (2.000000 , vrai) ... On consulte le par valeur 'n', no 1, contexte 1 ... la valeur est (2.000000 , vrai) ... On consulte le par valeur 'n', no 1, contexte 1
Évaluation et paramètres 257
... la valeur est (2.000000 , vrai) ... On consulte le par valeur 'n', no 1, contexte 1 ... la valeur est (2.000000 , vrai) >>> Appel à 'fact' (contexte 2) avec comme paramètres: 1: par valeur: (1.000000 , vrai) ... ... ... ... ... ...
On la On la On la
consulte le par valeur 'n', no 1, contexte 2 valeur est (1.000000 , vrai) consulte le par valeur 'n', no 1, contexte 2 valeur est (1.000000 , vrai) consulte le par valeur 'n', no 1, contexte 2 valeur est (1.000000 , vrai)
>>> Appel à 'fact' (contexte 3) avec comme paramètres: 1: par valeur: (0.000000 , faux) ... On consulte le par valeur 'n', no 1, contexte 3 ... la valeur est (0.000000 , faux) <<< Résultat = (1.000000 , vrai) <<< Résultat = (1.000000 , vrai) <<< Résultat = (2.000000 , vrai) 2.000000 -----------------
On voit bien sur cet exemple la vague des appels de plus en plus indentés vers la droite, puis la vague des retours de fonction vers la gauche. L’emploi des paramètres dans le corps des fonctions conduit dans le cas du passage par valeur à la simple consultation des valeurs préévaluées lors de l’entrée dans la fonction. 10.10
Evaluation des arguments par nom
Le moyen d’évaluer l’argument d’appel correspondant à une paramètre par nom est décrit par la classe EvalParNom, sous-classe de EvalArg. En plus des champs hérités de cette dernière, elle définit : •
le champ fGrapheSemantique, pointeur une instance de DescrSemFormula ou de l’une de ses descendantes, décrivant l’argument d’appel. Il n’est pas évalué avant d’entrer dans le corps de la fonction appelée ;
•
le champ fContexteEval, pointeur sur une instance de ContexteEval qui est le contexte d’évaluation de l’appel.
Le corps de la méthode Evaluer fait donc dans ce cas évaluer le graphe sémantique dans le contexte d’évaluation de l’appeleur, ce qui s’écrit : ValeurFormula EvalParNom :: Evaluer (short lIndentation) { Indenter (lIndentation); cout <<
258 Compilateurs avec C++
form ( "... On évalue le par nom '%s', no %d\, contexte %d\n", fNom, fNumero, fNumeroContexte ); ValeurFormula
res = fGrapheSemantique -> Evaluer (fContexteEval);
Indenter (lIndentation); cout << "... la valeur est "; res.Ecrire (); cout << "\n"; return res; } Exemple
Nous traitons le même source Formula qu’au paragraphe précédent. Le résultat de l’évaluation de fact (2) devient : Valeur: >>> Appel à 'fact' (contexte 1) avec comme paramètres: 1: par nom: 2.000000 ... ... ... ...
On la On la
évalue valeur évalue valeur
le par nom 'n', no 1, contexte 1 est (2.000000 , vrai) le par nom 'n', no 1, contexte 1 est (2.000000 , vrai)
>>> Appel à 'fact' (contexte 2) avec comme paramètres: 1: par nom: Moins n 1.000000 ... On évalue le par nom 'n', no 1, contexte 2 ... On évalue le par nom 'n', no 1, contexte 1 ... la valeur est (2.000000 , vrai) ... la valeur est (1.000000 , vrai) ... On évalue le par nom 'n', no 1, contexte 2 ... On évalue le par nom 'n', no 1, contexte 1 ... la valeur est (2.000000 , vrai) ... la valeur est (1.000000 , vrai) >>> Appel à 'fact' (contexte 3) avec comme paramètres: 1: par nom: Moins n 1.000000 ... On évalue le par nom 'n', no 1, contexte 3 ... On évalue le par nom 'n', no 1, contexte 2 ... On évalue le par nom 'n', no 1, contexte 1 ... la valeur est (2.000000 , vrai) ... la valeur est (1.000000 , vrai) ... la valeur est (0.000000 , faux)
Évaluation et paramètres 259
<<< Résultat = (1.000000 , vrai) <<< Résultat = (1.000000 , vrai) <<< Résultat = (2.000000 , vrai) 2.000000 -----------------
Bien que le résultat soit le même qu’au paragraphe précédent, on voit très nettement le surcroît de travail occasionné par les ré-évaluations successives des différentes occurrences du paramètre formel n de la fonction fact. En particulier, l’évaluation de toute occurrence du paramètre n fait remonter jusqu’à celle de l’argument 2 fourni dans l’évaluation : ? fact (2);
Cela n’a rien d’inquiétant puisque tout se passe comme si chaque occurrence de ce paramètre était remplacée par le texte de l’argument d’appel dans le passage par nom, ce qui remonte nécessairement jusqu’à l’appel au niveau principal. Le passage par besoin, illustré au paragraphe suivant, évite cet inconvénient. 10.11
Evaluation des arguments par besoin
L’évaluation des arguments d’appel correspondant à un paramètre passé par besoin est fait au moyen de la classe Evalpar besoin. En plus des champs hérités de sa superclasse EvalArg, elle s’appuie sur : •
le champ fGrapheSemantique, pointeur une une instance de DescrSemou de l’une de ses descendantes, décrivant l’argument d’appel ;
Formula
•
le champ fContexteEval, pointeur sur une instance de ContexteEval qui est le contexte d’évaluation de l’appel ;
•
le champ booléen fValeurDisponible, indiquant si l’argument d’appel a déjà été évalué ;
•
et enfin de champ fValeur destiné à recevoir la valeur de cet argument après son évaluation.
Le corps de la méthode Evaluer pour un passage par besoin est : ValeurFormula EvalParEsseux :: Evaluer (short lIndentation) { Indenter (lIndentation); if (! fValeurDisponible) { cout << form ( "... On évalue le par besoin '%s', no %d, contexte %d\n", fNom, fNumero, fNumeroContexte ); fValeur = fGrapheSemantique -> Evaluer (fContexteEval); Indenter (lIndentation); cout << "... la valeur est "; fValeur.Ecrire (); cout << "\n";
260 Compilateurs avec C++
fValeurDisponible = true; } else { cout << form ( "... On consulte le par besoin '%s', no %d, contexte %d\n", fNom, fNumero, fNumeroContexte ); Indenter (lIndentation); cout << "... la valeur est "; fValeur.Ecrire (); cout << "\n"; } return fValeur; } / / EvalParEsseux :: Evaluer Exemple
Voici le même exemple de calcul de fact (2) qu’aux deux paragraphes précédents, qui produit avec un passage par besoin la trace d’évaluation suivante : Valeur: >>> Appel à 'fact' (contexte 1) avec comme paramètres: 1: par besoin non encore évalué: 2.000000 ... ... ... ...
On la On la
évalue le par besoin 'n', no 1, contexte 1 valeur est (2.000000 , vrai) consulte le par besoin 'n', no 1, contexte 1 valeur est (2.000000 , vrai)
>>> Appel à 'fact' (contexte 2) avec comme paramètres: 1: par besoin non encore évalué: Moins n 1.000000 ... On évalue le par besoin 'n', no 1, contexte 2 ... On consulte le par besoin 'n', no 1, contexte 1 ... la valeur est (2.000000 , vrai) ... la valeur est (1.000000 , vrai) ... On consulte le par besoin 'n', no 1, contexte 2 ... la valeur est (1.000000 , vrai) >>> Appel à 'fact' (contexte 3) avec comme paramètres: 1: par besoin non encore évalué: Moins n 1.000000 ... On évalue le par besoin 'n', no 1, contexte 3 ... On consulte le par besoin 'n', no 1, contexte 2 ... la valeur est (1.000000 , vrai) ... la valeur est (0.000000 , faux)
Évaluation et paramètres 261
<<< Résultat = (1.000000 <<< Résultat = (1.000000 <<< Résultat = (2.000000 2.000000 ----------------10.12
, vrai) , vrai) , vrai)
Evaluation des appels de fonction
Pour les besoins de l’évaluation directe des graphes sémantiques, nous enrichissons la classe AppelDeFonction, présentée au paragraphe 8.22. On se prémunit des évaluations sans fin par un comptage du nombre d’appels à la méthode Evaluer. La méthode AppelDeFonction :: Evaluer est listée en appendice, au paragraphe A.6.1. 10.13
Exercices
10.1 : Traitement de listes en Formula (projet). A la suite du projet faisant l’objet de l’exercice 8.1, implanter la mise sous forme postfixée et l’évaluation directe des graphes sémantiques des notions constituant les nouvelles possibilités du langage.
262 Compilateurs avec C++
Chapitre
11
11 Environnement d’exécution
Les phases d’analyse lexicale, syntaxique et sémantique ont permis de contrôler que le texte source à compiler a une bonne forme et une sémantique bien définie pour le langage dans lequel il est écrit. Avant de passer à la synthèse du code objet, il est nécessaire de définir dans quel contexte, dans quel environnement ce code sera exécuté. Nous présentons dans ce chapitre la gestion des appels de procédures et fonctions et l’accès aux variables lors de l’exécution d’un programme. Le terme “variable“ est pris ici au sens de Von Neumann, soit un dépôt pouvant contenir des valeurs successives dans le temps. Nous présentons au début de ce chapitre la manière classique de gérer les appels de fonctions et procédures. Une vue plus moderne est décrite au paragraphe 11.19. 11.1
Portées statique et dynamique des variables
Pour étudier la gestion de la mémoire, il faut considérer les choses sous l’angle de la portée des valeurs et des variables. La question importante est celle de leur localisation en mémoire, et donc de la manière d’y accéder.
264 Compilateurs avec C++
On distingue classiquement : •
la portée statique d’une variable ou portée textuelle. C’est la région dans le code source d’un programme où le nom d’une variable est accessible, où l’on peut l’utiliser pour y affecter une valeur ou la consulter ;
•
la portée dynamique d’une variable ou durée de vie. C’est l’intervalle dans le temps, à l’exécution du programme, pendant lequel la variable existe, à savoir qu’il lui est alloué de la place quelque part dans la mémoire.
La portée textuelle des identificateurs est bien connue des programmeurs puisqu’elle est contrôlée statiquement, par les compilateurs. La règle des langages structurés en blocs est souvent utilisée dans les langages modernes. Les variables n’en sont qu’un cas particulier. Des exemples typiques sont présentés ci-dessous. La durée de vie des variables donne lieu aux trois modes d’allocation statique, automatique et dynamique, présentés à partir du paragraphe suivant.
Associé à une variable, le mot clé static de C++ indique une variable à allocation statique, dont la portée textuelle est locale. Associé à une fonction, static indique seulement que son identificateur a une portée textuelle locale. En effet, une fonction n’a pas de portée dynamique en C++. Cette notion pourrait toutefois dans un langage permettant la compilation incrémentale. Exemple de portée textuelle globale
Certaines variables sont utilisables dans tout le code de l’application. C’est le cas des variables déclarées au niveau des fichiers en C++, pour autant que l’on ne les déclare pas static. L’exemple suivant illustre cet aspect. On a dans le fichier source C++ Module.cp le code : //
Variables globales et privées en C++
static
int
entier_global
= 19;
int
entier_prive
= 347;
//
définition
Le programme principal Main.cp, quant à lui, contient : #include <stream.h> extern
int
entier_global;
//
déclaration
main () { cout << entier_global << "\n"; // "entier_prive" n'est pas visible ici! }
La spécification extern est nécessaire dans Main.cp pour déclarer la variable entier_global, qui est définie dans Module.cp.
Environnement d’exécution 265
Si l’on rajoute dans Main.cp la déclaration : extern
int
entier_prive;
et dans Main.cp l’instruction : cout << entier_prive << "\n";
on obtient un message d’erreur à l’édition des liens (linking) : c’est en effet à ce moment-là que l’on vérifie en dernier ressort que toutes les variables globales déclarées sont bel et bien définies. On voit pourquoi nous avons appelé cette variable entier_prive : sa visibilité est limitée au seul fichier source où elle est définie. Exemple de portée textuelle locale
Dans les langages à structure de blocs, dont Algol 60 a été le précurseur, un identificateur est utilisable dans le niveau de déclaration où il est déclaré ainsi que dans les niveaux de déclaration imbriqués dans celui-ci, pour autant que cet identificateur n’y fasse pas l’objet d’une autre déclaration. Lorsqu’un identificateur est redéclaré dans un niveau de déclaration imbriqué, la déclaration imbriquée masque la déclaration englobante, comme dans : program portees_textuelles; var i: integer; function f (i: boolean): real; begin write (i); (* ce "i" est le paramètre de "f" *) f := 3.141592; end; (* f *) begin (* portees_textuelles *) i := 27; write (i); (* ce "i" est global à "portees_textuelles" *) writeln (f (true)); end. (* portees_textuelles *)
Le programme ci-dessus produit comme résultat : 27 TRUE 3.142e+0 11.2
Allocation statique
Une variable allouée statiquement naît dès le lancement du programme et meurt à la fin de l’exécution de celui-ci. On lui attribue alors une adresse fixe, à laquelle le code objet peut y accéder facilement. Selon les environnements d’exécution, on peut adresser une variable allouée statiquement par une adresse absolue, ou relativement à un registre dédié pointant sur le bloc des variables allouées statiquement. En Fortran 77 et Cobol, toutes les variables sont allouées statiquement. En Algol 60, le mot clé own (personnel, appartenant en propre) indique l’allocation statique d’une variable à portée textuelle locale, comme static en C++.
266 Compilateurs avec C++
Voici l’exemple en C++ d’une fonction de génération pseudo-aléatoire uniforme de nombres réels compris entre 0.0 et 1.0 : #include <math.h> #include <stream.h> float hasard () { const long
a1 c1 a2 c2
= = = =
4193, 1111111, 5659, 1111111;
static long germe1 = 123457, germe2 = 123455; long float
// // //
ces variables sont statiques valeur initiale valeur initiale
// celles-ci sont automatiques modulo1 = pow (2, 16), modulo2 = pow (3, 9); res;
germe1 = (germe1 * a1 + c1) % modulo1; germe2 = (germe2 * a2 + c2) % modulo2; res = float (germe1) / modulo1 - float (germe2) / modulo2; if (res < 0.0) res = res + 1.0; return res; } // hasard main () { const short taille = 5; for (short i = 1; i <= taille; ++ i) cout << i << ": " << hasard () << "\n"; }
Notons qu’il serait plus “propre“ en C++ d’écrire une classe ’“générateur aléatoire“, ce qui permettrait de plus d’en utiliser plusieurs instances indépendantes. On doit spécifier la valeur initiale des variables statiques lors de leur définition. Ainsi, leur valeur initiale est fixée au chargement du programme, puis tous les appels à la fonction hasard de cet exemple retrouvent dans germe1 et germe2 les valeurs qui y avaient été stockées lors de l’appel précédent. Toutefois, la portée textuelle locale fait que la visibilité de ces deux variables est limitée au corps de la fonction hasard. Les résultats produits par ce programme sont : 1: 2: 3: 4: 5:
0.120518 0.278526 0.867251 0.786934 0.410382
Environnement d’exécution 267
11.3
Allocation automatique
Une variable allouée automatiquement naît à l’entrée dans le bloc où elle déclarée et meurt à la sortie de ce bloc. C’est notamment le cas des paramètres formels et des variables locales des procédures et fonctions dans les langages à structure de blocs. La sortie du bloc peut se produire par épuisement des instructions qui composent son corps ou par une instruction de sortie directe, comme return en C++. Le cas des langages admettant la définition de fonctions récursives pose un problème particulier. Lorsque un certain nombre d’appels récursifs coexistent à l’exécution, chacun a en propre un exemplaire de chacune de ses variables allouées de manière automatique. Dans ce contexte, la question suivante se pose : En cas d’appels récursifs de fonctions, à quel exemplaire particulier des variables automatiques accède-t-on ? Ce problème est illustré par l’exemple C++ suivant : #include <stream.h> void laFonction (int i) { static int appelsCoexistants = 0; // partagee par tous les appels const int indentation = 5; appelsCoexistants += 1; cout << form ("%*s", appelsCoexistants * indentation, "") << "--> Entrée dans laFonction: i = " << i << "\n"; int
interessante = (i + 7) * (i + 7); // propre a chaque appel
if (i > 0) laFonction (i - 1); else { cout << appelsCoexistants << "\n"; cout << interessante << "\n"; } cout << form ("%*s", appelsCoexistants * indentation, "") << "<-- Sortie de laFonction : i = " << i << "\n"; appelsCoexistants -= 1; } // laFonction
268 Compilateurs avec C++
void main () { laFonction (3); }
On accède toujours à l’exemplaire le plus récemment alloué d’une variable automatique. Cette règle est très naturelle si l’on raisonne en termes de décomposition de problèmes, ce qui est réalisé par la structuration du code en fonctions. Ici, le code de la fonction laFonction accède, sous le nom interessante, à l’exemplaire de cette variable qui est propre au plus récent appel à laFonction. Il y a donc masquage des autres exemplaires de cette variable pendant la durée d’un nouvel appel à laFonction. Le résultat produit par ce programme est : --> Entrée dans laFonction: i = 3 --> Entrée dans laFonction: i = 2 --> Entrée dans laFonction: i = 1 --> Entrée dans laFonction: i = 0 4 49 <-- Sortie de laFonction : i = 0 <-- Sortie de laFonction : i = 1 <-- Sortie de laFonction : i = 2 <-- Sortie de laFonction : i = 3
En effet, le plus récent appel à laFonction est tel que son exemplaire de i vaut 0, donc son exemplaire de interessante vaut 49. On a utilisé la variable statique appelsCoexistants pour visualiser par indentation les entrées et sorties des appels récursifs à laFonction. On remarquera que le paramètre formel i est lui aussi soumis à cette règle d’accès : rappelons qu’un paramètre formel passé par valeur n’est qu’une variable locale, donc automatique, initialisée par un argument fourni par l’appel. 11.4
Allocation dynamique
Une variable allouée dynamiquement naît à la demande, au moyen d’une instruction de création d’une variable comme new en Pascal ou C++ et malloc en C. La question fondamentale dans la gestion des variables dynamique est : Quand une variable dynamique meurt-elle ? En pratique, la réponse à cette question peut être : •
on procède à une désallocation explicite de la variable au moyen d’une instruction du langage, comme dispose en Pascal, delete en C++ et free en C ;
Environnement d’exécution 269
•
on s’appuie sur une gestion automatique de la mémoire, qui est notamment chargée de déterminer quelles variables dynamiques sont encore utilisées par le programme. Toutes les variables qui ne sont plus utilisées (accessibles) par le programme sont alors candidates pour une destruction permettant de récupérer la place qu’elles occupent.
Il est bien connu en programmation que la simple affectation entre variables de type pointeur, outre le fait de créer des alias, peut rendre inaccessible des pans entiers de la mémoire dynamique d’une application. De plus, il y a le danger de la désallocation explicite : on peut se retrouver avec des pointeurs en l’air (dangling pointers) parce qu’ils étaient un alias sur une variable qui a été désallouée explicitement via un autre pointeur. On peut même dans certaines langages désallouer une zone de taille différente de celle qui avait été allouée !
La récupération automatique de la place dans une gestion automatique de la mémoire est appelée ramassage des miettes (garbage collection). Ce nom est dû au phénomène typique d’émiettement provoqué par les allocations et libérations successives de blocs de mémoire. On trouve dans [Menu 92] le détail des algorithmes classiques de ramassage des miettes. 11.5
Blocs d’activation et pile d’exécution
Nous avons montré, au chapitre 10, que l’on peut exécuter directement les graphes sémantiques en les parcourant récursivement. Nous abordons maintenant la manière dont on gère les appels de fonctions dans les machines informatiques. Un bloc d’activation (activation block, environment) est un ensemble d’informations groupées en mémoire décrivant un appel particulier à une fonction. Les blocs d’activation implantent à bas niveau ce que nous avons fait à haut niveau avec la classe ContexteEval lors de l’évaluation directe des graphes sémantiques au paragraphe 10.6. Un bloc d’activation contient typiquement : •
des informations permettant la gestion de ces blocs d’activation, comme le retour à l’appeleur ;
•
s’il y a lieu, les exemplaires des paramètres propres à l’appel considéré ;
•
s’il y a lieu, les variables locales propres à l’appel considéré ;
270 Compilateurs avec C++
•
les variables temporaires éventuelles, variables cachées contenant des valeurs intermédiaires lors d’évaluations complexes ou des accès à des variables, comme dans le cas de with un_pointeur^ en Pascal.
Comme on sort des appels de fonctions dans l’ordre inverse de celui où on y est entré, on utilise une pile des blocs d’activation, également appelée pile d’exécution (runtime stack). Il est possible d’allouer le bloc placé au sommet de la pile conceptuellement à une adresse fixe, comme le font certains environnements Lisp : on peut considérer que le sommet de la pile est alors placé dans un cache, comme cela est fait dans le cas de certains processeurs réels. Informations de liaison
Un bloc d’activation contient des informations liaison appelées liens (links) gérant le mécanisme d’appel et de retour. La première information de liaison nécessaire à un bloc d’activation est le lien au bloc d’activation de l’appeleur, que nous appellerons “bloc père“. Ce lien s’appelle le lien dynamique (dynamic link) car il reflète la dynamique des appels à l’exécution. Le lien dynamique est simplement le lien au prochain bloc d’activation dans la pile. Il est nécessaire concrètement parce que les blocs d’activation n’ont pas tous la même taille. Les liens dynamiques forment une chaîne : elle part du bloc d’activation en sommet de pile, celui qui est en cours d’exécution, et lie les blocs d’activation en une pile dont le fond est celui du programme principal.
Une évaluation Formula, introduite par le terminal "?", est l’équivalent du programme principal dans d’autres langages. Nous avons vu qu’il s’agit d’une fonction anonyme sans paramètres, appelée implicitement sur le point de sa déclaration. Elle a donc un bloc d’activation dégénéré, sans liens, qui occupe le fond de la pile d’exécution. On en voit un exemple au paragraphe 11.16. La pile d’exécution est une liste de blocs d’activation chaînés par leur lien dynamique. Toutefois, certains implantations de langages allouent le bloc d’activation du programme principal de manière statique pour que l’accès aux variables globales soit plus efficace.
Environnement d’exécution 271
La seconde information nécessaire dans un bloc d’activation est l’adresse de retour (return address). Au retour d’un appel , on doit reprendre l’exécution de l’appeleur à l’instruction qui suit cet appel. C’est lors de l’appel à une procédure ou fonction que l’on établit les informations de liaison, et que l’on réserve s’il y lieu la place pour la pseudo-variable contenant la valeur retournée dans le cas d’une fonction. Par exemple, à l’exécution du programme C++ : void proc (int n) { … } void main () { proc (4); cout << "C’est fini"; }
le bloc d’activation créé lors de l’appel à proc depuis main contient comme lien dynamique un pointeur sur le bloc d’activation de main, et comme adresse de retour l’adresse de l’instruction : cout << "C’est fini";
qui suit cet appel. La paire formée du lien dynamique et de l’adresse de retour constitue le lien au bloc père.
Une troisième information est conceptuellement nécessaire dans le cas du bloc d’activation d’une fonction. Il s’agit de la valeur retournée (return value). Dans beaucoup de langages, cette valeur retournée n’est qu’implicite, comme en C++ dans l’instruction : return une_valeur;
En Pascal, en revanche, la valeur retournée par une fonction est désignée dans le code source par une pseudo-variable ayant le même nom que la fonction et permettant de retourner un résultat avec une affectation explicite, comme dans : function carre (n: integer): integer; begin carre := n + n end;
272 Compilateurs avec C++
Il est possible d’éviter l’allocation de la valeur retournée dans le bloc d’activation des fonctions en allouant un registre pour la contenir, comme c’est souvent le cas des implantations de C++. Les lien dynamique, adresse de retour, et s’il y a lieu valeur retournée, se retrouvent classiquement dans tous les langages permettant de déclarer des procédures ou fonctions. Nous allons voir dans le paragraphe suivant que les langages dans lesquels les déclarations de fonctions peuvent être imbriquées doivent être implantés avec un lien supplémentaire dans les blocs d’activation. 11.6
Le cas des fonctions imbriquées
Nous avons vu au paragraphe 11.3, un exemple de fonction imbriquée textuellement dans une autre. A l’exécution du code objet, on accède aux variables déclarées dans un niveau de déclaration englobant au moyen du lien statique (static link), qui reflète l’imbrication statique des blocs dans le texte source. Le lien statique d’un bloc d’activation d’une fonction pointe sur un bloc d’activation de la fonction qui contient textuellement sa déclaration. Il joue exactement le même rôle que le champ fContexteContenant des contextes d’évaluation présentés au paragraphe 10.6. Exemple
Soit le programme Pascal suivant, qui reprend l’idée de l’exemple du paragraphe précédent, mais avec une imbrication de fonctions illustrée à la figure 11.1 : program fonctions_imbriquees; var appels_coexistants: integer; procedure la_fonction (i: integer); const indentation = 5; var interessante: integer; procedure imbriquee; begin writeln (appels_coexistants); writeln (interessante); end; procedure jamais_appelee; var b: boolean; begin end; begin (* la_fonction *) appels_coexistants := appels_coexistants + 1;
Environnement d’exécution 273
writeln ( ' ': appels_coexistants * indentation, '--> Entrée dans la_fonction: i = ', i ); interessante := sqr (i + 7); if i > 0 then la_fonction (i - 1) else imbriquee; writeln ( ' ': appels_coexistants * indentation, '<-- Sortie de la_fonction : i = ', i ); appels_coexistants := appels_coexistants - 1 end; (* la_fonction *) begin (* fonctions_imbriquees *) appels_coexistants := 0; la_fonction (3); end.
On accède toujours à l’exemplaire le plus récemment alloué d’une variable automatique. Cette règle est très naturelle si l’on raisonne en termes de décomposition de problèmes, ce qui est réalisé par la structuration du code en fonctions. Dans notre exemple, la fonction imbriquee accède, sous le nom interessante, à l’exemplaire de cette variable qui est propre au plus récent appel à la_fonction. Il y a donc masquage des autres exemplaires de cette variable pendant la durée d’un nouvel appel à contenante. Le résultat produit par ce programme est : --> Entrée dans la_fonction: i = 3 --> Entrée dans la_fonction: i = 2 --> Entrée dans la_fonction: i = --> Entrée dans la_fonction: i = 4 49 <-- Sortie de la_fonction : i = <-- Sortie de la_fonction : i = <-- Sortie de la_fonction : i = 2 <-- Sortie de la_fonction : i = 3
1 0 0 1
274 Compilateurs avec C++
prédéfinis
programme fonctions_imbriquees
integer boolean writeln appels_coexistants la_fonction
procédure la_fonction
procédure imbriquee
i indentation interessante imbriquee jamais_appelee
procedure jamais_appelee
b
emploi de "interessante" emploi de "appels_coexistants"
imbrication des niveaux de déclarations
accès par_la_chaîne_statique
11.1Fonctions imbriquées et accès statique en Pascal 11.7
Etablissement du lien statique
Comme les autres informations de liaison, le lien statique est établi lors de l’appel à une procédure ou fonction. Le lien statique peut être identique au lien dynamique, mais que ce n’est pas toujours le cas. En pratique, deux cas se présentent : •
lors d’un appel à une fonction déclarée localement à l’appeleur, comme lorsque la_fonction appelle imbriquee, le bloc d’activation la procédure appelée est précisément celui de l’appeleur, qui est placé dans le lien dynamique. Dans ce cas, la règle est donc : lien_statique_de_l_appelé = lien_dynamique_de_l_appelé; // donc un pointeur au père (l’appeleur)
Environnement d’exécution 275
•
lors d’un appel à une fonction déclarée globalement à l’appeleur, donc aussi lors d’appels récursifs, on doit remonter dans la chaîne statique pour trouver le bloc sur lequel devra pointer le lien statique. Le nombre de liens de la chaîne statique qu’il faut parcourir est égal à la différence des niveaux statiques entre l’appeleur et l’appelé. En d’autres termes, on doit remonter dans la chaîne statique autant de fois que l’on franchit d’en-têtes de procédures ou de fonctions entre l’instruction d’appel et la déclaration de l’appelé. La règle dans ce cas est donc : aux = lien_statique_de_l_appeleur; for ( int i = 1; i <= to difference_des_niveaux_statiques; ++ i ) aux = lien_statique_de_aux; lien_statique_de_l_appelé = aux;
Dans le cas où l’appeleur et l’appelé sont déclarées au même niveau, soit dans le même bloc, la différence des niveaux statiques est nulle, et on se ramène alors à : lien_statique_de_l_appelé = lien_statique_de_l_appeleur; C’est en particulier le cas des appels récursifs, comme ceux de la_fonction dans son propre corps. Cela est naturel puisque le lien statique renvoie au bloc le plus récemment créé de la fonction contenant la déclaration de la procédure appelée, qui est la même pour l’appeleur et l’appelé dans ce cas particulier.
En Pascal, on pourrait se passer de lien statique pour les blocs d’activation des procédures et fonctions qui, dans leur corps, n’utilisent rien qui soit déclaré globalement à elles. Le compilateur Formula fait d’ailleurs cette optimisation : un lien statique n’est utilisé que lorsque les passages de paramètres ont lieu par nom et par besoin, mais pas pour les passages par valeur. 11.8
Exemple de pile d’exécution
Pour illustrer la pile d’exécution à l’œuvre, nous présentons à la figure 11.2, l’état de la pile d’exécution juste avant la sortie de la fonction imbriquée lors de l’exécution du programme fonctions_imbriquees du paragraphe 11.3. On voit que les liens statiques reflètent effectivement l’imbrication textuelle des déclarations de fonctions, tandis que les liens dynamiques ne font que la gestion de la pile des blocs d’activation.
276 Compilateurs avec C++
imbriquée
LD AR LS
la_fonction
i interessante LD AR LS
0 49
la_fonction
i interessante LD AR LS
1 64
la_fonction
i interessante LD AR LS
2 81
la_fonction
i interessante LD AR LS
3 100
appels_coexistants LD AR LS
4
fonctions_imbriquees
système
… … … … … …
lien dynamique lien statique
11.2Exemple de pile d’exécution en Pascal
Environnement d’exécution 277
Le bloc d’activation du programme principal fonctions_imbriquees n’a pas de lien statique. Son lien dynamique pointe sur le bloc d’activation du système d’exploitation. Depuis le corps de imbriquée, il faut effectuer une remontée de 1 lien dans la chaîne statique pour trouver le bloc d’activation contenant interessante, et une remontée de 2 liens pour accéder à appels_coexistants. Tous les blocs d’activation de la_fonction ont un lien statique qui pointe sur un bloc d’activation de fonctions_imbriquees, dont il n’y a notre cas qu’un exemplaire puisqu’il s’agit du programme principal. Les blocs d’activation de la fonction imbriquee, dont il n’y également qu’un exemplaire, ont un lien statique pointant sur un bloc d’activation de la_fonction. En fait, il y a dans notre exemple plusieurs tels blocs d’activation de la_fonction coexistant en mémoire puisque la hauteur de récursion sur la_fonction est 4. Dans ces conditions, sur lequel de ces blocs pointera le lien statique des blocs d’activation de max ?
Le lien statique du bloc d’activation d’une fonction pointe sur le bloc d’activation le plus récemment créé de la fonction contenant sa déclaration, soit sur celui qui est le plus proche du sommet de la pile. On retrouve là le lien avec la résolution de problèmes par décomposition : chaque sous-problème donne lieu à un appel de fonction qui a comme contexte le problème dont il est un composant, et qui est le plus récemment “abordé“ s’il y en a plusieurs du même type en cours de résolution. Les liens statiques forment une chaîne, dite chaîne statique (static chain), reflétant l’imbrication des procédures et fonctions. Le début de la chaîne statique est le bloc en sommet de pile, et la fin est le niveau principal de déclaration ou, si celui-ci est implanté en mémoire statiquement, le bloc d’activation d’une procédure ou fonction déclarée dans le programme principal. Rappelons que certaines optimisations fines comme le passage de tous paramètres dans des registres et l’optimisation des appels terminaux peuvent faire qu’une fonction n’ait pas de bloc d’activation, ce qui est extrêmement intéressant pour la vitesse d’exécution du code objet et la taille occupée en mémoire vive. Nous en présenterons un exemple au paragraphe 11.20. 11.9
La machine Pilum
La machine Pilum est une machine virtuelle à pile définie et implantée pour les besoins de ce livre. Nous précisons ici son architecture de base. La synthèse de code pour cette machine est traitée au chapitre 12.
278 Compilateurs avec C++
Valeurs manipulées
Les valeurs manipulées par Pilum peuvent être de différents types de valeurs pures ainsi que des adresses dans le code ou dans la pile d’exécution. Elles sont décrites par les types : typedef typedef
long long
AdresseCode; AdressePile;
struct AccesStatique // sans constructeur: utilise dans une union dans "InstrPilum" { short fDifferenceStatique; short fDeplacement; }; enum TypeValeurPilum { kValeurInconnue, kAdresseDansLeCode,
kAdresseDansLaPile,
kEntier, kCaractere, };
kFlottant, kChaine
struct ValeurPilum { typedef ValeurPilum*
kBooleen,
ValeurPilumPtr; ValeurPilum ();
//
pour l'initialisation
void
Ecrire (ostream & leFlot);
TypeValeurPilum
fTypeValeur;
union { AdresseCode AdressePile
fAdresseCode; fAdressePile;
};
long float Boolean char char }; // union // ValeurPilum
fEntier; fFlottant; fBooleen; fCaractere; * fChaine;
Le type ValeurPilum est l’unité de mesure de l’encombrement des informations dans la pile de la machine Pilum.
Environnement d’exécution 279
Jeu d’instructions
Les codes opératoires des instructions de la machine Pilum sont décrites par le type énuméré suivant, dont l’intégralité figure en appendice, au paragraphe A.7.1 : enum CodeOpPilum { iHalte,
iCommentaire,
iStocker,
iDesempiler,
iEmpilerValeur, … … … … …
iEmpilerAdresse,
iRacine, iHasard, iSin, iCos, }; // CodeOpPilum
iDupliquer,
iArcTan
Les instructions de la machine Pilum, quant à elles, sont décrites par : extern char
* pTextesCodesOperatoiresPilum [];
struct InstructionPilum { typedef InstructionPilum
* InstructionPilumPtr;
InstructionPilum (); // pour gérer l'initialisation void
Ecrire (ostream & leFlot);
CodeOpPilum
fCodeOpPilum;
union { AdresseCode
fAdresseCode;
long float Boolean char char
};
fEntier; fFlottant; fBooleen; fCaractere; * fChaine;
AccesStatique fAccesStatique; }; // union // InstructionPilum
const short
kTailleInstructionPilum = sizeof (InstructionPilum);
Description de la machine
Les états d’exécution de la machine Pilum sont : enum EtatPilum { kErreurInterne, kExecution, kInterruptionUtilisateur,
kFinNormale,
280 Compilateurs avec C++
kDebordementPile,
kVariableNonInitialisee,
kDiviseEntierParZero, kDiviseFlottantParZero };
kModuloEntierParZero,
Les déclarations suivantes sont utilisées : const long
kTailleDeLaPileParDefaut
const short
kTailleDesLiensObligatoires = 2; // adresse de retour et lien dynamique
const short
kPosLienStatique // pas toujours utilise
enum GenreExecution
= 2000;
= -2;
{kPasAPas, kEnContinu };
Enfin, la machine Pilum elle-même est implantée par la classe Pilum, dont l’interface figure en appendice, au paragraphe A.7.2. Citons simplement ici : •
fPile et fMemoireDuCode dénotent les tableaux dynamiques que sont respectivement la pile et la mémoire de Pilum. Tous deux sont alloués dans le constructeur Pilum :: Pilum ;
•
fSommet, fEtatCourant, fInstructionCourante et fEnvCourant décrivent l’état courant de la machine ;
•
ChargerBinaire permet d’un fichier de lire le code binaire Pilum placé dans un fichier par un compilateur comme celui de Formula ;
•
Executer permet de lancer l’exécution du code à partir d’une certaine adresse ;
•
InstructionInconnue traite le cas d’un code opératoire inconnu lors de Executer. Par défaut, cette méthode indique une erreur. Il est possible de la re-définir dans une sous-classe, pour y définir une machine disposant d’un jeu d’instructions étendu. C’est de cette manière que fonctionnent les trap dans un processeur réel : le décodage d’une instruction inconnue provoque une interruption du processeur. On peut récupérer cette interruption pour la traiter de manière adéquate ;
•
RemonteeStatique empile l’adresse d’une cellule de la pile, étant donnée une information d’accès statique, soit une différence de niveaux statiques et un déplacement.
Exemple de code binaire Pilum
La compilation du code source Formula : ? Racine (Pi);
produit le code objet suivant, présenté par la machine Pilum après chargement ; 0: Commentaire: 1: EcrireFinDeLigne 2: EmpilerChaine
'Début d'une évaluation' Valeur:
Environnement d’exécution 281
3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14:
EcrireChaine EcrireFinDeLigne EmpilerFlottant Racine Moins1Flottant EcrireFlottant EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire: Halte
3.141592
================= 'Fin d'une évaluation'
Le code binaire Pilum, tel qu’il est écrit sur un fichier par le synthétiseur de code et relu par cette machine, est structuré ainsi : •
au début, sur 32 bits, la longueur du bloc des chaînes en nombre d’octets ;
•
puis les chaînes qui apparaissent comme arguments des instructions Pilum. Celles de longueur impaire sont complétées par un caractère, pour éviter des adresses impaires sur le bus !
•
enfin, les instructions Pilum en binaire, à raison de 48 bits chacune. Les premiers 16 bits contiennent le code opératoire fCodeOpPilum sur 8 bits cadré à gauche. Les 32 bits suivants contiennent l’argument s’il y a lieu, ou des zéros sinon.
Le code objet ci-dessus, vu en binaire, est présenté à la figure 11.3.
11.3Exemple de code binaire Pilum
282 Compilateurs avec C++
11.10
L’interprète Pilum
L’interprète de la machine Pilum est un exemple typique de boucle d’interprétation. Il est implanté par la méthode Executer qui a la structure suivante : void Pilum :: Executer ( AdresseCode adresseDeDepart, GenreExecution leGenreExecution, Boolean afficherLeCode ) { fSommet = 0; fEtatCourant = kExecution; fInstructionCourante = adresseDeDepart; fEnvCourant = 0; while (fEtatCourant == kExecution) { InstructionPilum lInstruction = fMemoireDuCode [fInstructionCourante ++]; switch (lInstruction.fCodeOpPilum) { case iHalte: fEtatCourant = kFinNormale; break; case iCommentaire: // RIEN A FAIRE! break; case iStocker: fPile [fPile [fSommet - 1].fAdressePile] = fPile [fSommet]; fSommet -= 2; break; //
… … … … … … … … … …
case iArcTan: fPile [fSommet].fFlottant = atan (fPile [fSommet].fFlottant); break; default: InstructionInconnue (); break; } // switch } //
while
if (fEtatCourant != kFinNormale) switch (fEtatCourant) {
Environnement d’exécution 283
case kExecution: case kErreurInterne: ErreurFatale ("Erreur interne dans la machine Pilum"); break; case kDebordementPile: ErreurFatale ("Debordement de la pile Pilum"); break; … … … … … … … … case kInterruptionUtilisateur: cout << "Interruption par l'utilisateur"; break; } // switch else AfficherLEtatInterne ("FIN NORMALE DE L'EXECUTION"); } // Pilum :: Executer 11.11
Blocs d’activation dans la machine Pilum
Pilum permet d’appeler des procédures et des fonctions, dont l’appeleur empile les arguments éventuels. La structure typique d’un bloc d’activation est donnée à la figure 11.5. EMPILE PAR: fSommet
Temporaires appelé
Variables Locales fEnvCourant
Lien Dynamique
(LD)
-1
Adresse de Retour
(AR)
-2
Lien Statique
(LS)
-3
Argument “n“ Argument “…“ Argument “1“
11.4Structure du bloc d’activation Pilum
iAppel
appeleur
284 Compilateurs avec C++
La séquence d’appel à une fonction ou procédure est de la forme : empiler les arguments éventuels empiler lien statique si nécessaire AppelDeFonction
adresse_du_code
La machine Pilum réalise l’appel au moyen de : case iAppel: if (fSommet + 1 >= fLimiteSommet) fEtatCourant = kDebordementPile; else { // on empile l'Adresse de Retour ++ fSommet; fPile [fSommet].fAdresseCode = fInstructionCourante; fPile [fSommet].fTypeValeur = kAdresseDansLeCode; // on empile le Lien Dynamique ++ fSommet; fPile [fSommet].fAdressePile = fEnvCourant; fPile [fSommet].fTypeValeur = kAdresseDansLaPile; // on passe a l'environnement de l'appelé fEnvCourant = fSommet; // on saute au début de la fonction fInstructionCourante = lInstruction.fAdresseCode; } break;
Lors d’un retour de procédure, on doit détruire le bloc d’activation correspondant. On fournit comme argument à l’instruction iRetourDeProcedure la taille des informations empilées par l’appeleur, sans compter le lien dynamique ni l’adresse de retour. Cette taille comprend donc les arguments d’appel, et le lien statique s’il est présent. Le retour se fait donc au moyen de se fait par l’instruction : case iRetourDeProcedure: { AdresseCode lienDynamique = fPile [fSommet --].fAdressePile; AdresseCode
adresseDeRetour = fPile [fSommet --].fAdresseCode;
// on desempile les arguments fSommet -= lInstruction.fEntier; // on revient a l'environnement de l'appeleur fEnvCourant = lienDynamique; // on retourne a l'appeleur fInstructionCourante = adresseDeRetour; } break;
Environnement d’exécution 285
Le retour de fonction est un peu plus compliqué parce que la valeur résultant de l’exécution du corps, qui se trouve en sommet de pile, doit être laissée sur la pile pour l’appeleur après avoir détruit le bloc d’activation. Cela donne : case iRetourDeFonction: { ValeurPilum resultat = fPile [fSommet --]; AdresseCode
lienDynamique = fPile [fEnvCourant].fAdressePile;
AdresseCode
adresseDeRetour = fPile [fEnvCourant - 1].fAdresseCode;
// on desempile les temporaires eventuels, // le lien dynamique, l'adresse de retour, // les arguments eventuels // et le lien statique éventuel fSommet = fEnvCourant - (lInstruction.fEntier + 2); // on laisse le resultat sur la pile fPile [++ fSommet] = resultat; // on revient a l'environnement de l'appeleur fEnvCourant = lienDynamique; // on retourne a l'appeleur fInstructionCourante = adresseDeRetour; } break;
Le lien statique est optionnel selon le langage à partir duquel on a synthétisé le code Pilum. Les fonctions Formula n’ont besoin d’un lien statique que si les passages de paramètres se font par nom ou par besoin. On voit des exemples de blocs d’activation Pilum pour des fonctions Formula dans les paragraphes suivants. 11.12
Passages de paramètres dans la machine Pilum
Nous avons vu que dans les passages de paramètres par nom et par besoin, l’argument d’appel n’est pas évalué par l’appeleur, mais qu’on fournit à la fonction appelée le moyen d’évaluer cet argument si c’est nécessaire. Cela est fait dans la synthèse de code par un thunk. Un thunk est une fonction cachée sans paramètre permettant d’évaluer à volonté un argument d’appel passé par nom ou par besoin. Un thunk est créé pour chaque argument d’appel, et doit s’évaluer dans le contexte de l’appel.
286 Compilateurs avec C++
Il n’y a pas de terme correspondant à thunk en français. Concrètement, le passage par nom ou par besoin se fait en créant une fonction retournant la valeur de l’argument en question, et en passant cette fonction comme valeur de l’argument d’appel. Pilum supporte les modes de passage de paramètres de la manière suivante : •
un paramètre passé par valeur reçoit le résultat de l’évaluation de l’argument d’appel correspondant, qui est simplement empilée ;
•
pour chaque paramètre passé par nom , on crée un thunk dont on empile l’adresse. Comme un thunk doit s’évaluer dans le contexte de l’appel, on empile en plus le pointeur sur le bloc d’activation de l’appel, qui sera utilisé comme lien statique lors de l’évaluation du thunk. Le tout est réalisé par l’instruction iEmpilerThunk. L’évaluation du thunk est effectuée dans le corps de la fonction appelée au moyen de l’instruction iEvaluerThunk ;
•
le cas d’un passage par besoin est traité de manière analogue au passage par nom avec création d’un thunk. Il faut toutefois deux informations supplémentaires dans ce cas, soit un booléen indiquant si l’évaluation a déjà été faite, et la valeur après évaluation pour pouvoir la retrouver dans les accès ultérieurs. On empile donc dans ce cas lors de l’appel les valeurs initiales de ces deux informations, soit “faux“ et “inconnu“ respectivement ;
•
le passage par référence, bien que non disponible en Formula, est implanté très simplement avec les instructions iEmpilerAdresse, permettant d’empiler l’adresse d’une cellule de la pile, et iStocker, qui affecte la valeur au sommet de la pile à la cellule de la pile dont l’adresse se trouve immédiatement sous le sommet.
Ces considérations font donc que les tailles des paramètres dans la pile sont : 1 avec passage par valeur ; 1 avec passage par référence ; 2 avec passage par nom ; 4 avec passage par besoin. Les différentes instructions Pilum mentionnées ci-dessus sont décrites, avec leur implantation, dans les paragraphes suivants. 11.13
Exemple de passage par valeur avec Pilum
Soit le source Formula suivant : CarrePlus (x, y) = x * x + y; ? CarrePlus (LireNombre (), 6); Le bloc d’activation de la fonction CarrePlus au début de l’exécution de son corps avec un passage par valeur, soit à l’adresse 1, a la forme illustrée à la figure 11.5.
Environnement d’exécution 287
EMPILE PAR:
fSommet fEnvCourant
Lien Dynamique
(LD)
appelé
-1
Adresse de Retour
(AR)
iAppel
-2
y
6
-3
x
nombre lu
appeleur
11.5Bloc d’activation Pilum avec passage par valeur Le code synthétisé pour cet exemple par le compilateur Formula est : 0: Sauter
12
1: Commentaire:
'Début du corps de 'CarrePlus''
2: EmpilerValeur 3: Commentaire:
0,-3 'Par valeur x (no 1)'
4: EmpilerValeur 5: Commentaire:
0,-3 'Par valeur x (no 1)'
6: FoisFlottant 7: EmpilerValeur 8: Commentaire: 9: PlusFlottant 10: RetourDeFonction 11: Commentaire: 12: 13: 14: 15: 16:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne
17: 18: 19: 20:
LireFlottant EmpilerFlottant Appel Commentaire:
0,-2 'Par valeur y (no 2)' 2 'Fin du corps de 'CarrePlus'' 'Début d'une évaluation' Valeur:
6.000000 1 'CarrePlus'
21: EcrireFlottant 22: EcrireFinDeLigne 23: 24: 25: 26:
EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire:
27: Halte
================= 'Fin d'une évaluation'
288 Compilateurs avec C++
L’instruction de saut initiale : 0: Sauter 12 est là pour ne pas exécuter le corps de la fonction CarrePlus “dans la foulée“. Elle pourrait être évitée en plaçant dans le code objet d’abord le code pour les évaluations, puis le code pour les définitions de fonctions, ou encore en plaçant dans le code objet l’indication de l’adresse de début de l’exécution.
L’instruction LireFlottant a pour effet de demander une valeur flottante à l’utilisateur, et d’empiler la valeur lue : case iLireFlottant: if (fSommet == fLimiteSommet) fEtatCourant = kDebordementPile; else { ++ fSommet; fPile [fSommet].fFlottant = FLireFlottant ("Veuillez taper une valeur flottante: "); fPile [fSommet].fTypeValeur = kFlottant; } break;
L’exécution de ce code donne par exemple : Valeur: Veuillez taper une valeur flottante: 3 15.000000 =================
L’emploi des paramètres dans le corps des fonctions conduit dans le cas du passage par valeur à la simple consultation des valeurs préévaluées lors de l’entrée dans la fonction, au moyen de : EmpilerValeur difference_statique, deplacement Le fait que le texte “Veuillez taper une valeur flottante: “ s’affiche après le message “Valeur:“ est simplement dû à l’ordre dans lequel les choses sont exécutées dans le code postfixé. Ce dernier message signale en fait le début d’une évaluation produisant une valeur dans le cas de Formula. 11.14
Exemple de passage par nom avec Pilum
Compilons le même fichier source qu’au paragraphe précédent : CarrePlus (x, y) = x * x + y; ? CarrePlus (LireNombre (), 6); avec passage des paramètres par nom. Le bloc d’activation de la fonction CarrePlus juste avant de faire évaluer le thunk calculant la première occurrence de x dans son corps, soit à l’adresse 4, a la structure montrée à la figure 11.5.
Environnement d’exécution 289
EMPILE PAR: fSommet appelé fEnvCourant
Lien Dynamique
(LD)
-1
Adresse de Retour
(AR)
-2
Lien Statique
(LS)
iAppel
lien statique futur -4
adresse du thunk
29
appeleur
lien statique futur adresse du thunk
-6
22
11.6Bloc d’activation Pilum avec passage par nom Le code objet Pilum, quant à lui, est : 0: Sauter
16
1: Commentaire:
'Début du corps de 'CarrePlus''
2: EmpilerAdresse 3: Commentaire: 4: EvaluerThunk
0,-6 'Thunk du par nom x (no 1)'
5: EmpilerAdresse 6: Commentaire: 7: EvaluerThunk
0,-6 'Thunk du par nom x (no 1)'
8: FoisFlottant 9: EmpilerAdresse 10: Commentaire: 11: EvaluerThunk 12: 13: 14: 15:
PlusFlottant RetourDeFonction Commentaire: Commentaire:
16: 17: 18: 19:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine
0,-4 'Thunk du par nom y (no 2)'
5 'dont 1 pour le LS de cette fonction' 'Fin du corps de 'CarrePlus'' 'Début d'une évaluation' Valeur:
290 Compilateurs avec C++
20: EcrireFinDeLigne 21: Sauter 22: 23: 24: 25: 26:
Commentaire: LireFlottant RetourDeFonction Commentaire: Commentaire:
27 'Début du Thunk pour "x"' 1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "x"'
27: EmpilerThunk 28: Sauter
22 34
29: 30: 31: 32: 33:
Commentaire: EmpilerFlottant RetourDeFonction Commentaire: Commentaire:
'Début du Thunk pour "y"' 6.000000 1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "y"'
34: 35: 36: 37: 38:
EmpilerThunk EmpilerAdresse Commentaire: Appel Commentaire:
29 0,0 'LS pour l'appel de fonction' 1 'CarrePlus'
39: 40: 41: 42:
EcrireFlottant EcrireFinDeLigne EmpilerChaine EcrireChaine
43: EcrireFinDeLigne 44: Commentaire: 45: Halte
=================
'Fin d'une évaluation'
L’exécution de ce code peut donner lieu à l’interaction suivante : Valeur: Veuillez taper une valeur flottante: 5 Veuillez taper une valeur flottante: 5 31.000000 ================= La réitération de la demande à l’utilisateur vient simplement du passage par nom : on ré-évalue le thunk à chaque fois qu’on a besoin de la valeur du paramètre, ce qui fait que l’on remonte jusqu’au niveau de l’appel principal dans l’évaluation Formula. Bien entendu, il faut donner à chaque fois la même valeur, sans quoi on aurait un résultat inconsistant !
On voit sur cet exemple l’inefficacité inhérente au passage par nom. On remarque un allongement du code objet à cause du corps des thunks.
Environnement d’exécution 291
Dans la méthode Pilum :: Executer, l’empilage de l’adresse d’un thunk et de ce qui deviendra son lien statique lorsqu’on l’évaluera est fait par l’instruction : case iEmpilerThunk: if (fSommet + 1 >= fLimiteSommet) fEtatCourant = kDebordementPile; else { ++ fSommet; fPile [fSommet].fAdresseCode = lInstruction.fAdresseCode; fPile [fSommet].fTypeValeur = kAdresseDansLeCode; // on empile le Lien Statique ++ fSommet; fPile [fSommet].fAdressePile = fEnvCourant; fPile [fSommet].fTypeValeur = kAdresseDansLaPile; } break;
L’évaluation proprement dite d’un thunk est, quant à elle, faite par : case iEvaluerThunk: if (fSommet + 1 >= fLimiteSommet) fEtatCourant = kDebordementPile; else { AdresseCode AdressePile
lAdresseDuParametre = fPile [fSommet --].fAdresseCode; leLienStatique = fPile [lAdresseDuParametre + 1].fAdressePile;
// on empile le Lien Statique ++ fSommet; fPile [fSommet].fAdressePile = leLienStatique; fPile [fSommet].fTypeValeur = kAdresseDansLaPile; // on empile l'Adresse de Retour ++ fSommet; fPile [fSommet].fAdresseCode = fInstructionCourante; fPile [fSommet].fTypeValeur = kAdresseDansLeCode; // on empile le Lien Dynamique ++ fSommet; fPile [fSommet].fAdressePile = fEnvCourant; fPile [fSommet].fTypeValeur = kAdresseDansLaPile; // on passe a l'environnement de l'appele fEnvCourant = fSommet; // on saute au debut du thunk fInstructionCourante = fPile [lAdresseDuParametre].fAdresseCode; } break;
292 Compilateurs avec C++
11.15
Exemple de passage par besoin avec Pilum
Compilons le source Formula contenant la fonction CarrePlus des deux paragraphes précédents avec passage des paramètres par besoin ; CarrePlus (x, y) = x * x + y; ? CarrePlus (LireNombre (), 6);
Le bloc d’activation de CarrePlus, juste avant de tester s’il est nécessaire de faire évaluer le thunk calculant la première occurrence de x dans son corps, soit à l’adresse 4, prend la forme présentée à la figure 11.5. EMPILE PAR: fSommet
vrai
fEnvCourant
Lien Dynamique
(LD)
-1
Adresse de Retour
(AR)
-2
Lien Statique
(LS)
appelé
iAppel
lien statique futur
-6
adresse du thunk
67
valeur
inconnu
à calculer ?
vrai
appeleur
lien statique futur
-10
adresse du thunk
58
valeur
inconnu
à calculer ?
vrai
11.7Bloc d’activation Pilum avec passage par besoin Le code objet Pilum est listé en appendice, au paragraphe A.7.3. Là encore, on constate un allongement du code objet, cette fois pour gérer la variable booléenne de contrôle de l’évaluation du thunk correspondant à un paramètre par besoin.
Environnement d’exécution 293
L’exécution de ce code objet produit comme résultat : Valeur: Veuillez taper une valeur flottante: 8 70.000000 =================
On retrouve le comportement caractéristique du passage par besoin, à savoir qu’un paramètre par besoin ne donne lieu qu’à une évaluation du thunk au plus, après quoi le booléen de contrôle indique que la valeur résultante est déjà disponible. L’affectation de la valeur du booléen de contrôle comme de la cellule contenant la valeur déjà évaluée est faite à l’aide de la l’instruction Stocker, implantée dans l’interprète de la machine Pilum par : case iStocker: fPile [fPile [fSommet - 1].fAdressePile] = fPile [fSommet]; fSommet -= 2; break; 11.16
Exemple de temporaires dans Pilum
En Formula, les fonctions prédéfinies d’itération Pour, Somme et Produit sont les seuls cas où des temporaires sont utilisés. Dans le cas de Somme, il en faut un pour la valeur courante de l’indice, un pour la borne supérieure de ce dernier, et un pour la valeur de la somme courante, soit trois en tout. Le cas de Produit est similaire, tandis qu’un appel à Pour n’a besoin que des deux premiers temporaires cités ci-dessus. Le bloc d’activation
Les instructions de la machine Pilum utilisées pour allouer et libérer les temporaires sont respectivement : case iReserver: for (long i = 0; i < lInstruction.fEntier; ++ i) { if (fSommet == fLimiteSommet) fEtatCourant = kDebordementPile; else { ++ fSommet; fPile [fSommet].fTypeValeur = kValeurInconnue; } } // for break;
et : case iDesempiler: fSommet -= lInstruction.fEntier; break;
Soit l’évaluation Formula suivante : ? Somme (i, 1, 5, i * i);
294 Compilateurs avec C++
En la compilant, on obtient au début de la troisième itération le bloc d’activation de la figure 11.5. Le fait que l’environnement pointe hors de la pile ne doit pas étonner : aucune “vraie“ fonction n’a encore été appelée, donc on voit là le bloc d’activation occupant le fond de la pile d’exécution. EMPILE PAR:
fSommet +3
borne supérieure
5
+2
i
3
+1
somme accumulée
14
appelé
fEnvCourant
11.8Bloc d’activation Pilum contenant des temporaires Le code objet Pilum est, quant à lui : 0: Reserver
3
1: 2: 3: 4: 5:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne
'Début d'une évaluation'
6: 7: 8: 9:
EmpilerAdresse Commentaire: EmpilerFlottant Stocker
0,1 'Adresse de la somme' 0.000000
10: 11: 12: 13: 14:
EmpilerAdresse Commentaire: EmpilerFlottant Stocker Commentaire:
0,2 'Indice d'iteration i' 1.000000
15: 16: 17: 18: 19:
EmpilerAdresse Commentaire: EmpilerFlottant Stocker Commentaire:
0,3 'Borne de l'indice i' 5.000000
20: 21: 22: 23: 24: 25:
EmpilerValeur Commentaire: EmpilerValeur Commentaire: InfEgaleFlottant SauterSiFaux
0,2 'Indice d'iteration i' 0,3 'Borne d'iteration i'
Valeur:
'Valeur initiale de l'indice i'
'Valeur borne de l'indice i'
44
Environnement d’exécution 295
26: Commentaire: 27: EmpilerAdresse 28: Commentaire:
'Debut de 'Somme'' 0,1 'Adresse de la somme'
29: 30: 31: 32: 33:
0,2 'Emploi de l'indice d'iteration i (no 1)' 0,2 'Emploi de l'indice d'iteration i (no 1)'
EmpilerValeur Commentaire: EmpilerValeur Commentaire: FoisFlottant
34: EmpilerValeur 35: Commentaire:
0,1 'Valeur de la somme'
36: PlusFlottant 37: Stocker 38: Commentaire:
'Cumul dans la somme'
39: 40: 41: 42: 43:
EmpilerAdresse Commentaire: IncrFlottant Commentaire: Sauter
44: EmpilerValeur 45: Commentaire: 46: Commentaire: 47: 48: 49: 50: 51: 52:
EcrireFlottant EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire:
53: Desempiler
0,2 'Indice d'iteration i' 'Incrementation de l'indice i' 20 0,1 'Valeur de la somme resultante' 'Fin de 'Somme''
================= 'Fin d'une évaluation' 3
54: Halte
Une comparaison triviale de taille entre le source Formula et le code objet Pilum correspondant montre le fosé sémantique qui esiste entre les langages Formula et Pilum. Cela donne tout son sens à l’expression “langage de haut niveau“ !
296 Compilateurs avec C++
Le résultat produit par la machine Pilum à l’exécution de ce code est : Valeur: 55.000000 =================
L’allocation de temporaires pour les emplois des fonctions prédéfinies d’itération dans le corps d’une fonction est la même que celle ci-dessus. Rappelons qu’une évaluation Formula n’est qu’une fonction anonyme sans paramètres, appelée sur le point de sa déclaration. 11.17
Passages par nom imbriqués
Que se passe-t-il lorsque l’évaluation d’un paramètre par nom ou par besoin conduit à appeler une fonction ou procédure ayant elle-même un paramètre passé par nom ou par besoin ? La réponse est que le thunk du premier paramètre mentionné ci-dessus appellera un thunk synthétisé pour le second. A titre d’exemple, voici : carre (n) = n * n; puissance4 (n) = carre (carre (n)); ? puissance4 (3);
La situation qui nous intéresse ici est celle du fragment : carre (carre (n))
qui contient un appel imbriqué comme argument d’appel à carre. On va donc créer un thunk pour le second argument de l’appel à carre contenant, qui va utiliser le thunks créé pour l’argument de l’appel à carre contenu. Quel lien statique faut-il empiler en prévision de l’exécution de ce dernier thunk ? Un thunk correspondant à un paramètre d’un appel faisant lui-même partie d’un autre thunk est lié statiquement à ce dernier en vertu de la règle de l’évaluation par nom dans le contexte de l’appel. Cela conduit donc dans le compilateur à gérer le niveau d’imbrication textuel des thunks, ce que reflètent précisément les liens statiques ! Cela n’a rien d’étonnant si l’on se souvient qu’un thunk est une fonction cachée.
Environnement d’exécution 297
En compilant le source Formula ci-dessus avec un passage de paramètres par nom, on obtient le code placé en appendice, au paragraphe A.7.4. L’auteur décline d’avance toute responsabilité en cas de migraine ! L’exécution de ce code Pilum fournit comme résultat : Valeur: 81.000000 =================
Etant donné la complexité du code synthétisé pour l’exemple ci-dessus, on apprécie tout l’intérêt qu’il y a à disposer d’un compilateur ! C’est l’approche structurée et récursive qui fait que de tels compilateurs peuvent être écrits relativement simplement, comme on le verra au chapitre 12. 11.18
Cas d’un processeur réel : le M680x0
Pour sortir du monde des machines virtuelles à pile implantées par leur interprète écrit en langage de haut niveau, voici l’exemple du processeur à registres M680x0, cette dénomination recouvrant en fait toute une famille de processeurs. Comme tous les processeurs commerciaux actuels, il dispose d’instructions facilitant les appels et les retours de procédures et fonctions. Ce processeur dispose de : •
8 registres d’adresse de 32 bits nommés A0 à A7 ;
•
8 registres de données de 32 bits nommés D0 à D7 ;
•
une pile croissant des adresses hautes vers l’adresse 0, dont le sommet est constamment repéré par A7, aussi baptisé SP (stack pointer) ;
•
un registre pointant sur la prochaine instruction à exécuter nommé PC (program counter) ;
•
un registre contenant l’état du processeur nommé PSW (processor status word).
Les codes d’instructions du 68000 en format “langage d’assemblage“ indiquent par un suffixe la taille des informations manipulées, soit “.B“ pour un octet de 8 bits (byte), “.W“ pour un mot court de 16 bits (word) et “.L“ pour un mot long de 32 bits (long word). Ainsi, l’instruction : CMP.W -$0004(A6),D7 compare les 16 bits se trouvant à l’adresse “A6-4“ avec les 16 bits de droite du registre D7, qui en contient 32.
Dans le cas du Macintosh : •
le registre A5 est un pointeur sur l’espace alloué aux variables globales. Dans le programme fonctions_imbriquees du paragraphe 11.6, cela fait que le code objet accède directement via A5 à la variable globale appels_coexistants.
298 Compilateurs avec C++
•
le bloc d’activation de la procédure en cours d’exécution, soit l’environnement courant, est repéré par le registre A6. On notera qu’on ne repère pas le début de bloc, mais une position intermédiaire, ainsi qu’on va le voir ci-dessous.
Les blocs d’activation ont dans ce cas la structure montrée à la figure 11.5. Leur EMPILE PAR: SP alias A7
Temporaires appelé
Variables Locales A6
Lien Dynamique
(LD)
4(A6)
Adresse de Retour
(AR)
Lien Statique
(LS)
8(A6)
JSR
Argument “n“
$C(A6)
appeleur
Argument “…“ Argument “1“ Valeur Retournée
(VR)
11.9Structure du bloc d’activation Pascal Macintosh construction est faite en grande partie par l’appeleur, mais elle est terminée par l’appelé. L’appeleur exécute typiquement le code suivant : CLR.x
-(A7)
; réserve valeur_de_retour ; et l'initialise à 0
MOVE.y
paramètre,-(A7)
; empile un paramètre
MOVE.L
lien_statique,-(A7)
; empile le lien statique
JSR
adresse_appel é
; saut au début du code de l’appelé ; (JSR empile adresse_de_retour)
L’appelé, quant à lui, fait : adresse_appelé: LINK A6,taille_1
; empile lien_dynamique ; reserve 'taille_1' octets pour les ; variables locales et temporaires
Environnement d’exécution 299
La du bloc d’activation est à la charge de l’appelé, qui exécute dans le cas usuel d’un appel de fonction ou procédure : UNLK
A6
; désempile lien_dynamique et ; rétablit environnement appeleur
MOVEA.L
(A7)+,A0
; désempile adresse_de_retour
ADDQ.W
#$taille_2,A7
; détruit lien_statique (4 octets) ; et les arguments d’appel
JMP
(A0)
; saut à adresse_de_retour
Dans le cas d’une procédure sans arguments appelée depuis le programme principal, comme contenante, la séquence de sortie est simplement : UNLK
A6
; désempile lien_dynamique et ; rétablit environnement appeleur ; retour à l’appeleur
RTS
La réservation de la place pour la valeur de retour d’une fonction n’a bien sûr pas de raison d’être lorsqu’on appelle une procédure. Les arguments d’appel sont empilés l’un après l’autre, dans l’ordre textuel pour Pascal, et dans l’ordre inverse pour C. Cette technique traditionnelle dans les implantations de C permet d’appeler des fonctions de ce langage en omettant certains arguments. On remarquera que toutes les tâches qui doivent être exécutées de manière identique à chaque appel on été factorisées dans le code de la procédure appelée, ce qui évite une duplication de code superflue dans les cas où les appels sont textuellement fréquents. Exemple
Soit la fonction C++ suivante : int maFonction (int n) { return n * n + 2 * (n - 1); }
Le compilateur MPW C++ sur Macintosh crée pour cette fonction le code suivant : LINK MOVE.L
A6,#$0000 D7,-(A7)
; sauvegarde D7
MOVE.L
$0008(A6),D7
; D7 := n
MOVE.L MOVE.L JSR
D7,D0 D7,D1 ULMULT
; D0 := n ; D1 := n ; id: 104 ; D0 := n * n
MOVE.L SUBQ.L ADD.L ADD.L
D7,D1 #$1,D1 D1,D1 D0,D1
; ; ; ;
MOVE.L
D1,D0
; D0 := n * n + 2 * (n - 1)
D1 D1 D1 D1
:= := := :=
n n - 1 2 * (n - 1) n * n + 2 * (n - 1)
300 Compilateurs avec C++
MOVE.L -$0004(A6),D7 ; restaure D7 UNLK A6 RTS ; retoune D0 Bien qu’il soit moins apparent que dans le cas du code d’une machine à pile comme Pilum, le côté postfixé de ce code est indéniable. Nous laissons au lecteur le soin de dessiner le graphe sémantique correspondant à cette expression.
Les différentes occurrences du registre D7, qui a été choisi par le compilateur pour implanter le paramètre formel passé par valeur n, illustrent le partage de sousgraphes qui est caractéristique des sous-expressions communes. On remarque au passage une optimisation, qui consiste à remplacer une multiplication par 2 par une addition, qui est plus rapide. Cette optimisation s’appelle réduction de puissance. Sans doute même un décalage arithmétique à gauche (arithmetic shift left) serait-il encore meilleur.
Le fragment de code objet : MOVE.L D1,D0 ; D0 := n * n + 2 * (n - 1) illustre un problème intéressant lié à l’allocation des registres. La convention de l’implantation considérée ici est que toute fonction retourne sa valeur dans le registre D0. Comme le générateur de code n’a pas une vue à suffisamment longue distance, il attribue d’abord D1 pour l’expression n * n + 2 * (n - 1) avant de se rendre compte que ce choix n’est pas très heureux, et qu’il faut re-transférer le résultat dans D0. 11.19
Optimisation des appels terminaux
Un appel de fonction est terminal lorsqu’il constitue la dernière instruction dans le flot du contrôle lors de l’exécution du corps d’une fonction. Il peut y avoir plusieurs appels terminaux dans un même corps de fonction : cette notion est relative aux différents chemins d’exécution menant à la sortie d’une fonction. L’appel à EcrireLesInstructions est terminal dans la méthode : void SynthetiseurPilum :: EcrireBinaire (ofstream * leFichier) { EcrireLesChaines (leFichier); EcrireLesInstructions (leFichier); }
Au retour de l’appel à EcrireLesInstructions la seule chose à faire est de retourner à l’appeleur de EcrireBinaire. Pour cela les seules informations nécessaires dans le bloc d’activation de cette dernière sont le lien dynamique et l’adresse
Environnement d’exécution 301
de retour. Toutes les autres sont maintenues en vie jusqu’au retour de l’appel terminal pour rien. L’optimisation des appels terminaux (last call optimization) consiste à détruire le bloc d’activation de l’appeleur juste avant l’appel terminal. Elle est utilisable même s’il ne s’agit pas d’appels récursifs. Comme cela conduit à écraser au moins partiellement le bloc d’activation de l’appeleur par celui de l’appelé, on combine cette optimisation avec le passage des paramètres dans des registres, une technique illustrée au paragraphe suivant. Elle évite parfois de créer un bloc d’activation pour une fonction. Il est alors nécessaire de gérer l’adresse de retour dans un registre dédié appelé continuation. La continuation réalise en quelque sorte un “passage de témoin“, la fonction appelée de manière terminale étant chargée de rendre le contrôle directement à l’appeleur de l’appeleur. Le contenu de la pile d’exécution pendant l’exécution de EcrireLesInstructions avec et sans optimisation des appels terminaux est illustré à la figure 11.10.
“EcrireLesInstructions“ “EcrireBinaire“
“EcrireLesInstructions“
appeleur de “EcrireBinaire“
appeleur de “EcrireBinaire“
sans optimisation
avec optimisation
11.10Pile d’exécution et optimisation des appels terminaux Exemple d’appel non terminal
Considérons le code source C suivant : int SommeCarres (int borneInf, int borneSup) { return borneInf > borneSup ? 0 : borneInf * borneInf + SommeCarres (borneInf + 1, borneSup); }
302 Compilateurs avec C++
main () { printf ("%d", SommeCarres (1, 5)); }
Dans la fonction SommeCarres, l’appel (récursif) à SommeCarres n’est pas terminal, puisque la dernière chose que l’on fait avant de sortir dans ce cas est un “+“. Le code postfixé du corps de SommeCarres, dont nous laissons l’écriture comme exercice pour le lecteur, le montre clairement. Cette absence d’appel terminal fait que le calcul de la somme des carrés conduit à l’empilement de plusieurs blocs d’activation de SommeCarres qui vont co-exister en mémoire. Leur nombre varie en fonction des bornes entre lesquelles on veut calculer la somme des carrés. Or il semble intuitivement qu’un tel calcul doit pouvoir être fait itérativement dans un espace de taille fixe sur la pile d’exécution. On peut y parvenir avec la technique des paramètres d’accumulation, présentée ci-dessous. Paramètres d’accumulation
La technique des paramètres d’accumulation permet de transformer certains appels non terminaux en des appels terminaux. L’idée des paramètres d’accumulation est que les appels successifs se passent comme argument un résultat intermédiaire dans lequel s’accumulent, au fur et à mesure, les contributions des différents appels. Le dernier appel au bout de la chaîne est chargé de retourner la valeur accumulée qu’il reçoit. On peut ainsi réécrire le calcul de la somme des carrés ci-dessus en écrivant : int SommeCarresAccu (int borneInf, int borneSup, int accu) { return borneInf > borneSup ? accu : SommeCarresAccu ( borneInf + 1, borneSup, accu + borneInf * borneInf ); } int SommeCarres (int borneInf, int borneSup) { return SommeCarresAccu (borneInf, borneSup, 0); } main () { printf ("%d", SommeCarres (1, 5)); }
Dans la fonction auxiliaire SommeCarresAccu, l’appel à SommeCarresAccu est terminal. Cette fonction reçoit une valeur accumulée accu, qu’elle enrichit avant de la passer en paramètre au prochain appel. Lorsque les bornes se rejoignent, on retourne la valeur de l’accumulateur comme résultat. Certains compilateurs Lisp et Prolog sont même capables d’ajouter automatiquement ce paramètre pour pouvoir ensuite optimiser les appels qui deviennent terminaux.
Environnement d’exécution 303
11.20
Paramètres et registres dans le PowerPC
Le nom PowerPC est lui aussi celui d’une famille de processeurs réels à registres. Sans en détailler toute l’architecture, disons simplement que ces processeurs offrent 32 registres généraux de 32 bits et 32 registres de 64 bits pour les opérations flottantes, ainsi qu’un registre de continuation appelé Link Register (registre de lien) ou LR. Le PowerPC dispose de plusieurs instructions de branchement : •
b est un saut inconditionnel ;
•
bl est un saut avec chargement de l’adresse de l’instruction suivante dans le registre continuation, soit un appel de fonction ;
•
blr est un saut au contenu de la continuation, soit un retour de fonction.
Par ailleurs, l’instruction : li
ri,const
; ri = const
charge la constante entière const dans le registre ri, tandis que : ori
ri,rj,0x0000
; ri = rj ou (rj concat 0 sur 32 bits)
a pour effet de recopier rj dans ri. Pour illustrer le passage des paramètres dans les registres et l’optimisation des appels terminaux, nous avons choisi le compilateur PPCC de l’environnement MPW sur Macintosh. Cette implantation passes les arguments d’appel successifs dans les registres r3 et suivants, et ne crée par défaut pas de bloc d’activation pour l’appel. De plus, les valeurs de fonctions sont retournées dans r3. Dans l’exemple de la somme des carrés avec paramètre d’accumulation du paragraphe précédent, cette stratégie conduit à implanter borneInf et borneSup dans r3 et r4 respectivement, et accu dans r5. Le registre r6 est utilisé comme temporaire pour calculer le carré de borneInf. Le code obtenu est très élégant : .SommeCarresAccu cmpw ble ori blr
mullw addc addic cmpw ble b
r3,r4 $+0x000C
; compare borneInf à borneSup ; si plus petit
r3,r5,0x0000
; ; ;
r6,r3,r3 r5,r5,r6 r3,r3,1 cr1,r3,r4 cr1,$-0x0010 $-0x001C
séquence de sortie veleur_retournée = accu retour à la continuation
; sinon ; début de la boucle ; temp = borneInf * borneInf ; accu += temp ; borneInf += 1 ; compare borneInf à borneSup ; si plus petit, on boucle ; sinon, séquence de sortie
304 Compilateurs avec C++
.SommeCarres li b
r5,0 .SommeCarresAccu
nop blr
; accu = 0 ; saut à SommeCarresAccu ; retour à la continuation
.main mflr stw stwu
r0 r0,0x0008(SP) SP,-0x0038(SP)
; LR = 8
li li bl
r3,1 r4,5 .SommeCarres
; borneInf= 1 ; borneSup = 1 ; appel à SommeCarres
nop ori lwz bl lwz lwz addic mtlr blr lwz
r4,r3,0x0000 ; r4 = valeur_retournée r3,.stringBase0{TC}(RTOC); ; r3 = format d’impression .printf{GL} ; appel à printf RTOC,0x0014(SP) r0,0x0040(SP) SP,SP,56 r0
; LR = 8 ; retour au système
r0,0x0000(r0)
L’optimisation des appels terminaux peut rendre itérative l’exécution de fonctions écrites récursivement, comme le montre le code de la fonction SommeCarresAccu ci-dessus. 11.21
Exercices
11.1 Dérécursification de la fonction de Fibonacci (moyen). La version Formula de cette fonction présentée au paragraphe 1.5 est un cas typique de double récursion à gauche et à droite, ce qui peut être coûteux en temps d’exécution. Ecrire une autre version de cette fonction ne présentant qu’une récursion simple, donc un seul appel récursif dans le corps. Est-il facile de passer ensuite par une seconde étape de dérécursification à une version sans récursion, donc itérative ?
Environnement d’exécution 305
306 Compilateurs avec C++
Chapitre
12
12 Synthèse du code objet
Nous avons vu au chapitre 11 comment l’environnement d’exécution est structuré. Il nous reste à voir comment on s’y prend pour synthétiser le code objet. Pour illustrer en pratique la synthèse du code, nous avons choisi de créer du code pour la machine Pilum, présentée au chapitre 11, à partir de sources Formula. Quelques techniques simples d’optimisation du code objet sont présentées. Le lecteur intéressé trouvera de plus amples détails sur les techniques classiques utilisées dans les compilateurs industriels dans [Fischer & LeBlanc 88]. 12.1
Schémas de code pour les instructions de contrôle
Le code pour les instructions de contrôle comme if, case, while, repeat et for est créé par instanciation de schémas de code. Cette instanciation est fondamentale car les schémas de contrôle implantent la sémantique de l’instruction correspondante. Les organigrammes, à l’aide desquels on enseigne parfois ces instructions, sont une représentation graphique de ces schémas de contrôle. Dans le cas de l’instruction if : if Condition then Instruction_1 else Instruction_2
le schéma que l’on instancie est montré à la figure 12.1.
308 Compilateurs avec C++
partie_else:
évaluer si_faux
Condition partie_else
exécuter sauter_à
Instruction_1 suite
exécuter
Instruction_2
suite:
12.1Schéma de code pour “if“ Dans ce schéma de code, l’instruction suivant exécuter … sont sous instances de schémas de
suite est l’adresse à laquelle se trouve le code de le if. Les séquences de code évaluer … et forme postfixée et peuvent elles-mêmes contenir des contrôle, comme on le verra au paragraphe suivant.
Dans le cas de l’instruction while : while Condition do Instruction
le modèle que l’on instancie est celui de la figure 12.2.
debut_while:
évaluer si_faux
Condition suite
exécuter sauter_à
Instruction debut_while
suite:
12.2Schéma de code pour “while“ 12.2
Traitement des instructions imbriquées
Considérons les deux instructions if imbriquées suivantes : if Condition_1 then if Condition_2 then Instruction_1 else Instruction_2
Synthèse du code objet 309
else Instruction_3
Le code pour les instructions imbriquées est synthétisé par la création d’instances de schémas de code imbriquées elle-mêmes. Le schéma du code dans cet exemple est montré à la figure 12.3.
partie_else_2:
évaluer si_faux
Condition_1 partie_else_1
évaluer si_faux
Condition_2 partie_else_2
exécuter sauter_à
Instruction_1 suite_2
exécuter
Instruction_2
sauter_à
suite_1
exécuter
Instruction_3
suite_2: partie_else_1: suite_1:
12.3Schémas de code pour “if“ imbriqués Nous avons mis en évidence par un cadre les deux instances du schéma de contrôle indiqué ci-dessus pour le if : elles sont simplement imbriquées parce que nous avons affaire à deux instructions if imbriquées.
On remarque dans cet exemple que l’on obtient une structure des instructions de saut telle que l’on fait un saut sur un saut, soit ici : sauter_à … … … …
suite_2
sauter_à
suite_1
suite_2:
Cette lourdeur du code peut être optimisée de la manière suivante. L’optimisation des sauts sur des sauts consiste à les remplacer par des sauts directs à l’adresse placée au bout de la chaîne. On montre comment la réaliser au paragraphe 12.15.
310 Compilateurs avec C++
Dans le cas des deux instructions while imbriquées suivantes : while Condition_1 do begin Instruction_1; while Condition_2 do Instruction_2 end
le schéma du code est celui de la figure 12.4. Là encore, nous avons encadré les instances de schémas de code pour les mettre en évidence.
debut_while_1:
debut_while_2:
évaluer si_faux
Condition_1 suite_1
exécuter
Instruction_1
évaluer si_faux
Condition_2 suite_2
exécuter sauter_à
Instruction_2 debut_while_2
sauter_à
debut_while_1
suite_2: suite_1:
12.4Schémas de code pour “while“ imbriqués On retrouve bien sûr dans ce cas le problème des sauts sur des sauts, qui est un phénomène typique de l’instanciation imbriquée des schémas de code.
La nature récursive des instructions imbriquées fait qu’il est très naturel de les analyser de manière récursive, comme le fait la méthode de descente récursive. Cela conduit donc à créer beaucoup de sauts sur des sauts, ce qui justifie de chercher à les optimiser. 12.3
Exemple de schémas de code Pilum imbriqués
Quand on compile le code source Formula : ? Si (Faux, Si (Vrai, 23, 45), Si (Vrai, 35, 73));
et qu’on demande au compilateur de lister le code objet Pilum, on obtient le résultat suivant : Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne
'Début d'une évaluation' Valeur:
Synthèse du code objet 311
11
Etiqu_1:
12
Etiqu_2:
EmpilerBooleen SauterSiFaux
faux 13
// Etiqu_3 (siFaux)
EmpilerBooleen SauterSiFaux
vrai 11
// Etiqu_1 (siFaux)
EmpilerFlottant Sauter
12
EmpilerFlottant
13
23.000000 // Etiqu_2 (suiteSi) 45.000000
Sauter
18
EmpilerBooleen SauterSiFaux
vrai 17
// Etiqu_6 (suiteSi)
Etiqu_3:
EmpilerFlottant Sauter 17
Etiqu_4:
18 18
Etiqu_5: Etiqu_6:
EmpilerFlottant
18
// Etiqu_4 (siFaux) 35.000000 // Etiqu_5 (suiteSi) 73.000000
EcrireFlottant EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire:
=================
'Fin d'une évaluation'
Halte
Les commentaires mettent en évidence la structure et l’imbrication des schémas de code pour le Si. Les étiquettes multiples sur une même instruction, comme Etiqu_5 et Etiqu_6, proviennent de la création récursive des instances de schémas de code. 12.4
Synthèse de code pour Pilum
Cette synthèse est faite à partir des graphes sémantiques, dont nous rappelons qu’ils contiennent la même sémantique que le code source compilé. On utilise le type énuméré : enum GenreInstrOuEtiqu { kEtiquette, kInstructonAvecChaine, kInstructionReservation, kInstructionDeSaut, kInstructionDAppel, kInstructionDeThunk, kAutreInstruction };
312 Compilateurs avec C++
Toutes les instructions et les étiquettes sont des instances de sous-classes de la classe abstraite InstrOuEtiqu, dont elles héritent les champs suivants : •
fGenreInstrOuEtiqu décrit de quel genre d’instruction ou d’étiquette il s’agit ;
•
fPrecedente et fSuivante sont des pointeurs polymorphiques sur des instances de l’une des sous-classes concrètes de InstrOuEtiqu.
Les sous-classes concrètes doivent fournir des versions des méthodes virtuelles pures EcrireTexte et EcrireBinaire, utilisées respectivement pour produire l’image “langage d’assemblage“ des instructions présentée au paragraphe précédent et pour écrire l’instruction en binaire sur un fichier. La hiérarchie de ces classes est montrée à la figure 12.5. InstrOuEtiqu Etiquette Instruction InstrAvecChaine InstrCommentaire InstrEmpilageChaine InstrReserver InstrAccesCellule InstrEmpilage InstrSaut InstrAppel InstrEmpilerThunk
12.5Hiérarchie des classes instructions/étiquettes pour la synthèse Pilum Le synthétiseur de code Pilum
Le synthétiseur de code Pilum est implanté par la classe SynthetiseurPilum, qui utilise les champs et méthodes suivants : •
fDebutDuCode et fDebutDuCode, du type InstrOuEtiquPtr sont les extrémités de la liste bidirectionnelle des instructions et étiquettes en cours de synthèse. Cette liste est gérée par les méthodes Inserer et Supprimer. La bidirectionnalité de cette liste n’apporte rien dans l’état actuel de la synthèse de code pour Pilum : elle a été mise en place pour permettre des optimisations plus drastiques dans des sous-classes de SynthetiseurPilum, comme le déplacement ou la suppression d’instructions ;
•
fCompteurEtiquettes est un entier servant à donner des numéros en séquence aux étiquettes logiques, comme on le voit au paragraphe suivant;
Synthèse du code objet 313
•
fTailleDesChaines est un entier indiquant la taille totale des chaînes de caractères à placer dans le code objet. Rappelons que ce code est écrit en binaire sur un fichier, et qu’on en trouve un exemple au paragraphe 11.9;
•
les méthodes comme Commentaire, Entier, Saut, AccesCellule, LienStatique et Thunk sont chargées de synthétiser une instruction du type correspondant. On montre leur philosophie au paragraphe suivant ;
•
Optimiser implante l’optimisation peephole, comme on le voit au paragraphe 12.14 ;
•
DeterminerLesAdresses est chargée de placer dans les instructions de saut et d’appel les adresses absolues de destination, en fin de compilation.
L’interface complete de la classe SynthetiseurPilum est listé en appendice, au paragraphe A.8.1. Gestion des blocs d’activation
La description des blocs d’activation dans le fichier est faite par la classe : class DescrActivation { typedef DescrActivation
* DescrActivationPtr;
public: DescrActivation (); short
NombreTemporairesSimultanes ();
short void
AllouerTemporaire (); LibererTemporaire ();
private: short short };
//
fDernierTemporaireAlloue; fNombreTemporairesSimultanes; DescrActivation
L’implantation correspondante est réalisée par : DescrActivation :: DescrActivation () { fDernierTemporaireAlloue = 0; fNombreTemporairesSimultanes = 0; } short DescrActivation :: NombreTemporairesSimultanes () { return fNombreTemporairesSimultanes; }
314 Compilateurs avec C++
short DescrActivation :: AllouerTemporaire () { short res = ++ fDernierTemporaireAlloue; if (fDernierTemporaireAlloue > fNombreTemporairesSimultanes) fNombreTemporairesSimultanes = fDernierTemporaireAlloue; return res; } void DescrActivation :: LibererTemporaire () { -- fDernierTemporaireAlloue; }
On trouve des exemples d’allocation de temporaires pour lecontrôle d’une itération Formula au paragraphe 11.16. Contextes de synthèse
Toutes les méthodes Synthetiser recoivent en paramètre un pointeur sur une instance de la classe ContexteSynth, qui s’appuie sur les champs suivants :
12.5
•
fSynthePilum pointe sur le synthétiseur de code Pilum à utiliser ;
•
fNiveauStatique est le niveau statique courant ;
•
fDescrActivation pointe sur la description du bloc d’activation courant, celui où seront alloués des temporaires si nécessaire ;
•
fContinuation indique à quelle adresse logique continue l’exécution après le segment de code que l’on est en train de synthétiser. Cette information est utilisée par l’optimisation des sauts sur les sauts, présentée au paragraphe 12.15. Gestion des instructions et des étiquettes
Les méthodes de base pour synthétiser les instructions Pilum manipulent la liste bidirectionnelle de manière classique, avec des indirections doubles : void SynthetiseurPilum :: Inserer (InstrOuEtiquPtr lInstrOuEtiquPtr) { if (fFinDuCode == NULL) fDebutDuCode = lInstrOuEtiquPtr; else { fFinDuCode -> Suivante (lInstrOuEtiquPtr); lInstrOuEtiquPtr -> Precedente (fFinDuCode); } fFinDuCode = lInstrOuEtiquPtr; }
Synthèse du code objet 315
void SynthetiseurPilum :: Supprimer (InstrOuEtiquRef lInstrOuEtiquRef) { InstrOuEtiquPtr aSupprimer = * lInstrOuEtiquRef; InstrOuEtiquPtr suivante = aSupprimer -> Suivante (); * lInstrOuEtiquRef = suivante; delete aSupprimer; }
Les étiquettes sont gérées à l’aide de variables logiques pour pouvoir “fusionner“ (en fait, unifier) toutes celles qui participent à une chaîne de sauts sur des sauts avec celle qui est au bout de la chaîne. C’est grâce à cela que cette optimisation est si facile à faire, comme illustré au paragraphe 12.15. Une étiquette logique est obtenue du synthétiseur de code par : VarLogEtiquPtr SynthetiseurPilum :: CreerEtiquette (char * leSuffixe ) { return new VarLogEtiqu (leSuffixe); }
On place l’étiquette à l’endroit voulu dans le code en liant cette variable logique à une étiquette “physique“ dans la liste bidirectionnelle au moyen de : void SynthetiseurPilum :: PlacerEtiquette ( VarLogEtiquPtr laVarLogEtiquPtr ) { if ( ! laVarLogEtiquPtr -> UnifierValeur ( new Etiquette (++ fCompteurEtiquettes) ) ) { cerr << "Erreur interne: echec d'unification dans PlacerEtiquette"; exit (1); } Inserer (laVarLogEtiquPtr -> ValeurLiaison ()); } void SynthetiseurPilum :: DeterminerLesAdresses () // a pour effet de bord de compter les instructions { InstrOuEtiquPtr curseur = fDebutDuCode; AdresseCode lAdresse = 0; fNombreDInstructions = 0; while (curseur != NULL) { switch (curseur -> Genre ()) { case kEtiquette: EtiquettePtr (curseur) -> AdresseConcrete (lAdresse); break; case case case case
kInstructonAvecChaine: kInstructionReservation: kInstructionDeSaut: kInstructionDAppel:
316 Compilateurs avec C++
case kInstructionDeThunk: case kAutreInstruction: ++ fNombreDInstructions; lAdresse += InstructionPtr (curseur) -> TailleInstruction (); break; } // switch curseur = curseur -> Suivante (); } // while } // SynthetiseurPilum :: DeterminerLesAdresses () 12.6
Exemple des instructions d’accès à la pile
Nous baptisons “cellule“ une valeur du type ValeurFormula placée dans la pile d’exécution. On peut vouloir obtenir l’adresse d’une telle cellule, ou vouloir accéder à la valeur qui est stockée. On définit donc le type énuméré : enum GenreAccesCellule { kPourAdresse, kPourValeur };
Une instruction d’accès à une cellule est une instruction particulière : class InstrAccesCellule : public Instruction { typedef InstrAccesCellule * InstrAccesCellulePtr; public:
};
//
InstrAccesCellule ( AccesStatique GenreAccesCellule InstrAccesCellule
lAccesStatique, leGenreAcces );
La seule méthode à ce niveau est le constructeur : InstrAccesCellule :: InstrAccesCellule ( AccesStatique lAccesStatique, GenreAccesCellule leGenreAcces ) : Instruction ( kAutreInstruction, leGenreAcces == kPourAdresse ? iEmpilerAdresse : iEmpilerValeur ) { fInstructionPilum.fAccesStatique = lAccesStatique; }
Pour synthétiser une instruction d’accès à une cellule, on utilise l’une des deux méthodes surchargées sémantiquement : void SynthetiseurPilum :: AccesCellule ( AccesStatique lAccesStatique, GenreAccesCellule leGenreAcces ) { Inserer ( new InstrAccesCellule (lAccesStatique, leGenreAcces) ); }
Synthèse du code objet 317
void SynthetiseurPilum short short GenreAccesCellule { AccesStatique
:: AccesCellule ( laDifferenceStatique, leDeplacement, leGenreAcces ) lAccesStatique;
lAccesStatique.fDifferenceStatique = laDifferenceStatique; lAccesStatique.fDeplacement = leDeplacement; this -> AccesCellule (lAccesStatique, leGenreAcces); }
Pour conclure, voici la méthode utilisée pour synthétiser une instruction l’adresse d’un bloc d’activation accédé via la chaîne statique : void SynthetiseurPilum :: LienStatique ( short niveauDAppel, short niveauDeDeclaration ) { this -> AccesCellule ( niveauDAppel - niveauDeDeclaration, 0, kPourAdresse ); } 12.7
Synthèse de code Pilum pour Formula
Nous définissons une sous-classe de SynthetiseurPilum spécifique pour la synthèse de code Pilum à partir du langage Formula : class SynthePilumFormula : public SynthetiseurPilum { typedef SynthePilumFormula * SynthePilumFormulaPtr; public: SynthePilumFormula ( char * leNom, ostream * leFlotTexte, ofstream * leFichierBinaire ); ~ SynthePilumFormula (); void
void VarLogEtiquPtr
SynthetiserDefinition ( FonctUtilisateurPtr DescrSemFormulaPtr
lIdFonction, leCorps );
SynthetiserEvaluation ( DescrSemFormulaPtr
lExpression );
SynthetiserCorpsDeThunk ( ContexteSynthPtr leContexte, DescrParamPtr laDescrParam, DescrSemFormulaPtr lArgumentDAppel );
318 Compilateurs avec C++
protected: ostream ofstream
* fFlotTexte; * fFichierBinaire;
DescrActivationPtr fDescrActivation; VarLogEntierePtr fNombreLogTemporaires; }; // SynthePilumFormula La champ fFichierBinaire pointe sur flot associé à un fichier sur lequel sera écrit le code Pilum en binaire, tandis que fFlotTexte pointe sur le flot destiné à recevoir lécriture en format “langage d’assemblage“ du code synthétisé.
Le constructeur de cette classe synthétise une instruction de réservation de place pour les temporaires qui seront utilisés par les évaluations introduites par “?“. Comme on se sait encore combien il en faut, on utilise une variable logique fNombreLogTemporaires : SynthePilumFormula :: SynthePilumFormula ( char * leNom, ostream * leFlotTexte, ofstream * leFichierBinaire ) : SynthetiseurPilum (leNom) { fFlotTexte = leFlotTexte; fFichierBinaire = leFichierBinaire; fDescrActivation = new DescrActivation; fNombreLogTemporaires = new VarLogEntiere; ReserverCellules (fNombreLogTemporaires); } // SynthePilumFormula :: SynthePilumFormula
Le destructeur, quant à lui, récupère le nombre de temporaires dans la description du bloc d’activation fDescrActivation et synthétise une instruction pour récupérer la place allouée à ces temporaires. Ensuite, il synthétise une instruction pour faire arrêter l’exécution de la machine Pilum, optimise le code et l’écrit sur le fichier en binaire : SynthePilumFormula :: ~ SynthePilumFormula () { short nbTemporaires = fDescrActivation -> NombreTemporairesSimultanes (); fNombreLogTemporaires -> UnifierValeur (nbTemporaires); Entier (iDesempiler, nbTemporaires); Zeroadique (iHalte); Optimiser (); FinaliserLeCodeBinaire (); cout << "\nCode synthétisé:\n"; EcrireTexte (fFlotTexte); EcrireBinaire (fFichierBinaire); cout << "-----------------\n";
Synthèse du code objet 319
delete fNombreLogTemporaires; delete fDescrActivation; } // SynthePilumFormula :: ~ SynthePilumFormula
La méthode SynthetiserCorpsDeThunk est listée au paragraphe 12.11. Les méthodes SynthetiserDefinition et SynthetiserEvaluation, quant à elles, sont listées dans leur version qui optimise les sauts sur les sauts au paragraphe A.8.4. Le programme principal main du compilateur Formula basé sur l’analyseur lexical prédictif et la descente récursive est très similaire àcelui de l’autre compilateur, basé lui sur Lex et Yacc. La fonction main n’est donc listée que pour ce dernier, et on la trouve au paragraphe A.8.5. 12.8
Synthèse pour les graphes sémantiques simples
On ajoute à la classe DescrSemFormula, déjà enrichie pour l’évaluation directe au paragraphe 10.7, une méthode Synthetiser chargée de la synthèse de code Pilum : virtual void
Synthetiser (ContexteSynthPtr leContexte) = 0; // virtuelle pure
Comme on s’y attend, le code des méthodes Synthetiser effectue la synthèse du code pour les opérateurs de manière postfixée. Là non plus, il n’y a pas moyen de factoriser beaucoup de code par héritage puisque on implante par ce moyen la sémantique propre de chaque type de nœud du graphe sémantique. Voici quelques exemples illustrant la manière dont est réalisée la synthèse du code pour les graphes sémantiques de Formula : void ValeurInconnue :: Synthetiser (ContexteSynthPtr leContexte) { leContexte -> SynthPilum () -> ValeurInconnue (); } void ValeurNombre :: Synthetiser (ContexteSynthPtr leContexte) { leContexte -> SynthPilum () -> Flottant (fValeurNombre); } void ValeurLogique :: Synthetiser (ContexteSynthPtr leContexte) { leContexte -> SynthPilum () -> Logique (fValeurLogique); } void ValeurVide :: Synthetiser (ContexteSynthPtr leContexte) { leContexte -> SynthPilum () -> Commentaire ("*** VIDE ***"); } void LireNombre :: Synthetiser (ContexteSynthPtr leContexte) { leContexte -> SynthPilum () -> Zeroadique (iLireFlottant); } void Non :: Synthetiser (ContexteSynthPtr leContexte) { fOperande -> Synthetiser (leContexte);
320 Compilateurs avec C++
leContexte -> SynthPilum () -> Zeroadique (iNon); } void Racine :: Synthetiser (ContexteSynthPtr leContexte) { fOperande -> Synthetiser (leContexte); leContexte -> SynthPilum () -> Zeroadique (iRacine); } void EcrireBooleen :: Synthetiser (ContexteSynthPtr leContexte) { fOperande -> Synthetiser (leContexte); leContexte -> SynthPilum () -> Zeroadique (iEcrireBooleen); }
Les opérateurs binaires ne présentent pas de problème particulier : void Et :: Synthetiser (ContexteSynthPtr leContexte) { fOperandeGauche -> Synthetiser (leContexte); fOperandeDroit -> Synthetiser (leContexte); leContexte -> SynthPilum () -> Zeroadique (iEt); } void DivisePar :: Synthetiser (ContexteSynthPtr leContexte) { fOperandeGauche -> Synthetiser (leContexte); fOperandeDroit -> Synthetiser (leContexte); leContexte -> SynthPilum () -> Zeroadique (iDiviseFlottant); }
C’est en effet à la machine Pilum de se préoccuper d’éventuels court-circuits dans les opérateurs logiques et de dépister les divisions par 0. Le code pour la fonction de séquencement Seq est synthétisé par la méthode suivante, qui fait en sorte que la valeur adéquate soit retournée comme résultat de la séquence : void Seq :: Synthetiser (ContexteSynthPtr leContexte) { fOperandeGauche -> Synthetiser (leContexte); TypePtr
leTypeGauche = fOperandeGauche -> TypeLogique () -> ValeurLiaison ();
switch (leTypeGauche-> GenreType ()) { case kTypeNombre: case kTypeBooleen: leContexte -> SynthPilum () -> Entier (iDesempiler, 1); // on jette la valeur gauche break;
Synthèse du code objet 321
case kTypeVide: // RIEN A FAIRE break; } // switch fOperandeDroit -> Synthetiser (leContexte); } // Seq :: Synthetiser
Le traitement de la synthèse pour Seq1 est présenté en appendice, au paragraphe A.8.2. Enfin, la synthèse du code pour la fonction Si est faite par : void Si :: Synthetiser (ContexteSynthPtr leContexte) { SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); VarLogEtiquPtr etiquSiFaux = synth -> CreerEtiquette ("siFaux"); VarLogEtiquPtr etiquSuite = synth -> CreerEtiquette ("suiteSi"); fCondition -> Synthetiser (leContexte); synth -> Saut (iSauterSiFaux, etiquSiFaux); fValeurSiVrai -> Synthetiser (leContexte); synth -> Saut (iSauter, etiquSuite); synth -> PlacerEtiquette (etiquSiFaux); fValeurSiFaux -> Synthetiser (leContexte); synth -> PlacerEtiquette (etiquSuite); } // Si :: Synthetiser
La synthèse de code Pilum pour les emplois des paramètres formels des fonctions utilisateurs fait l’objet des paragraphes suivants. 12.9
Synthèse pour les emplois des paramètres
Ce cas de paramètre ne pose pas de problème particulier : il suffit de faire empiler sa valeur contenue dans le bloc d’activation de la fonction dont il est un paramètre. Le commentaire synthétisé à la suite est destiné au lecteur humain : void EmploiParamParValeur :: Synthetiser (ContexteSynthPtr leContexte) { DescrParamPtr laDescrParam = fParamFormel -> DescrParam (); SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); // //
on fait charger la valeur du parametre: tous les parametres sont au niveau 0
synth -> AccesCellule ( leContexte -> NiveauStatique (), laDescrParam -> PositionDansLeBloc (), kPourValeur ); synth -> Commentaire ( form ( "Par valeur %s (no %d)",
322 Compilateurs avec C++
laDescrParam -> ParamFormel () -> Nom (), laDescrParam -> NumeroDeParametre () )); } / EmploiParamParValeur :: Synthetiser
La synthèse du code pour les emplois des paramètres par nom est faite par la méthode : void EmploiParamParNom :: Synthetiser (ContexteSynthPtr leContexte) { DescrParamPtr laDescrParam = fParamFormel -> DescrParam (); SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); // //
on fait charger l'adresse du thunk: tous les parametres sont au niveau 0
synth -> AccesCellule ( leContexte -> NiveauStatique (), laDescrParam -> PositionDansLeBloc (), kPourAdresse ); synth -> Commentaire ( form ( "Thunk du par nom %s (no %d)", laDescrParam -> ParamFormel () -> Nom (), laDescrParam -> NumeroDeParametre () )); // puis on le fait evaluer: synth -> Zeroadique (iEvaluerThunk); } // EmploiParamParNom :: Synthetiser
Dans le cas des paramètres par besoin on doit synthétiser une instruction conditionnelle testant la variable booléenne de contrôle et faisant évaluer et mémoriser le résultat du thunk s’il n’a pas encore été évalué. Tout cela est un plus laborieux que les cas de synthèse rencontrés ci-dessus et est présenté en appendice, au paragraphe A.8.3. 12.10
Synthèse pour les arguments d’appel
Pour gérer la synthèse du code pour les arguments correspondant aux trois cas de passage de paramètre que nous implantons en Formula, nous avons enrichissons le type DescrParam avec la méthode : virtual void
Synthetiser ( ContexteSynthPtr leContexte, DescrSemFormulaPtr lArgumentDAppel ) = 0; // virtuelle pure Les différents modes de passage disponibles en Formula sont décrits par trois sous-classes concrètes de DescrParam.
Synthèse du code objet 323
Dans le cas d’un argument d’appel passé par valeur, la synthèse du code est réalisée simplement par la méhode suivante qui fait empiler la valeur de l’argument d’appel : void DescrParamParValeur :: Synthetiser ( ContexteSynthPtr leContexte, DescrSemFormulaPtr lArgumentDAppel ) { // on evalue l'argument AVANT d'entrer dans la fonction lArgumentDAppel -> Synthetiser (leContexte); }
La synthèse du code pour un argument d’appel passé par nom est faite par la méthode : void DescrParamParNom :: Synthetiser ( ContexteSynthPtr leContexte, DescrSemFormulaPtr lArgumentDAppel ) { SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); // //
on passe l'argument NON EVALUE à la fonction: pour cela on cree un THUNK
ContexteSynth
VarLogEtiquPtr
nouveauContexte synth, leContexte -> leContexte -> leContexte ->
( NiveauStatique () + 1, DescrActivation (), Continuation () );
lEtiquetteDuThunk = synth -> SynthetiserCorpsDeThunk ( & nouveauContexte, this, lArgumentDAppel );
synth -> Thunk (lEtiquetteDuThunk); } // DescrParamParNom :: Synthetiser
par :
Enfin, le cas d’un argument d’appel passé de manière paresseuse est traité void DescrParamParEsseux :: Synthetiser ( ContexteSynthPtr leContexte, DescrSemFormulaPtr lArgumentDAppel ) { SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); // // // //
on empile le booleen de controle (faux initialement), on alloue la place pour la valeur, et on passe l'argument NON EVALUE à la fonction: pour cela on cree un THUNK
ContexteSynth
nouveauContexte synth, leContexte -> leContexte -> leContexte ->
( NiveauStatique () + 1, DescrActivation (), Continuation () );
324 Compilateurs avec C++
VarLogEtiquPtr lEtiquetteDuThunk = synth -> SynthetiserCorpsDeThunk ( & nouveauContexte, this, lArgumentDAppel ); synth -> Logique (true); // le thunk doit encore etre evalue une premiere fois synth -> ValeurInconnue (); // valeur neutre avant evaluation effective synth -> Thunk (lEtiquetteDuThunk); } // DescrParamParEsseux :: Synthetiser 12.11
Synthèse du corps des thunks
Voici comment est synthétisé le code pour les thunks lors des passages de paramètres par nom et par besoin, où l’on voit la mise en place du saut par-dessus le code du thunk afin qu’il ne soit pas exécuté “dans la foulée“ : VarLogEtiquPtr SynthePilumFormula :: SynthetiserCorpsDeThunk ( ContexteSynthPtr leContexte, DescrParamPtr laDescrParam, DescrSemFormulaPtr lArgumentDAppel ) { ParamFormelPtr leParamFormel = laDescrParam -> ParamFormel (); char * leNom = leParamFormel -> Nom (); VarLogEtiquPtr
etiquDuThunk = CreerEtiquette ("thunk");
VarLogEtiquPtr
etiquApresThunk = CreerEtiquette ("apresThunk");
Saut (iSauter, etiquApresThunk); PlacerEtiquette (etiquDuThunk); Commentaire (form ("Début du Thunk pour \"%s\"", leNom)); lArgumentDAppel -> Synthetiser (leContexte); Entier (iRetourDeFonction, 1); Commentaire ("soit 1 pour le LS de ce thunk"); Commentaire (form ("Fin du Thunk pour \"%s\"", leNom)); PlacerEtiquette (etiquApresThunk); return etiquDuThunk; } // SynthePilumFormula :: SynthetiserCorpsDeThunk
Synthèse du code objet 325
12.12
Synthèse pour les appels de fonction
Les nœuds sémantiques décrivant les appels de fonction définies dans le source Formula compilé sont de la classe AppelDeFonction, et le code correspondant est synthétisé par la méthode : void AppelDeFonction :: Synthetiser (ContexteSynthPtr leContexte) { SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); ListeParamsPtr listeDescrParams = fFonctUtilisateur -> ListeParams (); short nombreDeParametres = listeDescrParams -> NombreDeParametres (); if (nombreDeParametres != 0) { // on empile les arguments IterateurParams DescrParamPtr short
iter (listeDescrParams); parametreCourant; i;
for ( (i = 0, parametreCourant = iter.PremierElement ()); iter.IlResteDesElements (); (++ i, parametreCourant = iter.ElementSuivant ()) ) parametreCourant -> Synthetiser ( leContexte, fArgumentsDAppel [i] ); if (fFonctUtilisateur -> LienStatiqueNecessaire ()) { // il faut empiler le LIEN STATIQUE // de la fonction APPELEE //
toutes les fonctions sont declarees GLOBALEMENT en Formula
synth -> LienStatique (leContexte -> NiveauStatique (), 0 ); synth -> Commentaire ("Lien Statique pour l'appel de fonction"); } } // if synth -> Appel (fFonctUtilisateur -> EtiquetteDuCorps ()); synth -> Commentaire (fFonctUtilisateur -> Nom ()); } // AppelDeFonction :: Synthetiser
Cette méthode utilise la notion d’itérateur sur la liste des paramètres présentée au paragraphe 8.24.
326 Compilateurs avec C++
12.13
Synthèse de code depuis Yacc
La synthèse de code depuis les actions sémantiques d’une grammaire Yacc ne pose pas de problème majeur. Toutefois on a une moins bonne structuration des variables que dans la descente récursive car on ne dispose pas de la gestion de variables automatiques comme dans cette dernière. Dans le compilateur Formula basé sur Lex et Yacc, le dernier terminal lu est décrit par l’union : %union /* Description du terminal courant */ { float fNombre; DescrIdent fDescrIdent; FonctUtilisateurPtr DescrSemFormulaPtr }
fFonctUtilisateur; fGrapheSemantique;
Les différentes productions et actions sémantiques de la grammaire Yacc de Formula se communiquent des informations via les deux champs fFonctUtilisateur et fGrapheSemantique. De plus, les notions AppelDeFonction et Iteration, laquelle n’est pas listée ici, ont besoin d’une pile de descriptions des appels pour gérer des variables locales propres à chacune. Cette pile est analogue à la pile d’exécution sous-jacente dans la descente récursive. Cela conduit à la production AppelDeFonction suivante : AppelDeFonction : IDENT { gAnalyseurFormula -> TraiterDebutAppelFonction ($1); /* empile une description d'appel */ } PAR_GAUCHE Arguments /* utilise la description d'appel */ PAR_DROITE { $$ = gAnalyseurFormula -> TraiterFinAppelFonction (); /* désempile la description d'appel */ } ;
Les méthodes Traiter… effectuent un travail similaire aux traitements sémantiques greffés sur la descente récursive au chapitre 8, et ne seront pas détaillées ici.
Synthèse du code objet 327
12.14
Optimisation peephole
Comme on l’a vu avec le problème des sauts sur des sauts, la synthèse du code objet est faite dans des fonctions qui travaillent un peu “en aveugle“, sans connaître le contexte dans lequel le code qu’elles synthétisent va s’insérer. Il est donc bon d’essayer d’améliorer le code objet après l’avoir synthétisé. Dans la technique peephole (un trou pour guigner) on procède en considérant chaque instruction pour elle-même et par rapport à celles qui l’entourent, à travers une petite “fenêtre“, d’où le nom de la méthode. On peut aussi gérer le “voisinage“ de l’instruction courante en termes de flot du contrôle. Toute amélioration du code obtenue peut rendre possible d’autres améliorations qui ne l’étaient pas avant. Le peephole permet de contourner les limitations de la synthèse par instanciation de schémas de code, qui a qu’une vue récursive du code. Dans le peephole, on procède par saturation, jusqu’à ce que plus aucune amélioration ne soit possible. On voit la saturation à l’œuvre au paragraphe 12.17.
Le compilateur optimisant Bliss décrit dans [Wulf, Johnsson & al. 75] illustre bien ce phénomène. C’est par saturation qu’il effectue l’optimisation des sauts sur les sauts et d’autres améliorations du code. Il gagne encore de manière sensible sur la qualité et la taille du code objet grâce à toutes les heuristiques mises en œuvre dans le peephole. Notre compilateur Formula/Pilum utilise le peephole pour supprimer des instructions de réservation et de destruction de temporaires superflues parce que leur argument est 0. Cela est nécessaire parce qu’on ne sait pas encore, lorsqu’on synthétise l’instruction iReserver par exemple, combien de temporaires seront nécessaires. Ce n’est que plus tard, lorsque tout le corps de la fonction a été compilé, qu’on connaît cette information. Le peephole est une passe de compilation en soi, puisqu’on repasse sur tout le code en cours de synthèse. Dans notre synthétiseur de code Pilum, le peephole est fait dans la méthode : void SynthetiseurPilum :: Optimiser () // OPTIMISATION PEEPHOLE { InstrOuEtiquRef curseurRef = & fDebutDuCode; while ((* curseurRef) != NULL) { switch ((* curseurRef) -> Genre ()) { case kInstructionReservation: {
328 Compilateurs avec C++
InstrReserverPtr VarLogEntierePtr
leReserverPtr = InstrReserverPtr (* curseurRef); leNombreLogTemporaires = leReserverPtr -> NombreLogTemporaires ();
if (! leNombreLogTemporaires -> EstLibre ()) if (leNombreLogTemporaires -> ValeurLiaison () == 0) // on supprime cette instruction this -> Supprimer (curseurRef); } break; case kAutreInstruction: { InstructionPtr lInstructionPtr = InstructionPtr (* curseurRef); InstructionPilum lInstructionPilum = lInstructionPtr -> GetInstructionPilum (); if (lInstructionPilum.fCodeOpPilum == iDesempiler) if (lInstructionPilum.fEntier == 0) // on supprime cette instruction this -> Supprimer (curseurRef); } break; case kEtiquette: case kInstructonAvecChaine: case kInstructionDeSaut: case kInstructionDAppel: case kInstructionDeThunk: break; } // switch curseurRef = (* curseurRef) -> RefSuivante (); } // while } // SynthetiseurPilum :: Optimiser
Cette méthode utilise la technique de double indirection, au moyen de: class InstrOuEtiqu { typedef InstrOuEtiqu typedef InstrOuEtiquPtr … … … … …
* InstrOuEtiquPtr; * InstrOuEtiquRef;
pour pouvoir facilement manipuler la liste à simple sens des instructions ou étiquettes contenant le code synthétisé. De manière analogue à l’acceptation d’un sur-langage, il est souvent plus facile de synthétiser du code imparfait puis de le “nettoyer“ avec une optimisation peephole que de le faire “propre“ directement.
Synthèse du code objet 329
12.15
Optimisation des sauts sur des sauts
On peut faire cette optimisation à très peu de frais si le code que l’on synthétise est en langage d’assemblage : il suffit d’utiliser des déclarations d’étiquettes, comme : suite_2 EQUATE suite_1 pour faire que l’on saute directement à l’adresse en bout de chaîne. C’est alors l’assembleur qui fera le travail !
Dans le cas où l’on crée du code binaire pour la machine cible, comme c’est le cas pour Formula/Pilum, on peut faire cette optimisation avec des variables logiques, comme on le montre ci-dessous. L’idée est de gérer une continuation qui est une variable logique contenant, après liaison, l’étiquette du code qui va être synthétisé à la suite de celui en cours de synthèse. Rappelons que la même notion de continuation est employée dans l’optimisation des appels terminaux, comme illustré au paragraphe 11.19. Les instructions qui doivent être traitées spécialement sont celles qui sont concernées par le flot du contrôle. Dans notre cas, le séquencement et la conditionnelle tombent dans cette catégorie, mais pas les fonctions d’itération Somme, Produit et Pour. En effet, ces dernières fonctions sont telles que leur quatrième argument est toujours suivi par du code de contrôle de l’itération : la continuation pour cet argument n’est donc pas la même que pour l’itération toute entière. Le code source correspondant est présenté en appendice, au paragraphe A.8.4. En compilant avec le compilateur ainsi modifié le source Formula suivant : ? Si (Faux, Si (Vrai, 23, 45), Si (Vrai, 35, 73));
on obtient le graphe sémantique : Si faux Si vrai 23.000000 45.000000 Si vrai 35.000000 73.000000 -----------------
et le code objet Pilum : 0: 1: 2: 3: 4: 5: 6:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne EmpilerBooleen SauterSiFaux
7: EmpilerBooleen 8: SauterSiFaux
'Début d'une évaluation' Valeur: faux 13 vrai 11
330 Compilateurs avec C++
9: EmpilerFlottant 10: Sauter
18
11: EmpilerFlottant 12: Sauter
18
13: EmpilerBooleen 14: SauterSiFaux
vrai 17
15: EmpilerFlottant 16: Sauter
18
17: 18: 19: 20: 21: 22: 23: 24:
23.000000 45.000000
35.000000
EmpilerFlottant EcrireFlottant EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire: Halte
73.000000 ================= 'Fin d'une évaluation'
On remarque que sur les quatre “branches“ que comportent les deux appels à Si, trois se terminent par le saut direct à l’adresse 18 qui est celle qui fait écrire le résultat de l’évaluation. La quatrième branche n’en a pas besoin, parce que le séquencement du contrôle fait qu’on arrive à l’étiquette 19. Nous n’avons pas géré les sauts sur les sauts servant à éviter les corps de fonctions et de thunks dans le flot des évaluations introduites par “?“ en Formula. Cela fait l’objet de l’exercice 12.1. 12.16
Gestion simple des registres
Voici des extraits du compilateur Newton original illustrant la gestion des registres et la synthèse de code en langage d’assemblage. La technique utilisée est directement inspirée de celle employée par Amann dans le premier compilateur Pascal, décrit dans [Amman 75]. On s’appuie ici sur les déclarations de types utilisées par l’analyse sémantique, figurant au paragraphe 8.27, auxquelles on ajoute celles décrivant les registres de la machine cible : const low_x = 0; high_x = 7; type xreg_no = low_x .. high_x; xreg_contents = available, konstant, simple_cont, indirect_cont, indexed_cont, … … … … …,
(* (* (* (* (*
registre libre *) le contenu est une constante *) accès par prof. statique et déplacement *) accès par un pointeur *) accès par index, i.e.éléments de tableaux *)
Synthèse du code objet 331
other );
(* valeur résultant d'une autre évaluation *)
load_easiness = (easy, medium, difficult, worst); (* utilisée par l’heuristique d’attribution des registres *) xreg_status = packed record ref_count:integer; x_ty: ty_descr_pt; last_use:integer; … … … … … case x_cont: xreg_contents of konstant: (word_val: word; easy_to_load: load_easiness); simple_cont: (x_s_lev: static_level; x_depl: integer); indirect_cont: (link_reg: xreg_no; x_displ: integer); indexed_cont; (base_reg: xreg_no; indx_reg: xreg_no); … … … … … ; available, other: () end; (* xreg_status *) xregs_status = array [xreg_no] of xreg_status;
On dispose de plus des procédures et fonctions suivantes : function need_xreg (low, high: xreg_no): xreg_no; (* retourne un registre libre entre ’low’ et ’high’ *) procedure save_used_regs (var save: saved_regs, …, …); (* sauvegarde l’etat des registres dans ’save’ *) procedure reload_used_regs (var save: saved_regs, …, …); (* recharge l’etat des registres depuis ’save’ *) const std_res_xreg = 6;
(* X6 est le registre résultat standard *)
procedure three_reg_load ( reg_1, reg_2, reg_3: xreg_no; dest_1, dest_2, dest_3: xreg_no ); (* amène le contenu des registres ’reg_i’ dans les ’dest_i’ *) function load_opd (var opd: opd_descr): xreg_no; (* fait charger la valeur de ’opd’ dans un registre à déterminer *) procedure shapen (var opd: opd_descr; shape: result_shape); (* fait déréférencer s’il y a lieu de référence à variable et de variable à valeur *) procedure force (var opd: opd_descr; tupe: ty_descr_pt); (* fait convertir ’opd’ au type ’tupe’ si nécessaire *)
Mentionnons encore la variable globale : var
curr_opd: opd_descr;
332 Compilateurs avec C++
décrivant un nœud d’un graphe sémantique non construit explicitement, de manière similaire à ce qui est fait pour DiaLog au paragraphe 8.16. Le premier cas intéressant est celui des fonctions mathématiques prédéfinies à un argument réel : sqrt_act, exp_act, ln_act, sin_act, cos_act, arctan_act: begin next_synt; if open_name = lpar_open then next_synt else message (225, trivial, blanks); stat (strong, real_std, val_shape); I := load_opd (curr_opd);
(* argument *)
release_xreg (I); protected_regs := [I]; save_used_regs (save, true, false); if I <> std_res_reg then gen_breg (op_code ('B', result_reg), xreg_name [I]); gen_call_std (id_act); if close_name = rpar_close then next_synt else message (226, trivial, blanks) end;
On voit là le traitement du cas où le registre alloué à l’opérande n’est pas celui qui est attendu par la routine de support d’exécution, auquel cas on recopie Xi dans X6. La gestion de préférences pour les registres, présentée au paragraphe 12.18, évite ce genre de lourdeur. Le second exemple que nous présentons est celui des opérateurs prédéfinis min et max, qui illustrent la gestion des étiquettes : min_op, max_op: case ty of int_ty, char_ty, scalar_ty, real_ty: begin lab := new_label; force (opd_2, opd_ty); I := load_opd (opd_1); J:= load_opd (opd_2); release_xreg (I); K := need_xreg (low_x, high_x); if K <> I then gen_breg ( op_code ('B', xreg_name [K]), xreg_name [I] ); L := need_xreg (low_x, high_x); if ty = real_ty then gen_breg ( op_code ('B', xreg_name [L]), xreg_name [I], '-', xreg_name [J] ) else
Synthèse du code objet 333
gen_breg ( op_code ('I', xreg_name [L]), xreg_name [I], '-', xreg_name [J] ); if op = max_op then gen_breglab ("PL", xreg_name [L], lab) else gen_breglab ("NG", xreg_name [L], lab) gen_breg (op_code ('B', xreg_name [K]), xreg_name [J]); gen_label (lab); release_xreg (J); release_xreg (L); with res do begin opd_ty := opd_1.opd_ty; opd_shape := val_shape; kind := expr_opd; expr_reg := K end end; string_ty: string_dyadic; else: err := 337; end; (* case *) On voit que le schéma de code instancié dans ce cas est un if, l’étiquette lab désignant le code qui suit ce if et qui n’a pas encore été synthétisé. 12.17
Optimisations classiques
Diverses techniques d’optimisation sont utilisées dans les compilateurs industriels, dont le lecteur trouvera une présentation dans [Aho, Sethi & Ullman 88] et [Fischer & LeBlanc 88]. On distingue entre celles qui sont indépendantes de l’architecture cible et celles qui ne le sont pas. Certaines de ces techniques d’optimisation peuvent sembler inutiles au premier abord parce qu’un bon programmeur éviterait décrire du code s’y prêtant. Leur intérêt réside dans le fait qu’elles peuvent devenir applicables à la suite d’autres optimisations : on les applique typiquement par saturation, comme le peephole, jusqu’à ce plus aucune ne puisse être mise en œuvre. Parmi les optimisations indépendantes de l’architecture, on peut citer : •
l’évaluation des expressions constantes (constant folding) qui consiste à faire le plus possible du travail de calcul à la compilation plutôt qu’à l’exécution. Ainsi : ? carre (3 * 4 + Abs (-3)) + 6;
serait alors compilé comme : ? carre (15) + 6;
334 Compilateurs avec C++
•
la propagation des expressions constantes (constant propagation) dans laquelle on remplace une variable par la valeur qui lui a été affectée si elle est constante : j = 9; k = j * 2;
est alors compilé comme : j = 9; k = 18;
•
la reconnaissance de sous-expressions communes (common subexpression detection), comme dans : var1 = b + c * d; … … … proc (c * d + z);
qui devient : un_temporaire = c * d; var1 = b + un_temporaire; proc (un_temporaire + z);
Cette technique est surtout intéressante si la sous-expression commune est très complexe ou si elle peut être allouée dans un registre. Des cas typiques de sous-expressions communes sont les accès à des niveaux statiques englobants par remontée dans la chaîne statique ou à des variables accédées par des pointeurs, ainsi que l’accès aux éléments des tableaux. On voit un exemple à la fin de ce paragraphe ; •
la suppression d’affectations superflues (dead store elimination), par exemple parce que la valeur constante affectée a été propagée ;
•
le déplacement de code invariant (invariant code motion) pour ne pas exécuter plusieurs fois du code donnant à chaque fois le même résultat. Ainsi : for (i = 0; i < taille; ++ i) tab [i] = x + y + i * i;
devient : un_temporaire = x + y; for (i = 0; i < taille; ++ i) tab [i] = un_temporaire + i * i;
•
la réduction de puissance (strength reduction) qui consiste à remplacer une opération par une autre moins coûteuse mais ayant le même effet. Ainsi, la multiplication entière i * 4 devient le décalage arithmétique à gauche i << 2 ;
•
l’élimination d’indice de boucle (induction variable elimination), dont on trouve un exemple à la fin de ce paragraphe ;
Synthèse du code objet 335
•
le développement de boucles (loop unrolling) dans lequel on développe tout ou partie des instructions qui seront exécutées dans la boucle. On passe moins de temps à gérer la boucle, mais le code grossit : for (i = 0; i < 3; ++ i) tab [i] = tab [i] + b[i];
devient Ainsi : tab [0] = tab [0] + b[0]; tab [1] = tab [1] + b[1]; tab [2] = tab [2] + b[2];
•
la fusion de boucles (loop jamming) dans laquelle deux boucles ayant le même “profil“ d’itération et dont les calculs sont indépendants sont fusionnées en une seule boucle. Ainsi, le code : for (i = 0; i < taille; ++ i) tab1 [i] = 0; for (i = 0; i < taille; ++ i) tab2 [i] = aux[i] + y;
devient : for (i = 0; i < taille; ++ i) { tab1 [i] = 0; tab2 [i] = aux[i] + y; }
•
la gestion du flot du contrôle, qui a de nombreuses applications. Par exemple, sachant qu’un paramètre passé par besoin a déjà été évalué dans le flot du contrôle menant du point d’entrée d’une fonction à l’expression actuelle, on pourrait consulter directement sa valeur sans même tester le booléen de contrôle. On raisonne dans ce contexte en termes de blocs de base (basic block) : un tel bloc est une séquence d’instructions dans le code objet ayant une entrée et une sortie, sans débranchement intermédiaire. [Campan & al. 75] décrit un optimiseur associant des propriétés booléennes aux variables et aux expressions du source compilé, comme la disponibilité en entrée d’un segment de code ou l’anticipabilité locale. On est ensuite amemé à résoudre des systèmes d’équations booléennes pour réaliser l’optimisation ;
•
les optimisations interprocédurales, qui appliquent les diverses techniques mentionnées ci-dessus par-delà les appels, et essaient de développer le code des fonctions en ligne (inline) à chaque appel pour ne pas payer le prix d’un appel de fonction, mais au prix d’une augmentation de la taille du code.
336 Compilateurs avec C++
Parmi les optimisations dépendantes de l’architecture cible, on peut citer : •
le choix des instructions particulières. Ainsi, dans la gamme des processeurs PowerPC, le moyen le plus efficace pour ne rien faire (no operation) est de faire une opération “ou“ ;
•
le remplacement des opérations vides sémantiquement par des instructions utiles. Ainsi, lorsque le compilateur Newton original devait compléter un mot de 60 bits par une instruction de 15 bits, il dupliquait le contenu d’un registre d’index Bi dans un autre, pour enrichir le contenu des registres, plutôt que de synthétiser un “no operation“ ;
•
l’exploitation des particularités d’un processeur particulier. Par exemple, les processeurs RISC actuels décodent des instructions en parallèle avec l’exécution de la ou des instruction(s) précédentes. Dans le cas des tests pouvant conduire à un débranchement, on peut avoir intérêt à jouer sur les instructions suivant le test pour tirer parti de ce phénomène.
Le compilateur Bliss décrit dans [Wulf, Johnsson & al. 75] regorge d’heuristiques d’optimisation fines exploitant l’architecture cible. Exemple
En partant du code source C++ : for (i = 0; i < taille; ++ i) tab [i] = i * 4;
on passe par réduction de puissance de “*4“ en “+4“ à : un_temporaire = 0; for (i = 0; i < taille; ++ i) { tab [i] = un_temporaire; un_temporaire += 4; }
puis par réduction de puissance de “[i]“ en “+1“ à : un_temporaire = 0; adresse_tab = & tab; for (i = 0; i < taille; ++ i) { * adresse_tab ++ = un_temporaire; un_temporaire += 4; }
et, enfin, par élimination de l’indice de boucle i devenu inutile et introduction à sa place de l’indice un_temporaire, à : adresse_tab = & tab; for (un_temporaire = 0; un_temporaire < taille * 4; ++ un_temporaire) * adresse_tab ++ = un_temporaire;
Synthèse du code objet 337
Le programmeur aurait pu écrire ce code en C++, mais au prix d’une perte de lisibilité indéniable. En revanche, dans un langage comme Ada qui ne permet pas la prise d’adresse d’une variable, on doit s’appuyer sur les optimisations des compilateurs. 12.18
Gestion moderne des registres et temporaires
Voici pour conclure ce chapitre sur la synthèse du code objet les grandes lignes des algorithmes utilisés dans un compilateur Prolog expérimental développé par l’auteur. Il est écrit en Pascal, comporte quatre passes et s’inspire d’un autocompilateur Prolog décrit dans [Van Roy 84], mais ce dernier utilise un nombre de passes beaucoup plus élévé. Ce compilateur crée du code pour la machine virtuelle PLM (Prolog Language Machine) créée par Warren [Warren 83] pour Prolog. Elle peut être implantée par un interprète comme celui de Pilum ou, au contraire, être coulée dans le moule d’une machine réelle. Certaines implantations de la PLM ont même été faites au niveau du microcode. Le contenu de ce paragraphe est centré sur la compilation de Prolog, mais l’allocation fine des registres et des temporaires décrite ici est applicable à d’autres langages, comme on l’a vu au paragraphe 11.20. Parmi les techniques mises en œuvre dans ce compilateur, citons : •
le passage systématique des paramètres dans des registres ;
•
l’implantation des variables dans des registres de la machine : ce n’est que lorsqu’une variable doit survivre à un appel de procédure que l’on sauvegarde sa valeur dans un bloc d’activation sur la pile ;
•
l’allocation la plus tardive possible des blocs d’activation dans l’espoir qu’un échec d’unification les rendra inutiles ;
•
l’optimisation des appels terminaux ;
•
l’écrémage des variables après leur dernière utilisation (environment trimming), ce qui permet de diminuer la taille des blocs d’activation au fur et à mesure de l’exécution du corps des clauses.
Ce compilateur expérimental est structuré comme suit : •
la passe 1 fait l’analyse lexicale et syntaxique de Prolog à l’aide d’une grammaire d’opérateurs analysée par descente récursive sur les priorités, une technique empruntée à C-Prolog 1.5 ;
•
la passe 2 construit les arbres sémantiques sous la forme d’un squelette de code objet PLM et détermine pour chaque variable sa première et dernière occurrence, ainsi que le nombre total de ses occurrences ;
338 Compilateurs avec C++
•
la passe 3 est consacrée à l’allocation de place pour les variables dans les registres et les temporaires sur la pile en tenant compte dans ce dernier cas de l’ordre de dernière occurrence des variables ;
•
la passe 4 enfin effectue une optimisation peephole supprimant les instructions PLM vides sémantiquement et effectuant la mise en forme finale du code. Elle insère notamment les instructions allocate et deallocate si un bloc d’activation est nécessaire.
Les deux dernières passes travaillent en mémoire sur l’arbre sémantique créé par le passe 2 et ne sont exécutées que pour les clauses contenant des variables non anonymes (ayant 2 occurrences au moins) ou des cut “!“. Le registre alloué à une variable est décrit par une variable logique : elle est libre initialement, puis sera instanciée plus tard. Le même registre peut être alloué à plusieurs variables successivement pour autant que les durées de vie de ces dernières soient disjointes.
La compilation de la clause Prolog : concat( [ Tete | Reste ], Liste, [Tete | NouveauReste ] ) :concat( Reste, Liste, NouveauReste ).
conduit au travail décrit ci-dessous. Les passe 1 et passe 2 constuisent le squelette : 1 2 3
-
get_pair unify_temp_var unify_temp_var
R1 R_Tete R_Reste
4
-
get_temp_var
R2, R_Liste
5 6 7
-
get_pair unify_temp_val unify_temp_var
R3 R_Tete R_NouveauReste
8 9 10
+ + +
put_temp_val put_temp_val put_temp_val
R_Reste, R1 R_Liste, R2 R_NouveauReste, R3
11 execute concat/3 Les variables comme R_Tete sont les variables logiques indiquant quel registre est alloué à la variable correspondante. Elles sont initialement libres. Le “-“ devant une instruction indique le mode où l’on consomme le contenu d’un registre reçu en paramètre. Le signe “+“ indique les instructions où l’on produit une valeur dans un registre comme argument d’un appel.
Sans entrer dans tous les détails de ce code, mentionnons simplement que : •
get_nil unifie le registre Ri avec la liste vide ;
•
get_temp_val est l’unification générale de deux arbres ;
•
get_pair unifie le registre Ri avec une paire pointée ;
•
les instructions ’unify_…’ unifient les sous-arbres des arguments de la tête de clause avec ceux des Ri correspondants ;
Synthèse du code objet 339
•
execute est un goto : tout transite dans les registres, et rien ne va sur la pile. La récursion terminale dans l’appel récursif à concat devient une simple itération dans le code objet ! Comme dans l’exemple du paragraphe 11.20, cela suppose implicitement la gestion d’une continuation ;
•
la gestion des registres fait que lors du execute faisant boucler sur le corps de concat, les registres R1, R2 et R3 ont la valeur adéquate. En particulier, R2 voit son contenu inchangé dans la boucle, jusqu’à ce qu’il soit finalement unifié avec le contenu de R3 (première clause) lorsqu’on arrive à la fin de la liste premier argument ;
•
allocate crée un bloc d’activation pour contenir la variable Res le plus tard possible, car il n’est pas nécessaire si les unifications qui précèdent échouent. Res est donc une variable permanente, qui n’est pas en tous temps dans un registre ;
•
call est un vrai appel de procédure, 1 est le nombre de variables à garder en vie dans le bloc créé par allocate au retour de concat ;
•
deallocate détruit le bloc d’activation.
La passe 3 alloue les registres aux variables en tenant compte de leur registre origine, comme R2 pour Liste, et de leur registre destination, comme R1 pour Reste, R2 pour Liste et R3 pour NouveauReste. On tient compte de la durée de vie des variables. Par exemple, celle de Liste va de l’instruction 4 à la 9 incluse. On tient compte aussi de la disponibilité des registres. Ainsi R2 n’est pas disponible avant l’instruction 4, puisqu’il contient le second paramètre reçu en entrée, et ne l’est plus après l’instruction 9 puisque celle-ci y charge un argument pour l’appel à concat. La figure 12.6, montre la durée de vie des variables et la disponibilité des registres. Nous avons dû abréger les noms des variables pour des raisons d’encombrement. La ligne horizontale en pointillé illustre le fait qu’il faut un registre supplémentaire par rapport à R1, R2 et R3, car on a besoin de trois valeurs simultanément à l’instruction 4, et seuls les regisres R1 et R2, dans lesquels on reçoit les paramètres, sont disponibles à cet endroit.
L’allocation des registres est faite en tenant compte des préférences indiquées par les origines et destinations. Pour L2, qui arrive dans R2 et doit s’y retrouver pour l’appel à concat, il n’y qu’à attribuer R2 ! Ces considérations conduisent aux choix suivants. •
Liste est implantée dans R2, préférence comme source et destination ;
•
Reste est implantée dans R1, préférence comme destination, qui est disponible pour la durée de vie de cette variable ;
•
NouveauReste est implantée dans R3.
340 Compilateurs avec C++
R_T 1
get_pair
R1
2
u_temp_var
R_T
3
u_temp_var
R_R
4
get_temp_var
R2, R_L
5
get_pair
R3
6
u_temp_val
R_T
7
u_temp_var
R_NR
8
put_temp_val
R_R, R1
9
put_temp_val
R_L, R2
10
put_temp_val
R_NR, R3
11
execute
concat/3
R_R
R_L
durée de vie
R_NR
R1
R2
R3
disponibilité
intervalle d’instructions préférences source ou destination
12.6Durée de vie des variables et disponibilité des registres A ce stade, on parcourt toutes les variables n’ayant pas encore reçu un registre Ri, et on leur en trouve un qui est disponible. Cela fait qu’on implante Tete dans R4. Le code objet devient : 1 2 3
-
get_pair unify_temp_var unify_temp_var
R1 R4 R1
4
-
get_temp_var
R2, R2
5 6 7
-
get_pair unify_temp_val unify_temp_var
R3 R4 R3
8 9 10
+ + +
put_temp_val put_temp_val put_temp_val
R1, R1 R2, R2 R3, R3
execute
concat/3
11
La passe 4 effectue l’optimisation peephole qui supprime les instructions 4, 8, 9 et 10 qui n’ont aucun effet. C’est ainsi que la 4 met le paramètre reçu en R2 dans
Synthèse du code objet 341
R2, tandis que la 9 met R2 dans R2 comme argument d’appel. Comme aucune variable ne doit survivre à une appel, il n’y pas de bloc d’activation associé à concat : tout transite dans des registres. Le code objet final pour cette clause est donc : 1 2 3
-
get_pair unify_temp_var unify_temp_var
R1 R4 ( Tete ) R1 ( Reste )
4 5 6
-
get_pair unify_temp_val unify_temp_var
R3 R4 ( Tete ) R3 ( NouveauReste )
execute
concat/3
7
Voici pour finir un second exemple, illustrant l’écrémage des variables : proc(Parm1) :autre_proc(Parm1), concat(Parm1, [167], Inter), write(Parm1), autre_proc(Inter), write(Inter).
et le code objet PLM correspondant : 1 2
-
allocate get_perm_var
2 R1, Perm_2 ( Parm1 )
3
+
put_perm_val
Perm_2, R1 ( Parm1 )
4
*
call
autre_proc/1, 2
put_perm_val
Perm_2, R1 ( Parm1 )
5 6 7 8
+
put_pair unify_w_int unify_w_nil
R2 167
9
+
put_perm_var
Perm_1, R3 ( Inter )
10
*
call
concat/3, 2
11 12
+
put_perm_val write
Perm_2, R1 ( Parm1 ) R1
13
"
put_perm_val
Perm_1, R1 ( Inter )
14
*
call
autre_proc/1, 1
15 16
+
put_unsafe_val write
Perm_1, R1 ( Inter ) R1
17 18
deallocate proceed
Dans la première instruction call, le 2 indique qu’il faut garder en vie les deux variables Parm1 et Inter car les deux sont utilisées après le retour de cet appel à concat. En revanche, dans la seconde instruction call, seule Inter est à maintenir en vie au retour car la dernière occurrence textuelle de Parm1 dans le
342 Compilateurs avec C++
corps de cette clause a déjà été dépassée. Il y a donc dans ce cas écrémage de la variable Parm1 dans le bloc d’activation créé par allocate. L’allocation des variables dans ce bloc est faite par le compilateur de manière que les variables qui meurent le plus vite soient les plus proches du sommet de la pile. Cette position relative est indiquée par le code Perm_i, où i est d’autant plus petit que la variable correspondante est proche du début du bloc d’activation.
La programmation des quatre passes de ce compilateur Prolog ne pose pas de problème particulier, et le résultat est un code objet très fin obtenu de manière très rapide. 12.19
Exercices
12.1 : Optimisation des sauts sur des sauts (moyen). Etendre le mécanisme mis en place au paragraphe 12.15, de manière à prendre en compte également les sauts de contournement des corps de fonctions. Il y a des sauts sur des sauts dans les cas où plusieurs définitions de fonctions se suivent textuellement, comme au paragraphe 11.17.
12.2 : Compilation incrémentale de Formula (projet). L’évaluation directe des graphes sémantiques et la synthèse de code Pilum s’appliquent à un fichier source Formula traité “d’un coup“, en batch (traitement par lots). Peut-on étendre cette implantation de Formula avec une facilité de compilation incrémentale, de manière que l’utilisateur puisse définir des fonctions interactivement et les utiliser à volonté à la suite de cela ?
12.3 : Compilation de pièces de puzzle (projet). Nous avons vu, au paragraphe 2.3, comment écrire un programme de gestion de contraintes pour résoudre un puzzle de type pentominos. Peut-on compiler les pièces du puzzle, de manière à obtenir du code décrivant les contraintes à partir d’une spécification de leur géométrie ? On pourrait décrire la pièce B par : Ouest, Sud Cela indique qu’un observateur se déplaçant sur la surface de la pièce depuis l’une des cases situées à ses extrémités devrait faire cette suite de mouvements pour passer une fois sur toutes les cases de cette pièce. Le code objet résultant de cette compilation de la géométrie de la pièce B pourrait être le code source CHIP de la relation contraintes_B listé au paragraphe 2.3, par exemple. Il faut prévoir le cas où l’on part de l’angle de la pièce B, ce qui pourrait être décrit par : AuChoix (Est, Sud) Qui a dit que l’on peut compiler bien autre chose que des langages de programmation ?
Synthèse du code objet 343
344 Compilateurs avec C++
Appendice
Réalisation en C++ Appendice : réalisation en C++ Nous présentons ici des exemples de réalisation écrits en C++, pour le lecteur intéressé aux détails. Les extraits ont été groupés de manière à correspondre aux chapitres correspondants. A.1
Analyse lexicale
A.1.1
Gestion des fichiers de caractères
La définition de la classe FichierDeCaracteres est réalisée essentiellement par : void FichierDeCaracteres :: LireDansTampon ( char * & leTampon, long & longueurDuTampon, char laSentinelle ) { long tailleDuFichier = Taille (); longueurDuTampon = tailleDuFichier + 1; // on prévoit la place pour la sentinelle leTampon = new char [longueurDuTampon]; if (leTampon == NULL) ErreurFichier ("pas assez de place pour allouer le tampon"); Rembobiner (); int
nombreCaracteres = read (fDescripteur, leTampon, tailleDuFichier);
346 Compilateurs avec C++
// on place la sentinelle en fin de tampon leTampon [longueurDuTampon - 1] = laSentinelle; } // FichierDeCaracteres :: LireDansTampon A.1.2
Gestion des producteurs de caractères
La définition de cette classe est faite essentiellement par : void ProducteurDeCaracteres :: RevenirDUnCaractereEnArriere () { if (-- fPosCaractereCourant < fPosDebutTampon - 1) ErreurProduction ("trop de retours en arrière sur des caractères"); } int ProducteurDeCaracteres :: PositionCourante () { return fPosCaractereCourant - fPosDebutTampon; } void ProducteurDeCaracteres :: ExtraireLaChaine ( int positionDeDepart, int nombreDeCaracteres, char * destination ) { memcpy ( destination, fPosDebutTampon + positionDeDepart, nombreDeCaracteres ); destination [nombreDeCaracteres] = '\0'; } A.1.3
Production de caractères depuis un fichier
La sous-classe ProducteurDeCaracteresFichier est définie par : char ProducteurDeCaracteresFichier :: LireUnCaractere () { return (++ fPosCaractereCourant) < fPosFinTampon ? (* fPosCaractereCourant) : (* fPosFinTampon); } Boolean ProducteurDeCaracteresFichier :: FinAtteinte () { return fPosCaractereCourant >= fPosFinTampon; }
Appendice : réalisation en C++ 347
A.1.4
Production de caractères depuis un flot
La définition est faite au moyen de : char ProducteurDeCaracteresFlot :: LireUnCaractere () { // le dernier caractère du tampon est réservé // pour pouvoir y placerla sentinelle le moment venu ++ fPosCaractereCourant; if (fPosCaractereCourant > fPosDernierCaractereLu) // on doit lire un nouveau caractère { if (fPosCaractereCourant >= fPosFinTampon) ErreurProduction ("le tampon utilisé est trop petit" ); fFlotDEntree -> get (* fPosCaractereCourant); fPosDernierCaractereLu = fPosCaractereCourant; } return (* fPosCaractereCourant); } // ProducteurDeCaracteresFlot :: LireUnCaractere Boolean ProducteurDeCaracteresFlot :: FinAtteinte () { if ((* fPosCaractereCourant) == fSentinelle) { if (fSentinelleRencontree) return true; fSentinelleRencontree = true; // on place la sentinelle comme dernier caractère lu fPosDernierCaractereLu += 1; (* fPosDernierCaractereLu) = fSentinelle; } return false; } // ProducteurDeCaracteresFlot :: FinAtteinte
On remarque que la gestion fine de l’avancée et du recul est beaucoup plus complexe dans le cas d’une lecture depuis un flot que dans le cas de la lecture du contenu d’un fichier d’un seul coup en mémoire. Cela n’est absolument pas gênant dans le cas d’une lecture interactive car l’utilisateur est de toute façon beaucoup lent à la frappe que les machines actuelles. A.1.5
Analyse des chaînes C--
On utilise les variables tampon, qui est l’adresse à partir de laquelle on va stocker les tronçons successifs reconnus, prochaineCopie, qui est l’adresse qui suit le dernier tronçon recopié, et positionDebut, qui marque le début du tronçon courant en cours d’analyse.
348 Compilateurs avec C++
Cela donne le code d’analyse suivant : case '"': // CHAINE { Boolean finChaineAtteinte = false; char char
* tampon = fIdent; * prochaineCopie = fIdent;
short short
longueurTroncon ; longueurChaine;
Avancer (); positionDebut = PositionDuCaractere (); while (true) // boucle infinie { switch (fCaractereCourant) { case '"': finChaineAtteinte = true; break; case '\\': { // on recopie le tronçon courant // à partir de "prochaineCopie" longueurTroncon = PositionDuCaractere () - positionDebut; longueurChaine = prochaineCopie - tampon; if (longueurChaine + longueurTroncon > kLongueurIdentMax) { cout << form ( "CHAINE trop longue tronquée à %d caractères\n", kLongueurIdentMax ); longueurTroncon = kLongueurIdentMax - longueurChaine; } fProducteurDeCaracteres -> ExtraireLaChaine ( positionDebut, longueurTroncon, prochaineCopie ); prochaineCopie += longueurTroncon; Avancer (); switch (fCaractereCourant) { case '"': * prochaineCopie ++ = '"'; break; case '\\': * prochaineCopie ++ = '\\'; break; case 'n': * prochaineCopie ++ = '\n'; break;
Appendice : réalisation en C++ 349
case 't': * prochaineCopie ++ = '\t'; break; default: ErreurLexicale ( "'\"', '\\', 'n' ou 't'attendu après '\\'" " dans une chaine de caractères"); Reculer (); } // switch positionDebut = PositionDuCaractere () + 1; } break; case SENTINELLE: ErreurLexicale ("'\"' attendu comme fin de chaine"); if (fProducteurDeCaracteres -> FinAtteinte ()) return FIN; // correction de l'erreur else return CHAINE; break;
//
correction de l'erreur
default: ; // RIEN ! } // switch if (finChaineAtteinte) break; Avancer (); } // while // //
on recopie le dernier tronçon à partir de "prochaineCopie"
longueurTroncon = PositionDuCaractere () - positionDebut; longueurChaine = prochaineCopie - tampon; if (longueurChaine + longueurTroncon > kLongueurIdentMax) { cout << form ( "CHAINE trop longue tronquée à %d caractères\n", kLongueurIdentMax ); longueurTroncon = kLongueurIdentMax - longueurChaine; } fProducteurDeCaracteres -> ExtraireLaChaine ( positionDebut, longueurTroncon, prochaineCopie ); return CHAINE; }
350 Compilateurs avec C++
A.1.6
Analyse lexicale des nombres Formula
Cette analyse des nombres est faite par: else if (isdigit (fCaractereCourant)) { // NOMBRE do
// on consomme tous les chiffres Avancer (); while (isdigit (fCaractereCourant)); switch (fCaractereCourant) { case '.': Avancer (); if (isdigit (fCaractereCourant)) { do // on consomme tous les chiffres Avancer (); while (isdigit (fCaractereCourant)); if ( (fCaractereCourant == 'E') || (fCaractereCourant == 'e') ) LireExposant (); } else ErreurLexicale ("un chiffre est attendu"); break; case 'E': case 'e': LireExposant (); break; default: ; // RIEN ! } // switch Reculer (); int longueurNombre = fProducteurDeCaracteres -> PositionCourante () - positionDebut + 1; fProducteurDeCaracteres -> ExtraireLaChaine ( positionDebut, longueurNombre, fIdent ); fNombre = atof (fIdent); return NOMBRE; }
Appendice : réalisation en C++ 351
La méthode LireExposant, quant à elle, est définie par: void AnalyseurLexicalFormula :: LireExposant () { Avancer (); if ( (fCaractereCourant == '+') || (fCaractereCourant == '-') ) { Avancer (); if (isdigit (fCaractereCourant)) { do // on consomme tous les chiffres Avancer (); while (isdigit (fCaractereCourant)); } else ErreurLexicale ("un chiffre est attendu"); } else if (isdigit (fCaractereCourant)) { do // consomme tous les chiffres Avancer (); while (isdigit (fCaractereCourant)); } else ErreurLexicale ("un chiffre ou '+' ou '-' est attendu"); } // AnalyseurLexicalFormula :: LireExposant A.1.7
Exemple d’analyse lexicale C--
Pour conclure ce paragraphe, voici un exemple de programme source C--, analysable par les techniques présentées au chapitre 5. Il met en évidence différents aspects de l’analyse lexicale : main () { int
//
un exemple C-- simple
i = 35;
/* int
k = 19;
*/ if (i % 2 == 0) cout << "Bonjour tout le monde!"; else exit ('Z'); ++ i; /**/ /***/ /****/ cout << '\t' << "C'est\nfini\"!\n"; /**//***//****/
352 Compilateurs avec C++
const limite1 = 2147483647; const limite2 = 2147483648; bla_rk(0xFFFfFFfF); }
Ce programme n’est pas supposé être syntaxiquement correct ni sémantiquement sensé. Il contient d’ailleurs une faute lexicale. Le résultat obtenu lors son analyse lexicale est : Ident
Ident Entier
Ident Entier
main ( ) { int i = Hexa (23) 35 ; if ( i % Hexa (2) 2 ==
Entier
Hexa (0) 0 )
Ident
cout << [22] Bonjour tout le monde! ;
Chaîne
else Ident Caractère
exit ( Ascii (90) Z ) ;
Ident
++ i ;
Ident Caractère Chaîne fini"!
cout << Ascii (9) << [13] C'est ;
Appendice : réalisation en C++ 353
Ident Entier
Ident
const limite1 = Hexa (7fffffff) 2147483647 ; const limite2 =
Erreur lexicale: débordement d'un entier après 214748364 pos = 282, ascii (56), char = 8 Entier Hexa (80000000) -2147483648 ; Ident Entier
bla_rk ( Hexa (ffffffff) -1 ) ; }
--- FIN ---
Bien que cela ne soit pas apparent dans le listage des résultats ci-dessus, le caractère tabulateur \t est bel est bien placé à la fin de la ligne : Caractère
Ascii (9)
De même, ce sont les deux fins de lignes \n présentent dans la chaîne de caractères "C'est\nfini\"!\n" qui font que l’on obtient : Chaîne fini"!
[13] C'est ;
Enfin, nous avons mis à l’épreuve le test de débordement de capacité des entiers dans la ligne : const limite2 = 2147483648; A.2
L’outil Lex
A.2.1
Librairie de support pour Lex
Le fichier d’implantation LexSupport.cp contient, outre les fonctions listées dans le paragraphe 6.6 : #include <stdlib.h> #include <stream.h> #include "LexSupport.h"
354 Compilateurs avec C++
// On a besoin du type Boolean #include //
Externes synthétisés par Lex
extern
yylex ();
extern int extern int
yylineno; yycharno;
extern int extern char
yyleng; yytext []; //
//
ajouté par l'auteur et non pas 'extern char * yytext;' !!!
extern FILE* yyin; //
Variables privées
static char static int static int
** pArguments; pArgumentsRestants; pArgumentCourant;
void ErreurLexicale (char * leMessage) { cerr << form ( "### Erreur lexicale à la ligne %d, caractère %d,", yylineno, yycharno ); if (pArgumentCourant != 0) cerr << form (" du fichier:\n else cerr << "\n";
%s\n", pArguments [pArgumentCourant]);
if (yyleng == 1) cerr << form ( " près du caractère Ascii (%d), |%c|:\n", yytext [0], yytext [0] ); else if (yyleng > 1) cerr << form (" près de la chaîne |%s|:\n", yytext); cerr << form ("
%s\n###\n", leMessage);
cerr.flush (); } // ErreurLexicale
Appendice : réalisation en C++ 355
A.2.2
Fichier ”make” pour LexFormula
La description des dépendances entre composants l’analyseur lexical Formula basé sur Lex est : LexSupport = ::LexSupport: OBJECTS = lex.yy.cp.o "{LexSupport}LexSupport.cp.o" LexFormula ƒƒ ∂ LexFormula.make ∂ {OBJECTS} Link -w -c 'MPS ' -t MPST ∂ -model far ∂ {OBJECTS} ∂ "{CLibraries}"CSANELib.o ∂ "{CLibraries}"Math.o ∂ "{CLibraries}"CplusLib.o ∂ "{CLibraries}"StdClib.o ∂ "{Libraries}"Stubs.o ∂ "{Libraries}"Runtime.o ∂ "{Libraries}"Interface.o ∂ "{Libraries}"ToolLibs.o ∂ -o LexFormula "{LexSupport}LexSupport.cp.o" ƒ ∂ LexFormula.make ∂ "{LexSupport}LexSupport.h" ∂ "{LexSupport}LexSupport.cp" CPlus -model far -i "{LexSupport}" "{LexSupport}LexSupport.cp" lex.yy.cp ƒ ∂ LexFormula.make ∂ LexFormula.Lex Lex LexFormula.Lex # -t -v Rename -y lex.yy.c lex.yy.cp lex.yy.cp.o ƒ ∂
CPlus
LexFormula.make ∂ LexFormula.h ∂ "{LexSupport}LexSupport.h" ∂ lex.yy.cp -model far -i "{LexSupport}" lex.yy.cp
356 Compilateurs avec C++
A.3
Analyse syntaxique
A.3.1
Descente récursive pour Formula
Voici le code de deux méthodes illustrant l’esprit de la descente récursive : void AnalyseurDescendantFormula :: Parametres () { if (fTerminal != IDENT) ErreurSyntaxique ("IDENT attendu comme paramètre de fonction"); else Avancer (); while (fTerminal == VIRGULE) { Avancer (); if (fTerminal != IDENT) ErreurSyntaxique ("IDENT attendu comme paramètre de fonction"); else Avancer (); } } // AnalyseurDescendantFormula :: Parametres void AnalyseurDescendantFormula :: Expression () { if (fTerminal == MOINS) Avancer (); // on l'accepte Terme (); while ( fTerminal == PLUS || fTerminal == MOINS ) { Avancer (); Terme (); } } // AnalyseurDescendantFormula :: Expression A.4
Analyse sémantique
A.4.1
Gestion des types logiques
L’implantation de ces types contient entre autres l’algorithme d’unification de deux variables logiques : Boolean VarLogType :: UnifierAutreVariable ( VarLogTypePtr lAutreVarLogType ) { //
On commence par chercher les deux fins de chaîne
VarLogTypePtr
curseur = this;
Appendice : réalisation en C++ 357
while (curseur -> fLiaisonAutreVariable != NULL) curseur = curseur -> fLiaisonAutreVariable; VarLogTypePtr
autreCurseur = lAutreVarLogType;
while (autreCurseur -> fLiaisonAutreVariable != NULL) autreCurseur = autreCurseur -> fLiaisonAutreVariable; //
On essaye maintenant d'unifier
if (curseur == autreCurseur) // les deux variables sont déjà unifiées return true; if (curseur -> fValeurType == gTypeLogLIBRE) // le receveur est une variable libre if (autreCurseur -> fValeurType == gTypeLogLIBRE) // les deux variables sont libres, // on LIE le receveur à l'autre en évitant d'allonger les chaînes { fLiaisonAutreVariable = autreCurseur; return true; } else // l'autre variable est seule liée, // on AFFECTE sa valeur au receveur { curseur -> fValeurType = autreCurseur -> fValeurType; return true; } else //
le receveur est une variable liée
if (autreCurseur -> fValeurType != gTypeLogLIBRE) // les deux variables sont liées return curseur -> fValeurType == autreCurseur -> fValeurType; else // l'autre variable est seule libre, // on lui AFFECTE la valeur du receveur { autreCurseur -> fValeurType = curseur -> fValeurType; return true; } } // VarLogType :: UnifierAutreVariable
L’unification d’une valeur logique de type avec un type donné est faite par : Boolean VarLogType :: UnifierValeur (TypePtr unType) { // On commence par chercher la fin de chaîne VarLogTypePtr
curseur = this;
while (curseur -> fLiaisonAutreVariable != NULL) curseur = curseur -> fLiaisonAutreVariable; //
On essaye maintenant d'unifier
358 Compilateurs avec C++
if (curseur -> fValeurType == gTypeLogLIBRE) // le receveur est une variable libre { curseur -> fValeurType = unType; return true; } else // le receveur est une variable liée return curseur -> fValeurType == unType; } // VarLogType :: UnifierValeur
La valeur de liaison d’une variable logique de type est au bout de la chaîne : TypePtr VarLogType :: ValeurLiaison () { // On commence par chercher la fin de chaîne VarLogTypePtr
curseur = this;
while (curseur -> fLiaisonAutreVariable != NULL) curseur = curseur -> fLiaisonAutreVariable; return curseur -> fValeurType; }
Enfin, le test permettant de savoir si une variable logique de type est libre est : Boolean VarLogType :: EstLibre () { return ValeurLiaison () == gTypeLogLIBRE; } A.4.2
Récupération des types Formula inférés
Le type inféré pour chaque identificateur peut être récupéré par : Boolean IdentFormula :: RecupererLeTypeInfere () { Boolean res = true; if (fVarLogType -> EstLibre ()) { res = false; fVarLogType -> UnifierValeur (gTypeInconnu); } this -> TypeIdent (fVarLogType -> ValeurLiaison ()); return res; }
Dans le cas des paramètres formels des fonctions utilisateur, cette récupération est traitée à l’aide d’un itérateur sur la liste des paramètres par : void AnalyseurFormula :: RecupererTypesParams ( ListeParamsPtr laListeParams) { if (laListeParams -> NombreDeParametres () != 0) { IterateurParams iter (laListeParams); DescrParamPtr parametreCourant;
Appendice : réalisation en C++ 359
for ( parametreCourant = iter.PremierElement (); iter.IlResteDesElements (); parametreCourant = iter.ElementSuivant () ) { ParamFormelPtr leParamFormel = parametreCourant -> ParamFormel (); if (! leParamFormel -> RecupererLeTypeInfere ()) ErreurSemantique ( form ( "le type du parametre formel %s n'a pas pu être inféré", leParamFormel -> Nom () )); } // for } // if } // AnalyseurFormula :: RecupererTypesParams A.4.3
Création des identificateurs Formula prédéfinis
La méthode principale effectuant le travail est : void AnalyseurFormula :: InsererFonctPredef ( char * leNom, GenreFonctPredef laFonction, VarLogTypePtr laVarLogType ) { InsererIdentPredef ( new FonctPredef (leNom, laFonction, laVarLogType) ); }
En dernier ressort, c’est InsererIdentPredef qui fait le travail et vérifie que l’on ne prédéfinit pas plusieurs fois le même identificateur : void AnalyseurFormula :: InsererIdentPredef (IdentPtr lIdent) { Boolean dejaPresentAuSommet; Boolean masqueAutreDeclaration; fPileDeDictionnaires.InsererIdent ( lIdent, dejaPresentAuSommet, masqueAutreDeclaration); if (dejaPresentAuSommet) ErreurSemantique ( form ( "InsererIdentPredef: '%s' défini multiplement\n", lIdent -> Nom () )); }
360 Compilateurs avec C++
A.4.4
Analyse d’une définition de fonction Formula
L’analyse d’une définition Formula est réalisé par la méthode suivante : void AnalyseurFormula :: Definition () { FonctUtilisateurPtr lIdentFonction = EnteteDeFonction (); fPileDeDictionnaires.Empiler (lIdentFonction -> DictParams ()); TesterTerminal (EGALE, "dans une définition"); DescrSemFormulaPtr leCorps = Expression (); VarLogTypePtr
laVarLogType = lIdentFonction -> VarLogType ();
char
* leMessage = form ( "le type de la fonction utilisateur '%s'", lIdentFonction -> Nom () );
TypePtr
typeFonction = RecupererLeType (leCorps, leMessage);
lIdentFonction -> TypeIdent (typeFonction); laVarLogType -> UnifierValeur (typeFonction); ListeParamsPtr
laListeParams = lIdentFonction -> ListeParams ();
RecupererTypesParams (laListeParams); fPileDeDictionnaires.Desempiler (); //
parametres
TesterTerminal (POINT_VIRGULE, "après une définition"); lIdentFonction -> Corps (leCorps); cout << form ( "\n--> Définition:\n%s\n", lIdentFonction -> SousFormeDeChaine () ); } // AnalyseurFormula :: Definition
L’analyse de l’en-tête de la fonction est réalisée par la méthode : FonctUtilisateurPtr AnalyseurFormula :: EnteteDeFonction () { ++ fCompteurFonctions; char
* nomDeLaFonction = form ("Fonction_%d", fCompteurFonctions);
if (fTerminal != IDENT) ErreurSyntaxique ("IDENT attendu comme nom de fonction"); else { nomDeLaFonction = SauvegarderChaine ( fAnalyseurLexical -> DernierIdentLu () ); // car on ne s'en sert qu'apres Avancer (); } DictionnairePtr
dictParams = new DictionnaireArbre (nomDeLaFonction);
Appendice : réalisation en C++ 361
FonctUtilisateurPtr
lIdentFonction = new FonctUtilisateur ( nomDeLaFonction, new VarLogType, dictParams );
Boolean Boolean
dejaPresentAuSommet; masqueAutreDeclaration;
fPileDeDictionnaires.InsererIdent ( lIdentFonction, dejaPresentAuSommet, masqueAutreDeclaration); if (dejaPresentAuSommet) ErreurSemantique ( form ( "re-définition de la fonction utilisateur '%s'", nomDeLaFonction )); if (masqueAutreDeclaration) AvertissementSemantique ( form ( "la définition de la fonction utilisateur '%s' " "masque une autre declaration", nomDeLaFonction )); if (fTerminal == PAR_GAUCHE) { Avancer (); Parametres (lIdentFonction); TesterTerminal (PAR_DROITE, "après les paramètres d'une fonction"); } return lIdentFonction; } // AnalyseurFormula :: EnteteDeFonction A.4.5
Analyse des paramètres d’une fonction Formula
L’analyse des paramètres des fonctions est faite par la méthode ci-dessous : void AnalyseurFormula :: Parametres ( FonctUtilisateurPtr laFonctUtilisateur ) { short numeroDeParametre = 0; while (true) // boucle infinie { ++ numeroDeParametre; char
* nomDuParametre = form ("Parametre_%d", numeroDeParametre);
if (fTerminal != IDENT) { ErreurSyntaxique ("IDENT attendu comme paramètre de fonction"); if (fTerminal == PAR_DROITE) break; }
362 Compilateurs avec C++
else { nomDuParametre = SauvegarderChaine (fAnalyseurLexical -> DernierIdentLu (); // car on ne s'en sert qu'apres; Avancer (); } VarLogTypePtr
laVarLogType = new VarLogType ();
DescrParamPtr
laDescrParam;
switch (fPassageParams) { case kParValeur: laDescrParam = new DescrParamParValeur (numeroDeParametre); break; case kParNom: laDescrParam = new DescrParamParNom (numeroDeParametre); laFonctUtilisateur -> UnLienStatiqueEstNecessaire (); break; case kParEsseux: laDescrParam = new DescrParamParEsseux (numeroDeParametre); laFonctUtilisateur -> UnLienStatiqueEstNecessaire (); break; } // switch ParamFormelPtr lIdentParametre = new ParamFormel ( nomDuParametre, laVarLogType, laDescrParam ); // mise a jour laDescrParam -> ParamFormel (lIdentParametre); if ( ! laFonctUtilisateur -> DictParams () -> InsererIdent (lIdentParametre) ) ErreurSemantique ( form ("re-définition du paramètre '%s'", nomDuParametre) ); DictionnairePtr IdentPtr
leDictionnaire; lIdentMasque = fPileDeDictionnaires.RechercherLeNom ( nomDuParametre, leDictionnaire );
if (lIdentMasque != NULL) AvertissementSemantique ( form ( "la définition du parametre formel '%s' " "masque une autre declaration", nomDuParametre )); laFonctUtilisateur -> ListeParams () -> Ajouter (laDescrParam);
Appendice : réalisation en C++ 363
if (fTerminal != VIRGULE) break; Avancer (); } // while } // AnalyseurFormula :: Parametres
A moins que les paramètres ne soient passés par valeur, le message UnLienStatiqueEstNecessaire est envoyé à la description de la fonction appelée pour enregistrer le fait qu’elle a besoin d’un lien statique. A.4.6
Analyse des évaluations Formula
Les expressions introduites par le point d’interrogation “?“ en Formula sont celles dont on désire l’évaluation. Leur analyse est faite par : void AnalyseurFormula :: Evaluation () { ++ fCompteurEvaluations; char
* nomDuDictionnaire = "Evaluation";
DictionnaireArbrePtr
dictEvaluation = new DictionnaireArbre (nomDuDictionnaire); // pour les idents non declares eventuels
fPileDeDictionnaires.Empiler (dictEvaluation); // INTERROGE a été accepté DescrSemFormulaPtr lExpression = Expression (); TesterTerminal (POINT_VIRGULE, "après une évaluation"); TypePtr
typeExpression = RecupererLeType ( lExpression, "le type de l'expression" );
cout << form ( "\n--> Evaluation: expression -> %s\n", typeExpression-> DescriptionType () ); fPileDeDictionnaires.Desempiler (); // } // AnalyseurFormula :: Evaluation
dictEvaluation
On voit que le type de l’expression est récupéré à partir de la valeur de la variable lExpression à l’aide de l’appel à RecupererLeType. A.4.7
Analyse des expressions Formula
Rappelons que le code effectuant cette analyse est basé sur la trilogie Expression - Terme - Facteur. Comme Terme est très similaire à Expression, nous ne présenterons que cette dernière ici. L’idée est de construire un graphe sémantique de l’expression à partir des graphes représentant les termes qui la composent. La variable locale expressionCourante contient ce graphe à chaque instant.
364 Compilateurs avec C++
On remarquera que si l’expression est simplement composée d’un Terme, comme dans le cas de : 3 * (…)
aucun test de type n’est effectué : le graphe décrivant le Terme est simplement retourné comme description de l’Expression. En revanche, si un opérateur “-“ unaire précède ce Terme, ou si des opérateurs “+“ ou “-“ dyadiques le suivent, les autres Terme opérandes de ces opérateurs sont soumis à la contrainte d’être du type Nombre, ce qui est vérifié au moyen de la fonction membre TesterTypeAttendu, décrite au paragraphe 8.18. C’est cette remarque qui justifie la présence du test : if (fTerminal == PLUS || fTerminal == MOINS)
avant la boucle portant sur le même test : while (fTerminal == PLUS || fTerminal == MOINS)
On mémorise dans la variable locale leTerminal l’opérateur qui a été rencontré avant un Terme donné, pour pouvoir ensuite construire en fonction de cet opérateur un nœud sémantique utilisant la description du Terme. La construction du graphe sémantique se fait typiquement de manière postfixée, puisqu’il est préférable de disposer des sous-graphes pour construire un nouveau nœud. Le code effectuant l’analyse des expressions Formula est : DescrSemFormulaPtr AnalyseurFormula :: Expression () { BooleanmoinsUnairePresent = false; if (fTerminal == MOINS) { moinsUnairePresent = true; Avancer (); // on l'accepte } DescrSemFormulaPtr
exprCourante = Terme ();
if (moinsUnairePresent) { TesterTypeAttendu ( gTypeNombre, exprCourante -> TypeLogique (), "terme"); exprCourante = new MoinsUnaire (exprCourante); } if (fTerminal == PLUS || fTerminal == MOINS) { TesterTypeAttendu ( gTypeNombre, exprCourante -> TypeLogique (), "terme");
Appendice : réalisation en C++ 365
while (fTerminal == PLUS || fTerminal == MOINS) { Terminal leTerminal = fTerminal; Avancer (); DescrSemFormulaPtr
terme2 = Terme ();
TesterTypeAttendu ( gTypeNombre, terme2 -> TypeLogique (), "terme"); switch (leTerminal) { case PLUS: exprCourante = new Plus (exprCourante, terme2); break; case MOINS: exprCourante = new Moins (exprCourante, terme2); break; } // switch } // while } // if return exprCourante; } // AnalyseurFormula :: Expression
La création des nœuds du graphe sémantique est faite par l’opérateur new. On apprécie ici l’agrément de C++, qui fait que les constructeurs adéquats sont appelés implicitement lors de chaque naissance d’une instance d’une classe pour laquelle un constructeur au moins a été déclaré. A.4.8
Analyse des facteurs Formula
Le code qui réalise cette analyse traite les cas simples et délègue les cas plus compliqués à d’autres méthodes d’analyse : DescrSemFormulaPtr AnalyseurFormula :: Facteur () { switch (fTerminal) { case NOMBRE: Avancer (); // on l'accepte return new ValeurNombre (fAnalyseurLexical -> DernierNombreLu ()); break; case IDENT: return FacteurIdent (); break; case PAR_GAUCHE: { Avancer (); DescrSemFormulaPtr res = Expression (); TesterTerminal (PAR_DROITE, "après une expression parenthésée");
366 Compilateurs avec C++
return res; } break; default: ErreurSyntaxique ( "NOMBRE, IDENT ou EXPRESSION parenthésée " "attendu comme Facteur" ); Avancer (); return gDescrSemFormulaInconnue; } // switch } // AnalyseurFormula :: Facteur
La fonction qui traite les facteurs débutant par un identificateur est : DescrSemFormulaPtr AnalyseurFormula :: FacteurIdent () { char * identCourant = fAnalyseurLexical -> DernierIdentLu (); DictionnairePtr
leDictionnaire;
IdentPtr
lIdentCourant = fPileDeDictionnaires.RechercherLeNom ( identCourant, leDictionnaire );
if (lIdentCourant == NULL) { ErreurSemantique ( form ( "l'identificateur '%s' n'a aucune déclaration accessible", identCourant )); // // //
a titre de rattrapage d'erreur semantique, on enregistre cet identificateur dans la table avec un type logique libre
VarLogTypePtr IdentPtr
leTypeLogique = new VarLogType (); lIdentNonDeclare = new IdentNonDeclare (identCourant, leTypeLogique);
Boolean Boolean
dejaPresentAuSommet; masqueAutreDeclaration;
fPileDeDictionnaires.InsererIdent ( lIdentNonDeclare, dejaPresentAuSommet, masqueAutreDeclaration); Avancer (); AccepterArgumentsSuperflus (kMessageDejaProduit); return new ValeurInconnue (leTypeLogique); // pour permettre l'inference du type // de cet identificateur non declare } else { // l’identificateur a une déclaration switch (lIdentCourant -> GenreIdent ()) {
Appendice : réalisation en C++ 367
case kIdentNonDeclare: // voir ci-dessous break;
//
rattrapage d'erreurs semantiques
case kIdentConstPredef: { // voir ci-dessous break; case kIdentFonctPredef: // voir ci-dessous break; case kIdentFonctUtilisateur: // voir ci-dessous break; case kIdentParamFormel: // voir ci-dessous break; case kIdentIndiceIteration: // voir ci-dessous break; default: ErreurSemantique ( "ConstPredef, FonctPredef, fonctUtilisateur, ParamFormel ou " "IndiceIter attendu(e) comme Facteur" ); Avancer (); return gDescrSemFormulaInconnue; } // switch } // lIdentCourant != NULL } // AnalyseurFormula :: FacteurIdent
Un identificateur ayant été déclaré implicitement par le compilateur parce que l’utilisateur ne l’a pas fait est traité comme suit : case kIdentNonDeclare: // rattrapage d'erreurs semantiques Avancer (); AccepterArgumentsSuperflus (kMessageDejaProduit); IdentNonDeclarePtr
lIdentNonDeclare = IdentNonDeclarePtr (lIdentCourant);
return new ValeurInconnue (lIdentNonDeclare -> VarLogType ()); // pour permettre l'inference du type // de cet identificateur non declare break;
Un identificateur de constante prédéfinie est traité par : case kIdentConstPredef: { ConstPredefPtr lIdentConstante = ConstPredefPtr (lIdentCourant); DescrSemFormulaPtr
res;
368 Compilateurs avec C++
switch (lIdentConstante -> Constante ()) { case kVrai: res = new ValeurLogique (true); break; … … … … … … } //
switch
Avancer (); AccepterArgumentsSuperflus (kMessagePasEncoreProduit); return res; } break;
Le traitement d’un identificateur de fonction prédéfinie est renvoyé à une autre méthode, présentée au paragraphe suivant, par : case kIdentFonctPredef: Avancer (); return AppelDeFonctPredef (FonctPredefPtr (lIdentCourant)); break;
Il en va de même pour les appels aux fonctions définies dans le code source compilé, dont le traitement est présenté à la paragraphe A.4.10 : case kIdentFonctUtilisateur: Avancer (); return AppelDeFonctUtilisateur ( FonctUtilisateurPtr (lIdentCourant)); break;
Un emploi d’un paramètre formel est traité par : case kIdentParamFormel: { ParamFormelPtr lIdentParametre = ParamFormelPtr (lIdentCourant); Avancer (); AccepterArgumentsSuperflus (kMessagePasEncoreProduit); switch (lIdentParametre -> DescrParam () -> PassageParams ()) { case kParValeur: return new EmploiParamParValeur (lIdentParametre); … … … … … … … … } // } break;
switch
Dans ce cas, les instructions du genre de : return new EmploiParamParValeur (lIdentParametre);
Appendice : réalisation en C++ 369
font que le pointeur sur la variable logique de type de l’identificateur du paramètre formel est aussi utilisé par le nœud sémantique décrivant cet emploi. Toute liaison de cette variable provoquée par un test de type sur ce nœud est répercutée à la description du paramètre formel correspondant, réalisant ainsi l’inférence de type. Enfin, voici comment sont traités les indices des fonctions d’itération Formula : case kIdentIndiceIteration: { IndiceIterPtr lIndiceIter = IndiceIterPtr (lIdentCourant); Avancer (); return new EmploiIndiceIter (lIndiceIter); } break;
Dans l’analyse des facteurs présentée ci-dessus, les appels à la méthode AccepterArgumentsSuperflus constituent un rattrapage psychologique d’erreurs, au sens défini au paragraphe 8.18. A.4.9
Analyse des appels aux fonctions prédéfinies Formula
Voici la méthode réalisant l’analyse de ces appels : DescrSemFormulaPtr AnalyseurFormula :: AppelDeFonctPredef ( FonctPredefPtr laFonctPredef ) { // IDENT a été accepté TesterTerminal ( PAR_GAUCHE, "avant les arguments d'un appel de fonction prédéfinie"); GenreFonctPredef
laFonctionPredef = laFonctPredef -> Fonction ();
DescrSemFormulaPtr
res;
switch (laFonctionPredef) { case kEgale: case kDifferent: res = DyadiqueComparaisonNombres (laFonctionPredef); break; … … … … … case kHasard: res = new Hasard (); break; … … … … … default: res = gDescrSemFormulaInconnue; } // switch TesterTerminal ( PAR_DROITE, "après les arguments d'un appel de fonction prédéfinie" );
370 Compilateurs avec C++
return res; } // AnalyseurFormula :: AppelDeFonctPredef
Le cas des opérateurs = et != est traité par la méthode : DescrSemFormulaPtr AnalyseurFormula :: DyadiqueComparaisonNombres ( GenreFonctPredef laFonctionPredef ) { DescrSemFormulaPtr operande1 = Expression (); TesterTypeAttendu ( gTypeNombre, operande1 -> TypeLogique (), "expression" ); TesterTerminal ( VIRGULE, "entre les deux arguments d'un 'Egale' ou 'Different'"); DescrSemFormulaPtr
operande2 = Expression ();
TesterTypeAttendu ( gTypeNombre, operande2 -> TypeLogique (), "expression" ); switch (laFonctionPredef) { case kEgale: return new Egale (operande1, operande2); break; case kDifferent: return new Different (operande1, operande2); break; } // switch } // AnalyseurFormula :: DyadiqueComparaisonNombres
On ne peut comparer avec les opérateurs = et != que des nombres et non pas des valeurs booléennes en Formula. On pourrait prédéfinir une fonction du genre de Equivalent à cette fin. Le cas des autres opérateurs de comparaison et des fonctions monadiques mathématiques est très similaire. A.4.10
Analyse des appels aux fonctions utilisateur Formula
L’analyse des arguments d’appel est faite par la méthode suivante : DescrSemFormulaPtr * AnalyseurFormula :: Arguments ( char * nomFonction, ListeParamsPtr laListeParams ) { Boolean enCoursDeRattrapage = laListeParams == & fListeParamsInconnus; short
nombreDeParams = laListeParams -> NombreDeParametres ();
DescrSemFormulaPtr* blocDArguments = new DescrSemFormulaPtr [nombreDeParams]; short
numeroDArgument = 0;
IterateurParamsPtr
iter = new IterateurParams (laListeParams);
DescrParamPtr
parametreCourant = iter -> PremierElement ();
Appendice : réalisation en C++ 371
while (true) // boucle infinie { if (! iter -> IlResteDesElements ()) { ErreurSemantique ( form ( "il y a trop d'arguments dans un appel à la fonction %s", nomFonction )); // //
on rattrape l'erreur en se raccordant sur la liste circulaire de parametres inconnus
delete iter; iter = new IterateurParams (& fListeParamsInconnus); parametreCourant = iter -> PremierElement (); enCoursDeRattrapage = true; } // if DescrSemFormulaPtr
lArgument = Expression ();
if (! enCoursDeRattrapage) { blocDArguments [numeroDArgument ++] = lArgument; ParamFormelPtr leParamFormel = parametreCourant -> ParamFormel (); VarLogTypePtr
laVarLogType = leParamFormel -> VarLogType ();
TesterTypeAttendu ( laVarLogType -> ValeurLiaison (), lArgument -> TypeLogique (), "argument" ); } parametreCourant = iter -> ElementSuivant (); if (fTerminal != VIRGULE) { if (iter -> IlResteDesElements () && ! enCoursDeRattrapage) ErreurSemantique ( form ( "il y a trop peu d'arguments dans " "un appel à la fonction %s\n" "\t%s ont besoin d'une valeur", nomFonction, laListeParams -> NomsDesParametres () )); while (numeroDArgument < nombreDeParams) // on complète le bloc d'arguments tout de même! blocDArguments [numeroDArgument ++] = gDescrSemFormulaInconnue; break; } // if Avancer (); // } // while
on consomme la VIRGULE
372 Compilateurs avec C++
delete iter; return blocDArguments; } // AnalyseurFormula :: Arguments A.5
L’outil Yacc
A.5.1
Librairie de support pour Yacc en C++
Cette librairie est implantée dans LexYaccSupport.cp par : static void Erreur (char * genreDAnalyse, char * leMessage) { cerr << form ( "### Erreur %s à la ligne %d, caractère %d,", genreDAnalyse, yylineno, yycharno ); if (pArgumentCourant != 0) cerr << form (" du fichier:\n else cerr << "\n";
%s\n", pArguments [pArgumentCourant]);
if (yyleng == 1) cerr << form ( " près du caractère Ascii (%d), |%c|\n", yytext [0], yytext [0] ); else if (yyleng > 1) cerr << form (" près de la chaîne |%s|\n", yytext); if (strcmp (leMessage, "syntax error") != 0) cerr << form (" %s\n###\n", leMessage); cerr.flush (); // } // Erreur
force l'affichage du texte d'erreur
void ErreurLexicale (char * leMessage) { Erreur ("lexicale", leMessage); } int yyerror (char * leMessage) { Erreur ("syntaxique", leMessage); } Boolean LexYaccAnalyser (int argc, char ** argv) { pArguments = argv; pArgumentsRestants = argc; pArgumentCourant = 0; yywrap (); Boolean
res = ! yyparse ();
Appendice : réalisation en C++ 373
return res; } // LexYaccAnalyser main (int argc, char ** argv) { if (! LexYaccAnalyser (argc, argv)) exit (-1); cout << "\n*** Analyse bien terminée ***\n"; } A.5.2
Grammaire sémantique Yacc de Formula
La description Yacc décorée de Formula est listée en grande partie ci-dessous, avec les terminaux et non terminaux intéressants mis en évidence dans le corps des productions. Nous avons omis le texte de certaines actions sémantiques que le lecteur peut facilement imaginer : %{ #include <stream.h> #include "SupportLexYaccFormula.h" AnalyseurFormulaPtr %} %union
gAnalyseurFormula;
/* Description du terminal courant */ { float fNombre; DescrIdent fDescrIdent; FonctUtilisateurPtr DescrSemFormulaPtrf }
fFonctUtilisateur; GrapheSemantique;
/* Les terminaux du langage */ %token
NOMBRE
%token %token
IDENT ITERATEUR
%token %token %token %token %token
PAR_GAUCHE EGALE PLUS POINT_VIRGULE FIN
PAR_DROITE VIRGULE MOINS INTERROGE
FOIS
/* Les valeurs décrivant les notions non-terminales */ %type
EnteteDeFonction
%type %type %type
Expression Terme Facteur
DIVISE
374 Compilateurs avec C++
%type %type
IdentOuIterateur Iteration
/* L'axiome du langage */ %start Programme %% /* Les notions non-terminales du langage */ IdentOuIterateur : IDENT | ITERATEUR ; Programme : DefinitionOuEvaluation | Programme DefinitionOuEvaluation ; DefinitionOuEvaluation : Definition | Evaluation ; Definition : EnteteDeFonction { gAnalyseurFormula -> TraiterDebutDefinition ($1); } EGALE Expression { gAnalyseurFormula -> TraiterCorpsDefinition ($1, $4); } POINT_VIRGULE { gAnalyseurFormula -> TraiterFinDefinition ($1, $4); } ; EnteteDeFonction : IDENT { gAnalyseurFormula -> TraiterIdentFonction ($1); } Parametres { $$ = gAnalyseurFormula -> IdentFonction (); } ; Parametres : PAR_GAUCHE { gAnalyseurFormula -> TraiterDebutParametres (); } ParametresConcrets PAR_DROITE | /* vide */ ; ParametresConcrets : UnParametre | ParametresConcrets VIRGULE UnParametre ; UnParametre: IdentOuIterateur { gAnalyseurFormula -> TraiterParametre ($1); } ;
Appendice : réalisation en C++ 375
Evaluation : INTERROGE { gAnalyseurFormula -> TraiterDebutEvaluation (); } Expression POINT_VIRGULE { gAnalyseurFormula -> TraiterFinEvaluation ($3); }; Expression : MOINS Terme { gAnalyseurFormula -> TesterTypeAttendu ( gTypeNombre, $2 -> TypeLogique (), "terme"); $$ = new MoinsUnaire ($2); } | Expression PLUS Terme { gAnalyseurFormula -> TesterTypeAttendu ( gTypeNombre, $1 -> TypeLogique (), "expression" ); gAnalyseurFormula -> TesterTypeAttendu ( gTypeNombre, $3 -> TypeLogique (), "terme"); $$ = new Plus ($1, $3); } | Expression MOINS Terme
{ … }
| Terme ; Terme : Terme FOIS Facteur | Terme DIVISE Facteur
{ … } { … }
| Facteur ; Facteur : NOMBRE { $$ = new ValeurNombre ($1); } | IDENT { $$ = gAnalyseurFormula -> TraiterFacteurIdentSimple ($1); } | PAR_GAUCHE Expression PAR_DROITE { $$ = $2; } | Iteration | AppelDeFonction ; Iteration : ITERATEUR { gAnalyseurFormula -> TraiterDebutIteration ($1); /* empile une description d'appel */ } PAR_GAUCHE
376 Compilateurs avec C++
IdentOuIterateur VIRGULE Expression { gAnalyseurFormula -> TraiterBorneInf ($6); /* utilise la description d'appel */ } VIRGULE Expression { gAnalyseurFormula -> TraiterBorneSup ($9); /* utilise la description d'appel */ } VIRGULE { gAnalyseurFormula -> TraiterIndiceIteration ($4); /* utilise la description d'appel */ } Expression { gAnalyseurFormula -> TraiterExprIteree ($13); /* utilise la description d'appel */ } PAR_DROITE { $$ = gAnalyseurFormula -> TraiterFinIteration (); /* désempile la description d'appel */ } ; AppelDeFonction : IDENT { gAnalyseurFormula -> TraiterDebutAppelFonction ($1); /* empile une description d'appel */ } PAR_GAUCHE Arguments /* utilise la description d'appel */ PAR_DROITE { $$ = gAnalyseurFormula -> TraiterFinAppelFonction (); /* désempile la description d'appel */ } ; Arguments : ArgumentsConcrets | /* vide */ ;
Appendice : réalisation en C++ 377
ArgumentsConcrets : UnArgument | ArgumentsConcrets VIRGULE UnArgument ; UnArgument: { gAnalyseurFormula -> TraiterDebutArgument (); } Expression { gAnalyseurFormula -> TraiterFinArgument ($2); }; %% /* On doit fournir l'analyseur lexical */ #include "lex.yy.c" main (int nbArguments, char * arguments []) { … }
Le corps de la fonction main est présenté au paragraphe A.8.5. A.6
Evaluation et paramètres
A.6.1
Evaluation d’un appel de fonction Formula
Pour les besoins de l’évaluation directe des graphes sémantiques, nous enrichissons la classe AppelDeFonction, présentée au paragraphe 8.22, de la manière suivante, qui se prémunit des évaluations sans fin par un comptage du nombre d’appels à la méthode Evaluer : ValeurFormula AppelDeFonction :: Evaluer (ContexteEvalPtr leContexte) { static int compteur = 500; // garde fou ! if (-- compteur <= 0) { cout << "\n\n### ON COUPE LES FRAIS ###\n\n"; exit (37); } ListeParamsPtr
listeDescrParams = fFonctUtilisateur -> ListeParams ();
short
nombreDeParametres = listeDescrParams -> NombreDeParametres ();
ValeurFormula
res;
if (nombreDeParametres != 0) // CAS AVEC PARAMETRES { EvalArgPtr* blocDEvaluations = new EvalArgPtr [nombreDeParametres]; ContexteEvalPtr
nouveauContexte = new ContexteEval ( blocDEvaluations, leContexte -> Indentation () + 1, leContexte );
378 Compilateurs avec C++
IterateurParams DescrParamPtr short i;
iter (listeDescrParams); parametreCourant;
for ( (i = 0, parametreCourant = iter.PremierElement ()); iter.IlResteDesElements (); (++ i, parametreCourant = iter.ElementSuivant ()) ) blocDEvaluations [i] = parametreCourant -> CommentEvaluer ( nouveauContexte -> NumeroContexte (), fArgumentsDAppel [i], leContexte ); Indenter (leContexte -> Indentation ()); cout << form ( ">>> Appel à '%s' (contexte %d) avec comme paramètres:\n", fFonctUtilisateur -> Nom (), nouveauContexte -> NumeroContexte () ); for (short j = 0; j < nombreDeParametres; ++ j) { Indenter (leContexte -> Indentation ()); cout << "\t\t\t" << j + 1 << ": "; blocDEvaluations [j] -> Ecrire (leContexte -> Indentation ()); cout << "\n"; } // for res = fFonctUtilisateur -> Corps () -> Evaluer (nouveauContexte); delete nouveauContexte; delete blocDEvaluations; } else // CAS SANS PARAMETRES { Indenter (leContexte -> Indentation ()); cout << form ( ">>> Appel à '%s' (contexte %d) sans paramètres:\n", fFonctUtilisateur -> Nom (), leContexte -> NumeroContexte () ); res = fFonctUtilisateur -> Corps () -> Evaluer (leContexte); } // if Indenter (leContexte -> Indentation ()); cout << "<<< Résultat = "; res.Ecrire (); cout << "\n"; return res; } // AppelDeFonction :: Evaluer
Appendice : réalisation en C++ 379
On retrouve là un usage d’un itérateur sur la liste des paramètres, comme au paragraphe A.4.10. A.7
Environnement d’exécution
A.7.1
Jeu d’instructions de Pilum
Les codes opératoires de la machine Pilum sont : enum CodeOpPilum { iHalte,
iCommentaire,
iStocker,
iDesempiler,
iDupliquer,
iEmpilerValeur, iEmpilerEntier, iEmpilerCaractere,
iEmpilerAdresse, iEmpilerFlottant, iEmpilerChaine,
iEmpilerBooleen, iEmpilerValInconnue,
iSauter, iAppel, iEmpilerThunk,
iSauterSiFaux, iRetourDeFonction, iEvaluerThunk,
iLireEntier, iLireCaractere, iEcrireEntier, iEcrireCaractere,
iLireFlottant, iLireChaine, iEcrireFlottant, iEcrireChaine,
iIncrEntier, iMoins1Entier,
iDecrEntier, iMoins1Flottant,
iAbsEntier, iImpair,
iAbsFlottant,
iPlusEntier, iFoisEntier, iPlusFlottant, iFoisFlottant,
iMoinsEntier, iDiviseEntier, iMoinsFlottant, iDiviseFlottant,
iNon,
iEt,
iOu,
iEgaleEntier, iDifferentEntier, iEgaleFlottant, iDifferentFlottant, iEgaleChaine, iDifferentChaine,
iInfEntier, iSupEgaleEntier, iInfFlottant, iSupEgaleFlottant, iInfChaine, iSupEgaleChaine,
iInfEgaleEntier, iSupEntier, iInfEgaleFlottant, iSupFlottant, iInfEgaleChaine, iSupChaine,
iRacine, iHasard, iSin, iCos, }; // CodeOpPilum
iRetourDeProcedure, iLireBooleen, iEcrireBooleen, iEcrireFinDeLigne,
iModuloEntier,
iArcTan
380 Compilateurs avec C++
A.7.2
La machine Pilum
L’interface de la classe définissant la machine virtuelle Pilum est : class Pilum { public: Pilum ( char char long
* leNom, * nomDuFichierBinaire, tailleDeLaPile = kTailleDeLaPileParDefaut
); ~ Pilum (); void
ErreurFatale (char * message);
void void
CreerMemoire (long tailleDeLaMemoireDuCode); CreerPile (long tailleDeLaPile);
void void
DetruireMemoire (); DetruirePile ();
void
ChargerBinaire ( char * nomDuFichierBinaire, long tailleDeLaPile = kTailleDeLaPileParDefaut );
void
Executer ( AdresseCode GenreExecution Boolean
adresseDeDepart = 0, leGenreExecution = kEnContinu, afficherLeCode = false
); virtual void
InstructionInconnue ();
protected: char
* fNom;
ValeurPilumPtr long long
fPile; fSommet; // fLimiteSommet;
InstructionPilumPtr long AdresseCode
fMemoireDuCode; fTailleDeLaMemoireDuCode; fInstructionCourante;
EtatPilum AdressePile
fEtatCourant; fEnvCourant;
AdressePile
RemonteeStatique ( AccesStatique lAccesStatique );
0 <=> pile vide
Appendice : réalisation en C++ 381
virtual void virtual void virtual void
AfficherLaMemoireDuCode (); AfficherLaPile (); AfficherLEtatInterne ( char * leTitre, Boolean afficherLeCode = false );
virtual void
InteragirAvecLUtilisateur ( AdresseCode lInstructionCourante, GenreExecution& leGenreExecution ); AfficherLesCommandes ();
virtual void }; // Pilum A.7.3
Exemple de passage par besoin avec Pilum
En compilant le source Formula contenant la fonction CarrePlus : CarrePlus (x, y) = x * x + y; ? CarrePlus (LireNombre (), 6);
avec passage des paramètres par besoin, on obtient le code : 0: Sauter
52
1: 2: 3: 4:
Commentaire: EmpilerValeur Commentaire: SauterSiFaux
'Début du corps de 'CarrePlus'' 0,-10 'Bool. de contr. du par besoin x (no 1)' 15
5: 6: 7: 8: 9: 10:
EmpilerAdresse Commentaire: EmpilerAdresse Commentaire: EvaluerThunk Stocker
0,-9 'Resultat de x (no 1)' 0,-8 'Thunk du par besoin x (no 1)'
11: 12: 13: 14:
EmpilerAdresse Commentaire: EmpilerBooleen Stocker
0,-10 'Bool, de contr. du par besoin x (no 1)' faux
15: EmpilerValeur 16: Commentaire:
0,-9 'Valeur du par besoin x (no 1)'
17: EmpilerValeur 18: Commentaire: 19: SauterSiFaux
0,-10 'Bool. de contr. du par besoin x (no 1)' 30
20: 21: 22: 23: 24: 25:
EmpilerAdresse Commentaire: EmpilerAdresse Commentaire: EvaluerThunk Stocker
0,-9 'Resultat de x (no 1)' 0,-8 'Thunk du par besoin x (no 1)'
26: 27: 28: 29:
EmpilerAdresse Commentaire: EmpilerBooleen Stocker
0,-10 'Bool, de contr. du par besoin x (no 1)' faux
382 Compilateurs avec C++
30: 31: 32: 33: 34: 35:
EmpilerValeur Commentaire: FoisFlottant EmpilerValeur Commentaire: SauterSiFaux
0,-9 'Valeur du par besoin x (no 1)'
36: 37: 38: 39: 40: 41:
EmpilerAdresse Commentaire: EmpilerAdresse Commentaire: EvaluerThunk Stocker
0,-5 'Resultat de y (no 2)' 0,-4 'Thunk du par besoin y (no 2)'
42: 43: 44: 45:
EmpilerAdresse Commentaire: EmpilerBooleen Stocker
0,-6 'Bool, de contr. du par besoin y (no 2)' faux
46: 47: 48: 49: 50: 51:
EmpilerValeur Commentaire: PlusFlottant RetourDeFonction Commentaire: Commentaire:
0,-5 'Valeur du par besoin y (no 2)'
52: 53: 54: 55: 56: 57:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Sauter
58: 59: 60: 61: 62:
Commentaire: LireFlottant RetourDeFonction Commentaire: Commentaire:
0,-6 'Bool. de contr. du par besoin y (no 2)' 46
9 'dont 1 pour le LS de cette fonction' 'Fin du corps de 'CarrePlus'' 'Début d'une évaluation' Valeur: 63 'Début du Thunk pour "x"' 1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "x"'
63: EmpilerBooleen 64: EmpilerValInconnue 65: EmpilerThunk
58
66: Sauter
72
67: 68: 69: 70: 71:
'Début du Thunk pour "y"' 6.000000 1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "y"'
Commentaire: EmpilerFlottant RetourDeFonction Commentaire: Commentaire:
vrai
Appendice : réalisation en C++ 383
A.7.4
72: EmpilerBooleen 73: EmpilerValInconnue 74: EmpilerThunk
vrai 67
75: 76: 77: 78:
EmpilerAdresse Commentaire: Appel Commentaire:
0,0 'LS pour l'appel de fonction' 1 'CarrePlus'
79: 80: 81: 82: 83: 84: 85:
EcrireFlottant EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire: Halte
================= 'Fin d'une évaluation'
Passages par noms imbriqués
Le code objet du programme puissance4 : carre (n) = n * n; puissance4 (n) = carre (carre (n)); ? puissance4 (3);
est : 0: Sauter 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
Commentaire: EmpilerAdresse Commentaire: EvaluerThunk EmpilerAdresse Commentaire: EvaluerThunk FoisFlottant RetourDeFonction Commentaire: Commentaire:
12 'Début du corps de 'carre'' 0,-4 'Thunk du par nom n (no 1)' 0,-4 'Thunk du par nom n (no 1)' 3 'dont 1 pour le LS de cette fonction' 'Fin du corps de 'carre''
12: Sauter
40
13: Commentaire: 14: Sauter
'Début du corps de 'puissance4'' 32
15: Commentaire:
'Début du Thunk pour "n"'
16: Sauter
24
17: 18: 19: 20:
Commentaire: EmpilerAdresse Commentaire: EvaluerThunk
'Début du Thunk pour "n"' 2,-4 'Thunk du par nom n (no 1)'
384 Compilateurs avec C++
21: RetourDeFonction 22: Commentaire: 23: Commentaire:
1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "n"'
24: 25: 26: 27: 28: 29: 30: 31:
EmpilerThunk EmpilerAdresse Commentaire: Appel Commentaire: RetourDeFonction Commentaire: Commentaire:
17 1,0 'LS pour l'appel de fonction' 1 'carre' 1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "n"'
32: 33: 34: 35: 36: 37: 38: 39:
EmpilerThunk EmpilerAdresse Commentaire: Appel Commentaire: RetourDeFonction Commentaire: Commentaire:
15 0,0 'LS pour l'appel de fonction' 1 'carre' 3 'dont 1 pour le LS de cette fonction' 'Fin du corps de 'puissance4''
40: 41: 42: 43: 44:
Commentaire: EcrireFinDeLigne EmpilerChaine EcrireChaine EcrireFinDeLigne
'Début d'une évaluation' Valeur:
45: Sauter
51
46: 47: 48: 49: 50:
Commentaire: EmpilerFlottant RetourDeFonction Commentaire: Commentaire:
'Début du Thunk pour "t"' 3.000000 1 'soit 1 pour le LS de ce thunk' 'Fin du Thunk pour "t"'
51: 52: 53: 54: 55:
EmpilerThunk EmpilerAdresse Commentaire: Appel Commentaire:
46 0,0 'LS pour l'appel de fonction' 13 'puissance4'
56: EcrireFlottant 57: EcrireFinDeLigne 58: 59: 60: 61:
EmpilerChaine EcrireChaine EcrireFinDeLigne Commentaire:
62: Halte
================= 'Fin d'une évaluation'
Appendice : réalisation en C++ 385
A.8
Synthèse du code objet
A.8.1
Le synthétiseur de code Pilum
Le synthétiseur de code Pilum est déclaré par : class SynthetiseurPilum { typedef SynthetiseurPilum* SynthetiseurPilumPtr; public: SynthetiseurPilum (char * leNom); ~ SynthetiseurPilum (); VarLogEtiquPtr void
CreerEtiquette (char * leSuffixe); PlacerEtiquette ( VarLogEtiquPtr laVarLogEtiquPtr );
void
Commentaire (char * leTexte);
void
Zeroadique (CodeOpPilum leCodeOpPilum);
void
Saut (VarLogEtiquPtr lEtiquette);
void
Saut ( CodeOpPilum VarLogEtiquPtr
leCodeOpPilum, lEtiquette );
void
ReserverCellules ( VarLogEntierePtr leNombreLogTemporaires );
void
AccesCellule ( AccesStatique lAccesStatique, GenreAccesCellule leGenreAcces );
void
AccesCellule ( short laDifferenceStatique, short leDeplacement, GenreAccesCellule leGenreAcces );
void void
Entier (long lEntier); Entier ( CodeOpPilum leCodeOpPilum, long lEntier );
void void void void
Flottant (float leFlottant); Logique (Boolean leLogique); Caractere (char leCaractere); Chaine (char * laChaine);
void
ValeurInconnue ();
void
Appel (VarLogEtiquPtr lEtiquette);
void
LienStatique ( short niveauDAppel, short niveauDeDeclaration );
386 Compilateurs avec C++
void
Thunk (VarLogEtiquPtr lEtiquette);
virtual void
Optimiser ();
virtual void
FinaliserLeCodeBinaire ();
virtual void virtual void
EcrireTexte (ostream * leFlot); EcrireTexte (char * nomDuFichierTexte);
virtual void virtual void
EcrireBinaire (ofstream * leFichier); EcrireBinaire (char * nomDuFichierBinaire);
protected: char
* fNom;
InstrOuEtiquPtr InstrOuEtiquPtr long
fDebutDuCode; fFinDuCode; fNombreDInstructions;
long long
fTailleDesChaines; fCompteurEtiquettes;
void void
Inserer (InstrOuEtiquPtr lInstrOuEtiquPtr); Supprimer (InstrOuEtiquRef lInstrOuEtiquRef);
long
TailleAEcrirePour (char * laChaine);
void void
EcrireLesChaines (ofstream * leFichier); EcrireLesInstructions (ofstream * leFichier);
void void }; // A.8.2
DeterminerLesAdresses (); MettreAJourLesInconnues (); SynthetiseurPilum
Synthèse pour les graphes sémantiques simples
Voici le cas de la synthèse de code pour l’instruction de séquencement Seq1 : void Seq1 :: Synthetiser (ContexteSynthPtr leContexte) { TypePtr leTypeDroit = fOperandeDroit -> TypeLogique () -> ValeurLiaison (); fOperandeGauche -> Synthetiser (leContexte); fOperandeDroit -> Synthetiser (leContexte); switch (leTypeDroit-> GenreType ()) { case kTypeNombre: case kTypeBooleen: leContexte -> SynthPilum () -> Entier (iDesempiler, 1); // on jette la valeur droite break; case kTypeVide: // RIEN A FAIRE break; } // switch } // Seq1 :: Synthetiser
Appendice : réalisation en C++ 387
A.8.3
Synthèse pour les paramètres par besoin
Le code pour l’emploi d’un paramètre par besoin est synthétisé par : void EmploiParamParEsseux :: Synthetiser (ContexteSynthPtr leContexte) { DescrParamPtr laDescrParam = fParamFormel -> DescrParam (); SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); short leNumeroDeParametre = laDescrParam -> NumeroDeParametre (); short positionDeBase = laDescrParam -> PositionDansLeBloc (); short positionBooleenControle = positionDeBase; short positionResultat = positionDeBase + 1; short positionThunk = positionDeBase + 2; short differenceStatique = leContexte -> NiveauStatique (); // tous les parametres sont au niveau 0 // le thunk doit-il etre encore etre evalue? synth -> AccesCellule ( differenceStatique, positionBooleenControle, kPourValeur ); synth -> Commentaire ( form ( "Booleen de controle du Par Besoin %s (no %d)", laDescrParam -> ParamFormel () -> Nom (), leNumeroDeParametre) ); VarLogEtiquPtr interParesseux = synth -> CreerEtiquette ("interParesseux"); synth -> Saut (iSauterSiFaux, interParesseux); // si oui, on fait charger l'adresse du resultat: synth -> AccesCellule ( differenceStatique, positionResultat, kPourAdresse ); synth -> Commentaire ( form ( "Resultat de %s (no %d)", laDescrParam -> ParamFormel () -> Nom (), laDescrParam -> NumeroDeParametre () )); // puis on fait charger l'adresse du thunk: synth -> AccesCellule ( differenceStatique, positionThunk, kPourAdresse ); synth -> Commentaire ( form ( "Thunk de %s (no %d)", laDescrParam -> ParamFormel () -> Nom (), laDescrParam -> NumeroDeParametre () )); // puis on le fait evaluer: synth -> Zeroadique (iEvaluerParNom); // puis on sauvegarde la valeur resultante: synth -> Zeroadique (iStocker);
388 Compilateurs avec C++
// on fait charger l'adresse du booleen de controle: synth -> AccesCellule ( differenceStatique, positionBooleenControle, kPourAdresse ); synth -> Commentaire ( form ( "Booleen de controle du Par Besoin %s (no %d)", laDescrParam -> ParamFormel () -> Nom (), leNumeroDeParametre) ); // puis on lui affecte la valeur 'faux': synth -> Logique (false); synth -> Zeroadique (iStocker); // dans tous les cas, on fait empiler la valeur de l'argument: synth -> PlacerEtiquette (interParesseux); synth -> AccesCellule ( differenceStatique, positionResultat, kPourValeur ); synth -> Commentaire ( form ( "Valeur du Par Besoin %s (no %d)", laDescrParam -> ParamFormel () -> Nom (), leNumeroDeParametre) ); } // EmploiParamParEsseux :: Synthetiser
Le lecteur est invité à suivre à la main l’effet du code postfixé synthétisé par le méthode ci-dessus pour se convaincre de son effet. A.8.4
Optimisation des sauts sur des sauts
Voici la méthode qui synthétise le code pour les définitions de fonctions Formula : void SynthePilumFormula :: SynthetiserDefinition ( FonctUtilisateurPtrlIdentFonction, DescrSemFormulaPtrleCorps ) { Boolean lienStatiqueNecessaire = lIdentFonction -> LienStatiqueNecessaire (); short
positionDeDepart = lienStatiqueNecessaire ? - (kTailleDesLiensObligatoires + 1) // 1 pour le lien statique : - kTailleDesLiensObligatoires;
ListeParamsPtr
listeDescrParams = lIdentFonction -> ListeParams ();
listeDescrParams -> AllouerLesParametres (positionDeDepart); short
tailleParametres = listeDescrParams -> TailleDesParametres ();
Appendice : réalisation en C++ 389
short
tailleADesempiler = lienStatiqueNecessaire ? tailleParametres + 1 // 1 pour le lien statique : tailleParametres;
char
* leNom = lIdentFonction -> Nom ();
DescrActivationPtr
laDescrActivation = new DescrActivation;
VarLogEntierePtr
leNombreLogTemporaires = new VarLogEntiere;
VarLogEtiquPtr
etiquDuCorps = CreerEtiquette ("corps");
VarLogEtiquPtr
etiquApresDefinition = CreerEtiquette ("apresDefinition");
lIdentFonction -> EtiquetteDuCorps (etiquDuCorps); Saut (iSauter, etiquApresDefinition); PlacerEtiquette (etiquDuCorps); Commentaire (form ("Début du corps de '%s'", leNom)); ReserverCellules (leNombreLogTemporaires); VarLogEtiquPtr
continCorps = CreerEtiquette ("continCorps");
leCorps -> Synthetiser ( new ContexteSynth (this, 0, laDescrActivation, continCorps)); PlacerEtiquette (continCorps); short
nbTemporaires = laDescrActivation -> NombreTemporairesSimultanes ();
leNombreLogTemporaires -> UnifierValeur (nbTemporaires); Entier (iDesempiler, nbTemporaires); TypePtr
leType = leCorps -> TypeLogique () -> ValeurLiaison ();
Entier ( leType-> GenreType () == kTypeVide ? iRetourDeProcedure : iRetourDeFonction, tailleADesempiler ); if (lienStatiqueNecessaire) Commentaire ("dont 1 pour le LS de cette fonction"); Commentaire (form ("Fin du corps de '%s'", leNom)); PlacerEtiquette (etiquApresDefinition); } // SynthePilumFormula :: SynthetiserDefinition
La synthèse du code pour Seq peut éviter les sauts sur des sauts au moyen de : void Seq :: Synthetiser (ContexteSynthPtr leContexte) { SynthePilumFormulaPtr synth = leContexte -> SynthPilum ();
390 Compilateurs avec C++
short
nIveauStatique = leContexte -> NiveauStatique ();
DescrActivationPtr
descrActivation = leContexte -> DescrActivation ();
VarLogEtiquPtr
continOperandeGauche = synth -> CreerEtiquette ("continOperandeGauche");
fOperandeGauche -> Synthetiser ( new ContexteSynth ( synth, nIveauStatique, descrActivation, continOperandeGauche) ); synth -> PlacerEtiquette (continOperandeGauche); TypePtr
leTypeGauche = fOperandeGauche -> TypeLogique () -> ValeurLiaison ();
switch (leTypeGauche-> GenreType ()) { case kTypeNombre: case kTypeBooleen: leContexte -> SynthPilum () -> Entier (iDesempiler, 1); // on jette la valeur gauche break; case kTypeVide: // RIEN A FAIRE break; } // switch fOperandeDroit -> Synthetiser (leContexte); } // Seq :: Synthetiser
Le cas de la conditionnelle Si, quant à lui, est traité par : void Si :: Synthetiser (ContexteSynthPtr leContexte) { SynthePilumFormulaPtr synth = leContexte -> SynthPilum (); VarLogEtiquPtr
etiquSiFaux = synth -> CreerEtiquette ("siFaux");
short
nIveauStatique = leContexte -> NiveauStatique ();
DescrActivationPtr
descrActivation = leContexte -> DescrActivation ();
VarLogEtiquPtr VarLogEtiquPtr
laContinuation = leContexte -> Continuation (); continCondition = synth -> CreerEtiquette ("continCondition");
fCondition -> Synthetiser ( new ContexteSynth ( synth, nIveauStatique, descrActivation, continCondition) ); synth -> PlacerEtiquette (continCondition); synth -> Saut (iSauterSiFaux, etiquSiFaux);
Appendice : réalisation en C++ 391
fValeurSiVrai -> Synthetiser ( new ContexteSynth ( synth, nIveauStatique, descrActivation, laContinuation) ); synth -> Saut (iSauter, laContinuation); synth -> PlacerEtiquette (etiquSiFaux); fValeurSiFaux -> Synthetiser ( new ContexteSynth ( synth, nIveauStatique, descrActivation, laContinuation) ); } // Si :: Synthetiser
Pour une évaluation Formula introduite par “?“, la continuation est définie comme étant la séquence de code qui affiche un résultat s’il y a lieu et fait terminer l’exécution. Cela est fait par : void SynthePilumFormula :: SynthetiserEvaluation ( DescrSemFormulaPtr lExpression ) { Boolean vraieValeur = lExpression -> VraieValeur (); Commentaire ("Début d'une évaluation"); Zeroadique (iEcrireFinDeLigne); if (vraieValeur) Chaine ("Valeur:"); else Chaine ("Execution..."); Zeroadique (iEcrireChaine); Zeroadique (iEcrireFinDeLigne); VarLogEtiquPtr continExpression = CreerEtiquette ("continExpr"); lExpression -> Synthetiser ( new ContexteSynth (this, 0, fDescrActivation, continExpression)); PlacerEtiquette (continExpression); TypePtr
leType = lExpression -> TypeLogique () -> ValeurLiaison ();
switch (leType-> GenreType ()) { case kTypeNombre: Zeroadique (iEcrireFlottant); break; case kTypeBooleen: Zeroadique (iEcrireBooleen); break; case kTypeVide: // RIEN A FAIRE break; } // switch
392 Compilateurs avec C++
if (vraieValeur) { Zeroadique (iEcrireFinDeLigne); Chaine ("================="); Zeroadique (iEcrireChaine); } else { Chaine ("...Fin"); Zeroadique (iEcrireChaine); } Zeroadique (iEcrireFinDeLigne); Commentaire ("Fin d'une évaluation"); } // SynthePilumFormula :: SynthetiserEvaluation A.8.5
Programme principal du compilateur Formula
Comme les deux compilateurs diffèrent très peu de ce point de vue, nous ne listons ci-dessous que le programme principal de celui qui est basé sur Lex et Yacc : main (int nbArguments, char * arguments []) { // … … … … … ofstream
fichierBinaire (nomDuFichierBinaire, ios :: out);
if (! fichierBinaire) { cerr << form ( "Pas pu ouvrir le fichier '%s' en ecriture\n", nomDuFichierBinaire ) << flush; exit (1); } SynthePilumFormulaPtr
Boolean Boolean Boolean Boolean
leSynthePilumFormula = new SynthePilumFormula ( "Synthétiseur Formula -> Pilum", & cout, & fichierBinaire );
montrerGrapheSemantique montrerCodePostfixe evaluerDirectement evaluerParPilum
= = = =
true; laTailleDeLaPile == 0; laTailleDeLaPile == 0; laTailleDeLaPile != 0;
gAnalyseurFormula = new AnalyseurFormula ( leSynthePilumFormula, lePassageParams, montrerGrapheSemantique, montrerCodePostfixe, evaluerDirectement, evaluerParPilum ); char
* argv [2];
Appendice : réalisation en C++ 393
argv [0] = "LexYaccFormula"; argv [1] = nomDuFichierSource; if (! LexYaccAnalyser (argc, argv)) ; // RIEN delete gAnalyseurFormula; delete leSynthePilumFormula; // force l'execution du destructeur, // charge de la terminaison de la synthese fichierBinaire.close (); if (evaluerParPilum) { Pilum laMachine ( "Pilum pour Formula", nomDuFichierBinaire, laTailleDeLaPile, true ); laMachine.Executer (0, kEnContinu); } } // main
394 Compilateurs avec C++
Bibliographie
Bibliographie Nous présentons ci-dessous des ouvrages triés par domaine, avec un commentaire pour les plus intéressants d’entre eux. Dans chaque domaine, les références sont présentées par ordre chronologique.
Implantation des langages en général [Randell & Russel 64]
Randell B. & Russel L.J., “Algol 60 implementation“ Academic Press, 1964 Montre en détail comment implanter Algol 60, langage alors tout récent, y compris le passage par nom. Contient cette remarque superbe : “la notion de pile semble tellement importante qu’elle pourrait bien influencer l’architecture des machines dans le futur“ ! [Wirth 71]
Wirth N., “The design of a Pascal compiler“ Software : Practise and Experience, 1:4, 309-333, 1971 Décrit l’architecture du compilateur Pascal original. [Abelson & Sussman 84]
Abelson H. & Sussman G.J., “Structure and interpretation of computer programs“ MIT Press, 0-262-01077-1, 1984 Contient une discussion intéressante sur les langages et leur implantation, ainsi que sur l’évaluation paresseuse et les structures de données infinies, avec des exemples en Scheme. [Aho, Sethi & Ullman 88]
Aho A.V., Sethi R. & Ullman J.D., “Compilateurs : principes, techniques et outils“ Addison-Wesley, 0-7296-0295-X, 1988 La bible du domaine, contenant une discussion détaillée de bien des aspects de la compilation. [Fischer & LeBlanc 88]
Fischer C.N. & LeBlanc R.J., “Crafting a compiler“ The Benjamin/Cummings Publishing Company, Inc., 0-8053-3201-4, 1988 Illustre les techniques mises en œuvre dans des compilateurs industriels, y compris l’optimisation fine du code objet. [Robson 88]
Robson R., “Practical compiling with Pascals-S“ Addison-Wesley, 1988, 0-201-18487-7
396 Compilateurs avec C++
Aspects spécifiques de la compilation [Wulf, Johnsson & al. 75]
Wulf W.A., Johnsson & al., “The design of an optimizing compiler“ American Elsevier, 1975 Décrit les algorithmes utilisés par le compilateur Bliss sur PDP/11. Le but de ce compilateur était de synthétiser du code en langage d’assemblage d’aussi bonne qualité qu’un bon programmeur, et il y arrive ! [Amman 75]
Amman U., “Die Entwicklung eines Pascal-compilers nach der Methode des Strukturierten Programmierens“ Juris Druck+Verlag, Zürich, 1975 Thèse de doctorat, en langue allemande, de l’auteur du premier compilateur Pascal. Décrit entre autres la méthode des arrêteurs pour le rattrapage d’erreurs syntaxiques, présentée au paragraphe 7.11, et des heuristiques de gestion des registres. [Campan & al. 75]
Campan Y. et Al., “Optimisation globale des programmes - Application au langage LIS“ CII, Note de programmation DT/DLB/RP/75/008, 1975 Traite l’optimisation globale des programmes à l’aide d’équations booléennes. Avantage : on manipule 32 variables à la fois sur des machines 32 bits… [Amman 77]
Amman U., “On code generation in a Pascal compiler“ Sofware : Practise and Experience, 7:3, 391-423, 1977 Décrit les aspects de la synthèse de code dans le compilateur Pascal original en une passe. Ces algorithmes ont fortement inspiré ceux du compilateur Newton, qui sont illustrés au paragraphe 12.16. [Milner 78]
Milner R., “A theory of type polymorphism in programming“ Journal of Computer and System Sciences, 17:3, 348-375, 1978 Décrit l’algorithme d’inférence de type adopté pour SML. [Barron 81]
Barron D.W., “Pascal - The language and its implementation“ Wiley, Chichester, 1981 Collection d’articles décrivant les différentes implantations de Pascal, contient le code source du compilateur/interprète Pascal-S. [DeRehmer & Pennello 82]
DeRehmer F. & Pennello T., “Efficient computation of LALR(1) look-ahead sets“ TOPLAS, 4:4, 615-649, 1982 Décrit en détail les meilleurs algorithmes du monde connu pour compacter les tables d’analyse LALR(1). [Warren 83]
Warren D.H.D., “An abstract Prolog instruction set“ Technical Note 309 Computer Science and Technology Division SRI International, 1983 Décrit en détail les considérations ayant amené à l’architecture de la machine virtuelle PLM.
Bibliographie 397
[Menu 83]
Menu J., “The general heap, a high-level concept“ Thèse de doctorat no 495 Ecole Polytechnique Fédérale de Lausanne, 1983 Présente la gestion de la mémoire à l’aide d’un tas (heap) mise en œuvre dans l’implantation du langage Newton. [Van Roy 84]
Van Roy P., “A Prolog compiler for the PLM“ Report UCB/CSD 84/203 University of California, 1984 Thèse de doctorat, décrit en détail un autocompilateur Prolog synthétisant du code pour la machine virtuelle PLM de Warren. Le code source est intégralement listé. [Garbinato 93]
Garbinato B., “Trison : Yet Another Bison“ Mémoire de diplôme Laboratoire de Compilation Ecole Polytechnique Fédérale de Lausanne, 1993 Documente les techniques de compactage des tables de Bison ainsi que son dérivé Trison, réalisé dans le cadre de ce travail.
Langages en général [Chomsky 69]
Chomsky N., “Structures syntaxiques“ Editions du Seuil, 2-02-005073-0, 1969 Réflexions sur une théorie générale formalisée de la structure linguistique. [Menu 92]
Menu J., “Langages informatiques : concepts et exemples“ Support de cours polycopié Laboratoire de Compilation Ecole Polytechnique Fédérale de Lausanne, 1992 Présente les langages informatiques du point de vue de la sémantique comparée. Contient une présentation de l’évolution de la programmation orientée objets de Simula 67 à Smalltalk 80 en passant par C++, des exemples de programmation par contraintes, ainsi que le détail des algorithmes classiques de gestion de la mémoire.
Programmation en Logique et Prolog [Clocksin & Mellish 84]
Clocksin W.F. & Mellish C.S., “Programming in Prolog“ Springer Verlag, 0-387-11046-1, 1984 La référence classique sur le langage Prolog et ses applications. [Bratko 86]
Bratko I., “Prolog programming for artificial intelligence“ Addison-Wesley, 0-201-14224-4, 1986 Autre référence classique sur le langage, et ses applications à l’intelligence artificielle.
Programmation fonctionnelle [Winston & Horn 84]
Winston P.H. & Horn B.K.P., “Lisp“ Addison-Wesley, 0-201-08372-8, 1984 Présentation complète du langage et de ses possibilités.
398 Compilateurs avec C++
[Paulson 91]
Paulson L., “ML for the working programmer“ Cambridge University Press, 0-521-39022-2, 1991 Le langage ML, précurseur de SML et qui en diffère peu, présenté en détail avec de nombreux exemples.
Conception et langages orientés objets [Birtwistle & al. 73]
Birtwistle G. & al., “Simula Begin“ Van Nostrand Reinhold, 0-905897-37-4, 1973 Le langage Simula 67, précurseur de l’approche objets. [Meyer 88]
Meyer B., “Object-oriented software construction“ Prentice Hall, 0-13-629049-3, 1988 Ouvrage de référence. Intéressante discussion sur les outils et les projets logiciels. [Dewhurst & Stark 89]
Dewhurst S. & Stark K., “Programming in C++“ Prencice Hall Software Series, 0-13-723156-3, 1989 Bonne introduction à la puissance d’expression de C++. [Ellis & Stroustrup 90]
Ellis M.A & Stroustrup B., “The annotated C++ reference manual“ Addison-Wesley, 0-201-51459-1, 1990 La référence sur C++, avec des considérations sur les choix de conception du langage. [Dugerdil 90]
Dugerdil Ph., “Smalltalk-80“ Presses Polytechniques Romandes, 2-88074-182-3, 1990 Bonne présentation du langage Smalltalk, où tout est objet, illustrée par des exemples.
Programmation par contraintes [Leler 88]
Leler A., “Contraint programming languages“ Addison-Wesley, 0-201-06243-7, 1988 Présente les concepts et exemples classiques de la programmation par contraintes, ainsi qu’un aperçu des difficultés d’implantation de tels langages. [Dincbas & al. 88]
[Fron 94]
Dincbas M. & al., “The constraints logic programming language CHIP“ in Proceedings of International Conference on 5th Generation Computer Systems, FGGS-88, Tokyo, Japon, 1988
Fron A., “Programmation par contraintes“ Addison-Wesley, 2-87908-062-2, 1994 Présente l’état de l’art des outils de programmation par contraintes avec des exemples concrets, ainsi que les algorithmes classiques d’implantation des contraintes.
Index
Index Symboles 99, 117 - 67, 117 -- 87
23 " 59, 79, 348 "" 42 "." 117 # 87 define 31 endif 31 ifndef 31 include 31, 52, 210, 213 #' 158 #= 15 $ 87, 111, 117 $ 231, 232 default 222 i 231, 232 $$ 232 % 87 % 104, 116, 210, 374, 377 { 106, 107, 109, 212, 213, 215 } 106, 108, 109, 212, 213 left 224 nonassoc 224 prec 224, 225 right 224 start 119, 212, 214, 216, 374 token 212, 214, 215, 231 type 230, 231, 232 union 212, 214, 231, 232 & 53 && 253 ' 158 ( 59, 108 (* 86 (*$ 87 ) 108 * 60, 61, 64, 67, 108, 117 *) 86
*/ 87, 98, 211 + 64, 108, 117 . 109, 117, 158, 159, 207 / 117 /* 87, 98, 211 // 87, 98 : 215 ; 87, 111, 124, 127, 128, 135, 158, 215 < 231 <= 109 =3 --> 42, 43 > 231 ? 3, 24, 108, 117, 169, 270, 363 [ 67 \ 108 \n 108 \t 108 ] 67 ^ 117 ^= 15 _ 108 { 59, 87, 107, 109, 117 | 108, 110, 117, 215 || 253 } 87, 107, 109, 117 …6
Numériques 0 fois ou plus 59, 67, 70, 117, 125 0 ou 1 fois 59 1 fois ou plus 59, 108, 117
A
abstract syntax 186 accept 147 accepter 144, 151 AccepterArgumentsSuperflus 369 accepteur 44, 63, 121, 124 accès à la valeur d'une variable 187 aux champs 23, 181
d'un enregistrement 181 accessibilité 264, 267, 269, 297 accumulation 302 action 109, 147, 148, 149 par défaut 109 sémantique 216, 217, 231 activation block 269 Ada 11, 52, 84, 242, 253, 337 adresse 187, 242, 265, 297 de retour 271 Algol 60 168, 242, 243, 265 algorithme d'analyse 88 d’analyse 77 de décision 77 de Markov 1 alias 163, 241, 269 alldifferent 17 allocation des registres 300 des variables 342 la plus récente 268, 273 la plus tardive possible 337 statique 264, 265, 270 AllouerTemporaire 313 alternative 124 ambiguïté 25, 64, 76, 85, 118, 129, 140, 220, 222 analyse 36, 63 ascendante 123 d’une grammaire 159 descendante 92, 123 LALR(1) 150 lexicale 36, 43, 81, 125, 351 LR 140 LR(1) 124 LR(1) canonique 150 par priorités d'opérateurs 124, 137 prédictive 92, 124, 125 sémantique 37, 43, 121 simple LR 148 SLR(1) 150 syntaxique 37, 43, 124
400 Compilateurs avec C++
analyseur lexical 210, 217 syntaxique 209 AnalyseurDescendantFormula 130 AnalyseurLexicalFormula 130 Appel 385 appel de fonction 257 le plus récent 268, 273 récursif 137, 267, 275 séquence infinie de 245 terminal 158, 300, 302, 337 terminal optimisé 301 AppelDeFonction 199, 202, 325 AppelDeFonctPredef 368 AppelDeFonctUtilisateur 201, 202 appels mutuels 130 arbre 35, 62, 72, 74 de dérivation 62, 63, 64, 70, 72, 121, 141 unicité 62 de dictionnaires 182 de recherche 180 sémantique 338 argument d’appel 283 facultatif 299 argument d'appel 241, 268 argument d’appel 285, 286, 296, 323 Arguments 202 ariable logique 178 arité 46, 72, 75 arrêteurs 135 assembleur 30, 95 croisé 95 asserta 36 association des opérandes aux opérateurs 64, 72, 73, 222 associativité 43, 72, 74, 75, 96, 137, 212, 220, 222, 224, 225 à droite 158 à gauche 74 atof 94, 95, 107, 111, 112, 350 attribut 231 autocompilateur 7, 55, 82, 165, 181, 337, 397 auto-interprète 53, 55, 251 automate à états finis 88 à pile 68 fini déterministe 66 avancée contrôle de 85 systématique 130 Avancer 131
axiome 58, 123, 212, 216
B
back end 38 Backus-Naur étendue 59 basic block 335 batch 342 BEGIN 119 Bliss 327, 396 bloc d'activation 269, 339, 341 construction 298 d’activation 283, 342 destruction 285, 299 empilement 302 d’arguments 201 de base 335 des identificateurs prédéfinis 181 imbriqué 272 père 270, 271, 274 Booléen 170, 172 bottom-up 62, 123 boucle 339 boucle d’interprétation 39, 46, 282
C
C 158, 242 C-- 7, 95, 96, 98, 99, 351 C++ 6, 96, 99, 105, 130, 167, 181, 211, 214, 243, 365, 397, 398 cache 270 calculabilité 245, 246 call by name 242 by need 242, 247 by reference 241 by value 241 by value-result 242 caractère accentué 104 autre que la fin de ligne 109 consommation 85 ignoré 111 lecture 85 cascade de messages 133, 227 chaînage arrière 54 chaîne statique 277 CHIP 15, 342, 398 Chomsky 66, 82 circularité 53 classe 243 de terminaux 79 code Ascii 118 auto modifiant 35 binaire 280 de service 112
en ligne 131, 335 objet 30 postfixé 219 source 30 CodeOpPilum 279, 379 commentaire 86, 109 imbriqué 80, 87, 102, 120 CommentEvaluer 255 common subexpression detection 334 compilateur croisé 32, 95 de compilateurs 55 de grammaires 31 de pièces de puzzle 342 interprète 396 interpréteur 55 compilation 30 conditionnelle 31 incrémentale 35, 36, 264, 342 indépendante 52 séparée 52, 112, 217 compiler 30 des dépendances 31 une grammaire 31, 103, 209 compiler compiler 55, 209 compiler generator 55 compile-time 30 complétude 244, 245 complexité grammaticale 141 conditionnelle 40, 130, 329 conflit 220, 224, 228 consommer/réduire 148, 149, 150, 155, 220, 222, 225 LR 148 réduire/réduire 148, 150, 155, 220, 223, 224, 225 connaissances d’expérience 134 cons 248 consommation d’opérandes 38 de terminaux 151 consommer 144, 149, 151, 222, 232 constant folding 333 propagation 334 constante octale 118 prédéfinie 23, 167, 177, 179 constructeur 89, 365 construction de Thompson 88 du bloc d'activation 298 du graphe sémantique 219 erronée 174 explicite 180, 182, 190, 219, 247
Index 401
contexte d’évaluation 272 de l'appel 244 de l’appel 285 ContexteEval 251, 269 context-free grammar 68 continuation 301, 303, 304, 329, 339 contrainte sur la syntaxe 166 sur les types 170 control unit 29 conversion implicite 165, 187 court-circuit 253, 320 CPlus 105, 212 CreerEtiquette 315, 385 cross assembler 95 compiler 32, 95
D
DAG 174 dangling pointer 269 de bas en haut 62, 219 droite à gauche 140 gauche à droite 126, 140, 219 haut en bas 62 dead store elimination 334 débordement de capacité 95 DEBUT_EXPR 138 décalage arithmétique à gauche 300 décidabilité 127, 128 semi 76 déclaration 105, 264, 366 d'un indice d’itération 168 d’opérateur 75 globale 275 locale 274 décompilation 35 incrémentale 35, 36 décomposition de problèmes 268, 273, 277 Decrire 111, 112, 113, 114 defaut, voir par défaut définition 264, 266, 345 delete 268 démontrer 29 dépendance mutuelle 181 déplacement de code invariant 334 déréférenciation explicite 243 implicite 241, 243 dérivation 61, 62 élémentaire 60 en le vide 68 enchaînement 60, 123 la plus à droite 140, 141
la plus à gauche 126 derivation tree 62 désallocation explicite 268, 269 descente récursive 3, 6, 124, 128, 137, 141, 194, 310, 326 sur les priorités d’opérateurs 337 DescrActivation 313 description des opérations 165 des types 164 du source compilé 37 grammaticale 103 DescrParam 196, 255, 322 DescrParamParEsseux 196, 323, 362 DescrParamParNom 323, 362 DescrParamParValeur 323, 362 DescrSemantique 188 DescrSemFormula 188, 190, 199, 248, 251, 319 désempiler 227 DeterminerLesAdresses 313, 315, 386 déterminisme 88, 92, 140 deterministic finite automaton 88 développement de boucles 335 DFA 88 diagramme syntaxique 59, 159 DiaLog 7, 18, 190 dichotomie 97 dictionnaire 180, 199 des identificateurs prédéfinis 195 DictionnaireArbre 363 différence des niveaux statiques 275 Different 370 Directed Acyclic Graph 174 disponibilité 339 dispose 268 diviser pour régner 28 durée de vie 264, 338, 339 DyadiqueComparaisonNombres 369 dynamic link 270 dynamique 30
E
E 108 e 108 EBNF 59 ECHO 118 écrémage des variables 337 EcrireBinaire 300 édition de liens 112 édition des liens 52, 265 effet de bords 169 efficacité 11, 126, 242, 290
Egale 370 élimination d’indice de boucle 334 émiettement de la mémoire 269 EmpilerValeur 288 EmploiIndiceIter 369 EmploiParamParEsseux 387 EmploiParamParNom 322 EmploiParamParValeur 321, 368 enchaînement 244 enchaînement de ré-écritures 60 engendrer le vide 68 ensemble des dictionnaires 180 entier maximum 95 environment 269 trimming 337 eof 42 epsilon 68, 79 épuisement des instructions 267 Equivalent 370 erreur sémantique 172 syntaxique 134, 152 ErreurLexicale 112, 113, 115, 354 ErreurSemantique 200 error 148, 227, 228, 230 recovery 134 espace 86 significatif 67, 107 et 53, 54 état d’analyse 143, 145, 147 initial 143, 223 état du processeur 297 EtatPilum 279 EvalArg 252, 255 évaluation 244 d’un thunk 291 directe des graphes sémantiques 194, 197, 205, 250 Formula 270 ne se terminant pas 245 paresseuse 242, 245, 247, 395 postfixée 251 Evaluer 251, 255 évaluer 29 Evaluer () 250, 251 EvaluerThunk 289, 381 Executer 282 exécuter 29 exécution 30 Expression 200 expression commune 187, 300 conditionnelle 130 constante 174 régulière 67, 89, 107, 109, 117 Extended Backus-Naur Form 59 extern 264
402 Compilateurs avec C++
F
FacteurIdent 365 factorisation 109, 188, 215, 299 de la récursion à gauche 70 des productions 59 du comportement 90 fContexteContenant 251, 272 fermeture transitive 77, 88, 142, 143, 144 feuille 62, 123 Fibonacci 8, 35 FichierDeCaracteres 89 fIdent 110 FIN 77, 92, 93, 97, 101, 115, 147, 152, 157, 214, 349, 353, 373 fin de ligne 104 du fichier 114 FIN_EXPR 137 FIRST 77, 127, 142 fListeParamsInconnus 201 flot 248 de caractères 91 flot du contrôle 335 fNombre 110 FOLLOW 77, 127, 142, 149 fonction anonyme sans paramètres 296 associative parfaite 96 booléenne 124, 125 d’itération 168, 236, 249, 329, 369 déclarée globalement à l'appeleur 275 déclarée localement à l'appeleur 274 implicite 296 locale 130 prédéfinie 3, 50, 176, 177, 179, 244 privée 218 stricte 40 utilisateur 3, 167, 177 FonctUtilisateur 195, 361 fond 121 format de page 21 forme 27, 121 interne des règles 44 objet 30, 36 source 30, 36 Formula 92, 104, 107, 111, 112, 116, 129, 130, 140, 162, 167, 181, 210, 213, 216, 250, 261, 342 Fortran 84, 86, 180 forward 181 fosé sémantique 295 fossé sémantique 12
free 268 front end 37 frontale 37 fusion de boucles 335
G
garbage collection 269 gContexteEvalVide 251 gDescrSemFormulaInconnue 200, 366, 367, 369 générateur d'analyseurs lexicaux 103 d’analyseurs syntaxiques 209 de compilateurs 31 pseudo-aléatoire 266 GenreAccesCellule 316 GenreInstrOuEtiqu 311 gestion du flot du contrôle 335 gestion de la mémoire 269, 397 gestion des étiquettes 332 getc 85 Gödel 42 goto 145, 146, 147, 148 grammaire 58, 103, 209 ambiguë 148, 220 d'attributs 55 d’opérateurs 72 de précédence 78 décorée 55, 209, 216 du type 2 82, 140 du type 3 77, 82, 103 dynamique 234 générative 66 indépendante du contexte 68, 82, 140 LR(1) 194 régulière 66, 82, 88, 103 sémantique 66 grammaires équivalentes 69, 71, 76 grammar compiler 55 graphe 29, 205 acyclique orienté 174, 186 sémantique 3, 158, 181, 186, 187, 213, 219, 329 et notation postfixée 194 évaluation 250 gTypeInconnu 207 gTypeLogNonPrecise 195
H
Hasard 369 hasard 266 haut niveau 12, 295 heap 397 heuristique 331 hiérarchie d’opérateurs 74 des grammaires 78
I
iAppel 284 IDENT 96 Ident 177, 179 IdentEstUnIterateur 235 IdentFormula 178, 179, 196 identificateur 79, 108 non déclaré 199 prédéfini 96, 180, 181, 183, 195 identification de type 171, 172 identification de type 171 identité de type 162 IdentNonDeclare 366 iDesempiler 293 iEmpilerThunk 291 iEvaluerThunk 291 if 124, 135, 307 ifstatement 135 iLireFlottant 288 imbrication des blocs 181, 272 des thunks 296 implantation d’un langage 1 des variables dans des registres 337 implémentation 1 Inconnu 170 indécidabilité 42 indice d’itération 177 itération 184 indirection double 314 induction variable elimination 334 inférence de type 4, 172, 175, 178, 369, 396 en logique 170 informations de liaison 270 inline 131, 335 inout 242 input 114 Inserer 312, 314, 386 InsererIdentsPredefinis 195 instanciation d’un modèle de texte 106, 212 de schéma de code 309 de schémas de code 48, 307, 308, 327 InstrAccesCellule 316 InstrIteration 234 InstrOuEtiqu 312 instruction 29 épuisement 267 prochaîne à exécuter 297 InstructionPilum 279
Index 403
interaction lexico-sémantique 82, 234 interprète 29, 32, 33, 39, 282, 337 de commandes 25 du microcode 29 méta-circulaire 53 interpréter 29, 282 intervalle de caractères 108 invariant 30 invariant code motion 334 iReserver 293, 327 iRetourDeFonction 285 iRetourDeProcedure 284 isdigit 350 iStocker 293 itérateur sur une liste 201, 325, 358 IterateurParams 325, 358, 370 Iteration 249 itération 71, 168, 177, 304, 339
K
Knuth 140
L
LALR(1) 141, 150, 151, 194, 209, 211, 220, 396 langage cible 103, 210, 211, 216 commun 32 compilé 29 d’assemblage 5, 30, 87, 297, 330 d’expressions 168 d’implantation 164 de commande 89 de description de pages 12, 32 de haut niveau 12, 295 de programmation 1, 31, 89 engendré 63, 209, 222, 224 informatique 1 informatique unique 11 interprété 29 multiple 119 objet 30, 49 pseudo-compilé 29 source 30 structuré en blocs 182, 264 last call optimization 301 lazy evaluation 247 lecture des caractères 192 interactive 347 left hand side 149 left value 149 leftmost derivation 126 Lex 3, 7, 55, 66, 82, 105, 209, 211, 217, 218, 234, 237, 353 lex.yy.c 103, 105, 210, 217, 218
lex.yy.cp 211 lexical analyzer 81 LexSupport 105, 115, 212, 218, 353 LexYaccSupport 212, 218 lhs 149 LibererTemporaire 313 librairie de support d’exécution 50 lien dynamique 270 statique 272, 285, 289, 290, 291, 363 LienStatique 313, 317, 385 Link 105, 212 Lisp 26, 28, 34, 36, 55, 82, 158, 168, 207, 250, 251, 270, 302 liste circulaire 201 LL 78 LL(1) 126, 128, 129, 154 LL(n) 126 lookahead 126, 141 loop jamming 335 unrolling 335 LR 78, 124, 128 LR(0) item 141 LR(1) 141, 154 item 150 LR(n) 140
M
M680x0 7, 297 machine à pile 39, 277 cible 4, 38 informatique 29 physique 30 virtuelle 4, 30, 33, 277 machine informatique 33 macro-expansion 242 main 115, 116, 218, 319, 377, 392 make 31, 105, 355 malloc 268 Markov 40 Markovski 2, 7, 27, 42, 55, 78, 129 marqueur syntaxique 81 masquage 181, 184, 185, 233, 265, 268, 273 mémoire 33, 91, 269, 397 gestion 269 travail en 338 message cascade 133 d’avertissement 87, 185 superflu 133, 134, 172, 175, 185 meta-circular interpreter 53
méthode des arrêteurs 135 MettreAJourLesInconnues 386 microcode 29, 34, 337 mise en correspondance 163 mode normal 230 spécial 227 verbeux 220, 221, 226, 228 modifier le code source 211 Modula-2 244 module importé 181 Moins 365 mot 58 clé 97, 119 réservé 96, 97, 119, 233
N
name 243 new 268, 345, 365 Newton 7, 83, 181, 205, 330 NFA 88 niveau de déclarations 177, 180 lexical 28, 81 statique différence 275 syntaxique 28, 81, 82 no operation 336 nœud 62 Nombre 170, 172 nombre d’or 9 nombre de repétitions 109 non strict 248 non terminal 48, 58, 62, 124, 231 nondeterministic finite automaton 88 normal 119, 120 notacia polska odwrócona 38 notation algébrique 59, 72, 74, 124, 225 algébrique usuelle 73 arabe 65 de Backus-Naur étendue 59 polonaise inverse 38 postfixée 12, 13, 14, 38, 39, 158, 194, 219, 251, 302, 308, 364 romaine 65 syntaxique des structures de données 35 notion non terminale 61, 62 terminale 62
O
object code 30 form 30 language 30
404 Compilateurs avec C++
op 75 opd_descr 190 opérande 72 élémentaire 187 opérateur 187 binaire 73 défini par le programmeur 137 dyadique 73 infixé 75 monadique 73 postfixé 24, 75 préfixé 75 principal 72, 194 unaire 73 opération sur un texte 111 vide sémantiquement 336 optimisation 242, 247, 253, 277, 300 des appels terminaux 158, 301, 337 du pasage par nom 244 interprocédurale 335 Optimiser 313, 327, 386 OR 9, 10 ordre d'emploi des productions 62 inverse 141, 182, 299 textuel 299 output 114 ouvrir l’accès aux champs 23 own 265
P
par défaut 109, 216, 231 par nom 257 paramètre d’accumulation 302, 303 facultatif 241 formel 177, 196, 241, 368 ParamFormel 362 parser 121 Pascal 6, 82, 86, 99, 181, 244, 271, 337, 396 Pascal-S 6, 135, 396 passage dans des registres 337 mode de 196 par besoin 8, 192, 242, 247, 285 par nom 8, 168, 242, 243, 244, 247, 285, 286, 290, 297 par référence 190, 241, 242, 243, 286 par valeur 8, 168, 177, 193, 199, 241, 242, 244, 247, 256, 268, 286, 300, 323, 363
d’une adresse 242 par valeur-résultat 242 paresseux 286, 323 passe de compilation 6, 37, 38, 45, 94, 136, 181, 190, 192, 194, 205, 327, 337, 342 pattern matching 163 PC 297 peephole 8, 313, 327, 328, 338, 340 perfect hash function 96 phrase du langage 57, 61, 121 du langage engendré 63 parenthésée imbriquée 68 pile 39, 137, 297, 395 d’exécution 270, 326 d’opérandes 38 de descriptions des appels 326 de dictionnaires 182 de machines informatiques 33 de valeurs 232 des blocs d’activation 270 Pilum 4, 33, 193, 307, 337 pipe 91 PL/1 11, 84 PlacerEtiquette 315, 385 PLM 337, 338, 396, 397 Plus 365 point de déclaration 181 pointeur en l’air 269 politesse d’un compilateur 171, 195 portée dynamique 264 statique 264 textuelle 264 locale 264 position d’analyse 141, 222 ensemble de 143 initiale 142 postfixation 192 PostFixer 248 PostScript 12, 13, 26, 32, 159 Pour 293 PowerPC 7, 303 pragma 87 précédence faible 124 simple 124 prédéclaration 181 prédéfini 192 pré-processeur C 242 priorité 43, 72, 73, 75, 212, 220, 337 relative 222, 224, 225 priorités d’opérateurs 124 processeur 29, 33 RISC 336
processor status word 297 producteur-consommateur 83 ProducteurDeCaracteres 90 production 58, 62, 124 Produit 293 program counter 297 programmation orientée objets 243, 397 par contraintes 397 sans variables 248 Prolog 6, 27, 34, 36, 55, 66, 75, 129, 166, 172, 302, 337, 397 promesse 248 pseudo-code 151 pseudo-terminal 92, 137, 147 pseudo-variable 271 PSW 297 push down list 39
Q
quadruplet 190 quantificateur 23, 191
R
racine d’une grammaire 58 racine du graphe sémantique 194 ramassage des miettes 269 rattrapage d’erreurs 134 psychologique 134, 136, 195, 201, 369 sémantique 185 syntaxique 227 read 43, 44, 166 RechercherLeNom 235 récupération de mémoire 269 RecupererLeType 363 RecupererLeTypeInfere 178 RecupererTypesParams 179, 358 récursion 65, 192, 267, 275, 301 à droite 64, 71, 155 à gauche 64, 70, 128, 155, 158, 224 suppression 69 double 8, 304 infinie 129, 246 optimisée en itération 304 simple 304 suppression de la 304 terminale 64, 339 reduce 147 reduce/reduce 148 réduction 61, 62, 135, 144, 151 de puissance 300, 334 élémentaire 61 enchaînement 61 enchaînement de 123 forcée 227 réduire 149, 150, 151, 222, 232 ré-écriture 40, 242 enchaînement 60
Index 405
ré-évaluation 244 référence 241 registre 297, 337 d'adresse 297 de donnée 297 de lien 303 destination 339 origine 339 préférence 339 règle grammaticale 58 regular expression 67 grammar 66 REJECT 118 Rename 105, 211 répétition libre 125 Reserver 294 ressource 1, 12 restriction du passage par nom 244 du sur-langage 29, 166 re-synchronisation 133, 134, 135 retour arrière 88, 92, 123, 129 de fonction 246, 257, 271, 303 return 267 address 271 value 271 ré-utilisation 177 revenir sur des caractères déjà lus 90 rightmost derivation 140 RISC 336 runtime 30 stack 270
S
s’adapter à ce qui a été écrit par le programmeur 195 same_type 207 saturation 77, 143 saut direct 309, 330 inconditionnel 303 sur un saut 309, 310, 315 SauvegarderChaine 107, 111, 112 scanner 81 schéma de code 48, 307, 308, 309, 333 de Horner 94, 95 Scheme 55, 395 semantic gap 12 sémantique 28, 30, 72, 82, 186 invariante 30, 192 statique 184 semi-décidabilité 76 sentinelle 89, 90 séparateur 81, 86 Seq 320, 389 Seq1 321, 386
séquence de caractères 57 de mots 57 de terminaux 61 séquencement 124, 129, 254, 320, 329, 386 shadok 14, 25 shift 147 shift/reduce 148 Si 40, 130, 171, 188, 200, 249, 254, 321, 330, 390 side effect 169 signification 28, 161 Simula 67 243, 397, 398 SLR 148 SLR(1) 141, 148, 152 Smalltalk 398 Smalltalk 80 34, 36, 73, 397 SML 2, 173, 396, 398 Somme 168, 233, 243, 293 source code 30 form 30 language 30 sous-expression commune, voir expression commune SP 297 spurious message 133 SQL 12 stack 270 pointer 297 start 216 condition 119 static 218, 264, 265, 266, 267 chain 277 link 272 statique 30, 52 stop 42, 43 stoppers 135 stratégie 244 complète 245 incomplète 244 stream 91, 248 strength reduction 334 strict 40, 248, 253, 255 structure d'un arbre de dérivation 64 de blocs 181, 265, 267 de données 29, 35 infinie 247, 395 de l’arbre de dérivation 62 des phrases 59 suppression d’affectations superflues 334 Supprimer 312, 315, 386 surcharge sémantique 46, 73, 175, 195, 200, 201, 316 sur-langage 29, 43, 166, 167, 185, 328
restriction 29 survivre à un appel 341 appel survivre à 337 switch 110, 124 symbol table 180 symbole non terminal 58 terminal 27, 58 syntaxe 27 abstraite 186 SynthePilumFormula 317, 318 synthèse 36, 63 automatique de compilateurs 55 postfixée 319 Synthetiser 319, 322 SynthetiserCorpsDeThunk 317, 319, 324 SynthetiserDefinition 317, 319, 388 SynthetiserEvaluation 317, 319, 391 SynthetiseurPilum 312, 385
T
table associative 180 d'analyse 140 des identificateurs 165, 180 des symboles 4, 180, 182 tableau de graphes sémantiques 201 taille des informations manipulées 297 tampon 89, 90, 91, 114, 347 temporaire 270, 293, 296 terminaison 40 terminal 27, 28, 36, 49, 57, 58, 59, 62, 63, 81, 92, 96, 110, 124, 131, 158, 212, 215, 231, 232 ensemble de 77 pseudo 92 terminal symbol 27 TerminalSousFormeTextuelle 113 test de type 171 TesterTypeAttendu 364 texte source 209 Thompson 88 Thunk 313, 386 thunk 285, 286, 290, 291, 292, 293, 296, 322, 323, 324 token 27 top-down 62, 123 traduction automatique 30 traitement de listes 207, 248, 261 transition 88, 145, 146 transitive closure 77 transitivité 60
406 Compilateurs avec C++
trap 280 traversée 62, 180, 182, 190, 219, 247 trilogie 124, 221 triplet 190 tronçon de caractères contigus 99 typage dynamique 250 statique 250 Type 175 type caché interne au compilateur 174, 207 identité de 162, 207 inféré 178 libre 172 prédéfini 174 récursif 205, 207 simple 174 structuré 174, 205 test de 171 test dynamique de 250 TypeLogLIBRE 172 TypeValeurPilum 278
U
ungetc 86 unification 172, 337, 339 UnifierAutreVariable 200 UnifierValeur 315, 358 union 214, 231, 250 unité de contrôle 29 syntaxique 27 unput 114
V
vague des appels 246, 257 des retours 246, 257 valeur 74 de l’entier accepté 96 de liaison 173 initiale 266 jetée 254, 320, 386, 390 retournée 271, 285, 303, 304 valeur_de 187 ValeurFormula 316 ValeurInconnue 366, 367 ValeurLogique 368 ValeurNombre 365 ValeurPilum 278 var 242 variable 263 accès à la valeur 187 anonyme 338 automatique 267, 268 devant survivre à un appel 341 écrémage de 337, 341, 342
globale 235, 270, 297 prédéfinie 23 locale 265, 268 logique 172, 173, 188, 199, 201, 329, 338, 369 libre 172 liée 172 prise d’adresse de 337 programmation sans 248 pseudo 271 statique 266 temporaire 270 variante 176, 214 VarLogType 173, 178, 199, 356, 361, 362 véhiculer une sémantique 28 veleur retournée 303 Vide 167, 170, 172 vide 68, 168, 224 visibilité temporaire 181 void 167
W
Warren 397 while 308 with 23, 181, 182, 183, 270
Y
y.output 220, 221, 223, 226, 228 y.tab.c 210, 211, 217 y.tab.cp 217 y.tab.h 210, 214 Yacc 3, 8, 55, 66, 104, 110, 111, 140, 141, 151, 155, 194, 209, 211, 372, 373 yy.lex.c 105, 112 YYACCEPT 230 yyclearin 230 yyerrok 230 YYERROR 230 yyerror 218, 227 yyinput 120 yyleng 109 yyless 114 yylex 103, 106, 109, 110, 111, 114, 116, 210, 211, 214, 218, 230 yylval 110, 111, 214, 232 yymore 114 yyparse 210, 212, 230 YYSTYPE 232 yytext 109, 111, 112, 114 yyval 232, 233 yyvp 233 yywrap 114, 115, 116
Z
zone de communs 180