Université du Littoral – Côte d'Opale L3 Informatique Aspects Théoriques de l'Informatique TP / Mini-Projet : un calculateur en ligne Introduction Un analyseur lexical analyse un flux de caractères (typiquement un fichier source que vous voulez compiler) et isole dans ce fichier les "tokens" c'est à dire les éléments "de vocabulaire" du langage dans lequel est écrit votre code source. Par exemple parmi les tokens d'un source Java on va trouver des identifiants comme des noms de variables ou de méthodes (a, compteur, val_max, c3, CréerTableau, System…), des nombres (123, -45, 3.141592), des opérateurs (+, -, *, /, <, <=, …) et autres éléments lexicaux (parenthèses, accolades, point-virgule, =, …). Comme vu en cours ces classes de tokens sont définies généralement par des langages réguliers spécifiés par des expressions rationnelles (ex : un identifiant appartient au langage décrit par : une lettre suivie de autant de lettres, chiffres ou blanc soulignés qu'on veut). Au niveau supérieur, la succession de ces token est contrainte par une grammaire, généralement algébrique, qui vérifie que le programme est syntaxiquement correct (par exemple : à toute accolade fermante correspond une unique accolade ouverte précédemment). Dans le cadre de ce mini-projet, on va programmer un calculateur, qui va chercher ses instructions de calcul dans un fichier. Chaque calcul s'écrit comme une unique ligne de "commandes", mais le fichier à traiter peut contenir plusieurs lignes (plusieurs calculs), et on veut disposer de variables où affecter des résultats temporaires, réutilisables dans les calculs suivants du fichier. Pour réaliser ce calculateur il faut donc être capable d'analyser le contenu d'un fichier : notre calculateur se comporte donc comme un interpréteur capable de reconnaître et de comprendre des instructions, de manière similaire à ce que fait un compilateur (mais un compilateur doit en plus sortir une traduction dans un autre langage). L'analyse du fichier se fait typiquement en 2 grandes étapes : l'analyse lexicale, basée sur des grammaires régulières, pour reconnaître le vocabulaire (les tokens) ; et l'analyse syntaxique, basée sur une grammaire algébrique, pour vérifier l'organisation du vocabulaire et en comprendre le sens. Note : pour faciliter le travail, on considérera que tout nombre est converti en flottant même s'il est écrit sans point décimal, et que l'affectation à une variable est notée de manière préfixée, avec le symbole d'affectation suivi de la variable puis de la donnée, par exemple = a 28 (et non pas a = 28). Étape 1 : écrire un analyseur lexical "à la main" Dans cette première étape on se limite à explorer le travail que réalise un analyseur lexical sur un exemple simple. Soit le langage L sur l'alphabet {a, b, c} spécifié par l'expression rationnelle : (a|b)*acab(c|a)+ où * désigne l'étoile des rationnels, + est l'étoile propre et | est l'opérateur d'union. 1. Trouver un automate fini déterministe reconnaissant L. Faites un automate complet, avec un état puits (non terminal) : si on ne peut pas lire une lettre dans l'état courant, alors on va dans l'état puits ; et si on est dans l'état puits, alors toute lettre renvoie dans l'état puits. 2. Coder cet automate en Java, sans bien sûr utiliser de librairie toute faite. Vous organiserez votre code de la façon suivante : -1- Université du Littoral – Côte d'Opale L3 Informatique ◦ saisie de la phrase à reconnaître ; ◦ utilisation d'une variable mémorisant le numéro de l'état courant dans l'automate ◦ itération sur chaque lettre de la phrase ; ◦ à l'intérieur de l'itération on testera la lettre courante avec l'état courant pour déterminer quel est le nouvel état ; Noter qu'un automate déterministe peut se représenter comme un tableau 2D indéxé par [numéros d'état][ lettres] : changer d'état devient très simple à implanter ; ◦ Quand l'itération sur la phrase est terminée, alors il faut vérifier si l'état courant est terminal (sinon c'est une erreur et le mot n'est pas reconnu). ÉTAPE 2 : introduction à JFLEX Jflex est un outil Java libre permettant de construire automatiquement un analyseur lexical (NB : il existe d'autres outils de ce type). Jflex lit un fichier de description dans lequel on spécifie des familles de tokens (en fait des "langages" dans le vocabulaire de la théorie des langages) par des expressions rationnelles (exemple : les identifiants, les nombres entiers, les opérateurs arithmériques). Pour chaque tolen reconnu dans une famille on indique quelle action doit être effectuée, en général "retourner un code numérique pour le token reconnu". Les actions sont codées sous la forme d'instructions Java. Un préambule permet d'indiquer du code Java (classes, variables, méthodes) pour permettre de réaliser des actions aussi sophistiquées que nécessaire lorsqu'on reconnaît un token. Des variables et des fonctions prédéfinies permettent : • d'obtenir le token reconnu, méthode yytext() • ainsi que par exemple la ligne (variable yyline) et la colonne du fichier où il se trouve (utile pour les messages d'erreur d'un compilateur) • ... Lorsqu'on exécute Jflex sur le ficher de description, il construit à partir des expressions rationnelles un automate fini non déterministe (NFA – Non deterministic Finite Automaton) capable de reconnaître les mots de chacun des langages de tokens, puis il va déterminiser cet automate (DFA – Deterministic Finite Automaton) et le minimiser. En sortie Jflex écrit un programme Java, nommé par défaut Yylex.java, contenant l'automate, le code Java du préambule du fichier de description, et les méthodes d'accès aux mots reconnus, numéros de ligne, etc. Un fichier de description correspondant au langage de l'étape 1 est donné ci-dessous, et se compose de 3 sections : le préambule qui est recopié tel quel dans le code du "lexer", la partie expressions rationnelles (où l'on spécifie les familles de tokens) avec la définition de quelques options (type de retour pour le code token, etc), enfin la partie actions où l'on donne le code Java a exécuter pour chaque expression rationnelle reconnue. Exemple : -2- Université du Littoral – Côte d'Opale L3 Informatique // PREAMBULE recopié tel quel import java.io.*; class Token { public static void main(String[] args) throws FileNotFoundException, IOException { // recupérer le fichier passé en argument de ligne de commande FileInputStream fis = new FileInputStream(args[0]); // créer l'analyseur Yylex L = new Yylex(fis); // le lancer sur tout le fichier L.yylex(); } } %% /* notez les 2 % : ici on définit les EXPRESSIONS RATIONNELLES */ /* (dans cette partie un commentaire ne peut pas commencer en début de ligne) */ /* demander le suivi des numéros de ligne */ %line /* si les tokens sont retournés, ils le sont comme des chaines */ %type String /* les mots du langages à reconnaitre */ mot_correct = (a | b)*acab(c|a)+ /* autre langage : les "espaces", qu'on va ignorer */ /* (les crochets encadrent une énumération de choix possibles) */ whitespace = [ \n\t\r] -3- Université du Littoral – Côte d'Opale L3 Informatique %% /* notez les 2 % : ici dans la partie ACTIONS on dit quoi faire quand on reconnaît un mot des langages définis ci­dessus */ /* si on est dans l'état initial de l'automate...*/ <YYINITIAL> { /* ...et si on reconnaît un mot_correct alors */ /* afficher le mot reconnu et le numéro de ligne */ {mot_correct} { System.out.println("[line "+ yyline + "] " +"Mot reconnu: " + yytext() ); } /* si on reconnait un espace, on ne fait rien */ {whitespace} {} /* si on reconnait autre chose on affiche erreur et le mot */ [^] { System.out.println("[line "+ yyline + "] " +"Erreur: " + yytext() ); } } Sauver ce fichier comme test.lex, et créer l'analyseur en exécutant : jflex test.lex Un fichier Yylex.Java est créé, que l'on va compiler : javac Yylex.java On va exécuter notre classe du préambule : java Token source_etape1.txt avec source_etape1.txt un fichier contenant des mots à analyser comme par exemple : cabc caba aaabbbaaacabcac accabc cab Note : voyez que par exemple le +, le * et le . sont des composants des expressions rationnelles. Si on veut reconnaître des tokens contenant ces caractères, il y aurait une ambiguïté, que l'on lève en préfixant avec anti-slash : ainsi * est l'itération rationnelle et \* est le caractère '*'. Examinez les sorties de l'analyseur, et testez quelques modifications (changement du langage reconnu, des instructions de sorties). -4- Université du Littoral – Côte d'Opale L3 Informatique ÉTAPE 3 : analyse lexicale pour un calculateur en ligne de commande Pour notre calculateur, il faudra reconnaître les tokens : • token "nombre" : des constantes numériques sans signe, comme (13467, 12, 3.1415). S'il s'agit de réels alors il y aura forcément au moins un chiffre à gauche et un chiffre à droite du point. • token "ident" : des noms de variables définis comme le sont habituellement les identifiants des langages de programmation. • autres tokens : ◦ parenthèse ouvrante : token "po" ◦ parenthèse fermante : token "pf" ◦ le symbole d'affectation = : token "affect" (l'affectation sera préfixée : le symbole = est placé devant le nom de variable suivi de la donnée à affecter) ◦ les 4 opérations +, -, *, / : tokens "oplus", "ominus", "omult", "odiv". Notez que le "+" et le "-" peuvent être compris comme des opérateurs binaires, comme dans 12 – 3, ou bien unaires comme dans a = -3 (ce sera lors de l'analyse syntaxique que l'on décidera laquelle des significations est la bonne) ◦ la fin de ligne : token "eol" qui termine le calcul contenu sur cette ligne. Utiliser JFLEX pour reconnaître ces tokens. L'analyseur devra afficher pour chaque ligne d'un fichier source les tokens qu'il contient. Par exemple pour le source ci-dessous à gauche, on affichera les lignes ci-dessous à droite : +123.0 oplus nombre eol ((-4 *12) / 2.0) po po omoins nombre omult nombre pf odiv nombre pf eol =a6 affect ident nombre eol (a+1) po ident oplus nombre pf eol = total (4*a) affect ident po nombre omult ident pf eol ÉTAPE 4 : préparation à l'analyse syntaxique Reconnaître les tokens n'est pas suffisant pour notre calculateur, car on ne peut pas par exemple vérifier si une expression est bien parenthésée : ça ne peut pas être fait par une grammaire régulière, il faut une grammaire algébrique. Il existe des outils basés sur la théorie de la compilation qui prennent une grammaire et génèrent automatiquement un analyseur lexical (par exemple CUP), mais ici nous allons nous contenter de coder directement la grammaire pour vérifier ces contraintes. Il faut donc ne pas se contenter d'afficher les tokens, mais pouvoir les récupérer et les analyser. On va modifier la fonction main() du préambule et le code des actions dans la dernière partie du fichier de description .lex : • L'appel à yylex() du main() se fait désormais dans une boucle : chaque appel va récupérer -5- Université du Littoral – Côte d'Opale L3 Informatique une valeur retournée par l'action et pour cela dans les actions on va remplacer les system.out.print(...) par des return ... • Le type de valeur retourné par l'action doit être défini : nous utiliserons la classe Token du préambule. Cette classe n' a pour l'instant pas de champ de données, donc on ajoute les champs : ◦ champ TypeToken tok : le type du token, avec TypeToken défini comme une énumération des symboles NOMBRE, IDENT, AFFECT, PO, PF, OPLUS, OMINUS, OMULT, ODIV, ERREUR, EOL, EOF ◦ champ String repr : chaine représentant le token (récupéré par yytext() dans les actions) ◦ champ float val : valeur du token si le token est un nombre, sinon 0.0 par défaut ◦ on ajoutera aussi un constructeur pour la classe Token initialisant ces champs par les valeurs reçues en paramètre ◦ exemple pratique : le system.out.print("oplus") de l'action traitant le "+" sera remplacé par : return new Token(Token.TypeToken.OPLUS, yytext(), 0.0); • Il faut pouvoir terminer la boucle de lecture des tokens du main() quand on arrive à la fin de fichier. Jflex permet de déclarer dans la section des expressions rationnelles une règle gérant la fin de fichier : %eofval{ return new Token(Token.TypeToken.EOF, "", 0.0); %eofval} Faites les modifications indiquées ci-dessus et reprenez l'exemple de fichier source de l'étape 3 en faisant afficher dans le main() la valeur associée aux tokens NOMBRE et pour les autres tokens la chaîne les représentant. Vous obtiendrez : +123.0 ((-4*12)/2.0) =a6 (a+1) =total(4*a) ÉTAPE 5 : analyse syntaxique Nous allons donc décrire la grammaire algébrique du langage reconnu par notre calculateur, à partir des tokens qui seront considérés comme des terminaux (apparaissant ici tout en majuscules, les variables de la grammaire commençant par une majuscule et finissant en minuscules). L'axiome de la grammaire est Calcul. Calcul → AFFECT IDENT Expression EOL | Expression EOL -6- Université du Littoral – Côte d'Opale L3 Informatique Expression → LitteralNombre | IDENT | PO Expression Operateur Expression PF LitteralNombre → NOMBRE | OPLUS NOMBRE | OMINUS NOMBRE Operateur → OPLUS | OMINUS | OMULT | ODIV Sans entrer trop en détail dans la théorie de la compilation, on peut noter que cette grammaire a une particularité : quand une variable peut se dériver de plusieurs façons, alors le premier token permet toujours de décider quelle est la règle de dérivation à choisir. De plus si une des règle est récursive (c'est le cas pour le 3ème choix de dérivation de Expression), alors la récursion n'apparaît pas comme le premier symbole à gauche. Ces deux propriétés permettent de coder facilement la grammaire, et d'éviter les récursions infinies : • On associe une fonction Java à chaque variable, qui va lire et vérifier le premier token pour choisir quelle règle de dérivation est la bonne ; • Quand la dérivation est choisie, la fonction lit de gauche à droite les symboles correspondant à la partie droite de la dérivation : chaque token doit être présent dans le fichier, et chaque variable correspond à un appel à la fonction Java associée à cette variable. L'analyse se fait donc de gauche à droite (Left to right), en se décidant sur 1 seul token (le premier), et dérive les variables dans l'ordre de lecture, commençant par la variable la plus à gauche (Leftmost) : c'est une grammaire dite LL(1), qu'il est facile de programmer directement. Exemple pratique : la fonction Calcul(...) peut tester si le token courant est AFFECT, si oui on doit lire un token IDENT suivi d'un appel à la fonction Expression(...), sinon on appelle la fonction Expression(...) directement. Si par exemple la fonction Operateur(...) trouve autre chose qu'un OPLUS, OMINUS, OMULT ou ODIV, alors c'est une erreur syntaxique. Note : vous aurez probablement besoin de passer des paramètres à ces fonctions (par exemple le "scanner" de type Yylex pour lire les tokens). • Coder cette grammaire sous forme de fonctions dans la classe Token. La boucle du main() fait un appel à Calcul(...) tant que l'on n'est pas en fin de fichier ; • Vérifier qu'on est capable de lire et d'afficher la même chose qu'à l'étape 4. Modifier l'affichage pour que les affectations apparaissent de la manière habituelle, dite infixée : "IDENT = valeur" plutôt que "= IDENT valeur" ; • Ajouter une fonction Erreur(...) qui signale quand on détecte une erreur de syntaxe (ou un token non reconnu), et affiche le token erroné. ÉTAPE 6 : analyse sémantique Jusqu'ici on n'a fait que contrôler les tokens et leur organisation (ce qui est déjà bien). Il nous reste à donner du sens aux expressions rencontrées. • La fonction Calcul(...) retourne le résultat du calcul ; dans le main() on affichera ce résultat sur la ligne suivant le calcul (voir exemple plus bas) ; • Lorsqu'une affectation est rencontrée, on se contente pour l'instant de retourner la valeur affectée comme résultat du calcul ; • Lorsqu'il s'agit d'une expression, on en retournera la valeur. On utilisera le fonction associée -7- Université du Littoral – Côte d'Opale L3 Informatique à la variable Expression : ◦ un nombre retourne la valeur du nombre ; ◦ un identifiant retourne toujours pour l'instant 0.0 ; ◦ une opération (+, -, *, /) récupère les valeurs de ses opérandes gauche et droite et leur applique l'opération souhaitée, puis retourne le résultat. ◦ Exemple : (les résultats des calculs sont affichés en gras – rappel : les variables valent 0) 123.0 123.0 ((-4.0*12.0)/2.0) -24.0 a = 6.0 6.0 (a+1.0) 1.0 total = (4.0*a) 0.0 ÉTAPE subsidiaire : On pourra compléter cette étude en implantant les améliorations suivantes : • Lorsqu'une affectation est rencontrée, il faut stocker le nom de l'identifiant de la variable et la valeur affectée à la variable ; • Lorsqu'un identifiant est rencontré dans une expression, on retourne la valeur stockée pour cet identifiant ou on déclenche une erreur si aucune affectation n'a été réalisée pour cet identifiant (la variable n'est pas connue). Cette erreur ne peut pas être détectée par notre grammaire, ce n'est pas une erreur syntaxique mais sémantique ; • La fonction Erreur doit détailler : ◦ le type d'erreur : lexicale, syntaxique, ou sémantique ; ◦ le numéro de ligne et de colonne où a lieu l'erreur ; ◦ le token qui était attendu en cas d'erreur syntaxique, ou le nom de la variable erronée dans le cas d'une erreur sémantique. -8-