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
2172 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)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 chaine_de_travail; cout ", "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 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 TerminalSousFormeTextuelle (fTerminal) 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
. 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 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 l’identificateur de l’un des champs de l’union : %type %type %type
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
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 >> 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) > 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) PostFixer (); fValeurSiVrai -> PostFixer (); fValeurSiFaux -> PostFixer (); cout PostFixer (); fBorneSup -> PostFixer ();
250 Compilateurs avec C++
cout Nom ()); fExpression -> PostFixer (); cout 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 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 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) 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
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, '= 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 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 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 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 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 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 Nom (), nouveauContexte -> NumeroContexte () ); for (short j = 0; j < nombreDeParametres; ++ j) { Indenter (leContexte -> Indentation ()); cout Evaluer (nouveauContexte); delete nouveauContexte; delete blocDEvaluations; } else // CAS SANS PARAMETRES { Indenter (leContexte -> Indentation ()); cout Nom (), leContexte -> NumeroContexte () ); res = fFonctUtilisateur -> Corps () -> Evaluer (leContexte); } // if Indenter (leContexte -> Indentation ()); cout 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