Mini-projet

publicité
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-
Téléchargement