IFT313 Introduction aux langages formels Froduald Kabanza Département d’informatique Université de Sherbrooke Analyseurs récursifs LL (1)
Sujets C’est quoi un analyseur syntaxique récursif ? Comment le programmer ? Comment fonctionne un générateur d’analyseur syntaxique récursif ? IFT313 © Froduald Kabanza
Objectifs Pouvoir programmer un analyseur syntaxique récursif pour une grammaire donnée. Connaître les fondements d’un générateur d’analyseur syntaxique LL tel que JavaCC. IFT313 © Froduald Kabanza
Références [2] Appel, A. and Palsberg. J. Modern Compiler Implementation in Java. Second Edition. Cambridge, 2004. Section 3.2 [4] Aho, A., Lam, M., Sethi R., Ullman J. Compilers: Principles, Techniques, and Tools, 2nd Edition. Addison Wesley, 2007. Section 4.4.1 IFT313 © Froduald Kabanza
Rappel : Analyseur LL(1) non récursif Un analyseur syntaxique LL non récursif exécute une boucle dans laquelle, à chaque étape, soit il prédit la production à appliquer ou il reconnaît (match) le prochain lexème (token). Pour cette raison, on l’appelle souvent en anglais « predictive parser » ou « predict-match parser ». Un générateur d’analyseur syntaxique non récursif : Prend une grammaire comme entrée. Produit, à partir de la grammaire, une table d’analyse qui prédit la production à appliquer en fonction du non terminal au sommet de la pile et du prochain lexème (token). Le générateur a accès à du code pour un driver LL(1) (qui est essentiellement un automate à pile LL(1)) L’analyseur pour la grammaire d’entrée est obtenu en combinant le driver et la table d’analyse. IFT313 © Froduald Kabanza
Rappel : Exemple Table d’analyse G = (V, A, R, E) : V = {E, E’, T, T’, F} A = {(, ), +, *, n} R = { E TE’ E’ + TE’ | ε T FT’ T’ *FT’ | ε F ( E ) | n } n + * E E TE’ $ E’ E’+TE’ E’ ε T T FT’ TFT’ T’ T’ ε T’*FT’ F F n F(E) ( ) IFT313 © Froduald Kabanza
n + * E E TE’ $ E’ E’+TE’ E’ ε T T FT’ TFT’ T’ T’ ε T’*FT’ F F n F(E) ( ) Entrée : n+n*n Étape Règle Pile Entrée Algorithm LLDriver 0. stack = ($S); a = in.read(); x=stack.top(); while (true) { 1. if (x = = $) && (a= = $) return true ; 2. if (x = = a) && (a != $) { pop a from stack; a = in.read(); continue;} 3. if x is a nonterminal { if M[x,a] is error exit with error; let x y in M[x,a] pop x from stack; push y on stack; continue; } 4. exit with error;} 0. 3. 2. 1. E TE’ T FT’ F n T’ ε E’ +TE’ T’ *FT’ T’ ε E’ ε $E $E’T $E’T’F $E’T’n $E’T’ $E’ $E’ T+ $E’ T $E’T’F* $ n+n*n$ +n*n$ n*n$ *n$ n$ $ return true IFT313 © Froduald Kabanza
Analyse LL(1) descendante récursive On peut aussi définir un analyseur LL(1) directement à partir des règles de productions et de la table d’analyse, sans utiliser le driver LL1. L’idée est de simuler directement la dérivation la plus à gauche : En associant des fonctions d’analyse aux différents symboles de la grammaire (terminaux et non terminaux). En faisant les appels de fonctions selon la structure de la grammaire. Aux terminaux on associe une fonction match(Token) qui va matcher le prochain token. A chaque non terminal X, on associe une fonction X() dont le corps appelle des fonctions correspondant aux parties droites des règles dont X est la partie gauche. IFT313 © Froduald Kabanza
Exemple Exemple de mot généré : if (id = = id) { print(id); print(id) G = (V, A, R, S) : V = {S, T, L, E} A ={ if, else, {, }, ;, =, ), (, id, print } R = { S if T S else S S { S L | print(E) T (id = = id) L } | ;S L E id Exemple de mot généré : if (id = = id) { print(id); print(id) } else IFT313 © Froduald Kabanza
Analyseur LL(1) récursif Token a; // Variable globale : contiendra le prochain token void match (GrammarSymbol x) { if (x.equals(a.text()) a = getNextToken(); else error();} void S() { switch (a) case if : match(if); T(); S(); match(else); S(); break; case ‘{’ : match(‘{’); S(); L(); break; case print : match(print); match(‘(’); E(); match(‘)’); break; default: error();} void T() case ‘(’ : match(‘(’); match(id); match(=); match(=); match(id); match(‘)’); break; default : error();} G = (V, A, R, E) : V = {S, T, L, E} A ={ if, else, {, }, ;, =, ), (, id, print } R = { S if T S else S S { S L | print(E) T (id = = id) L } | ;S L E id Note : En pratique ‘;’ sera représenté par un symbole (ex. SEMI). Idem pour {, }, (, ). Ce n’est pas fait ici pour une question de clarté. IFT313 © Froduald Kabanza
Analyseur LL(1) récursif (suite) G = (V, A, R, S) : V = {S, T, L, E} A ={ if, else, {, }, ;, =, ), (, id, print } R = { S if T S else S S { S L | print(E) T (id = = id) L } | ;S L E id void L() { switch (a) case ‘}’ : match(‘}’); break; case ‘;’ : match(‘;’); S(); L(); break; default : error();} void E() case id : match(id); break; default : error();} void main () // Point d’entrée du parseur { a = getNextToken(); S(); // fonction d’analyse pour le symbole de départ System.out.print(“Accepte : entrée correcte”); } IFT313 © Froduald Kabanza
Exercices Pour vous convaincre que ça marche, simulez l’analyseur sur les entrées suivantes : Entrée correcte syntaxiquement : if (id = = id) { print(id); print(id) } else Entrée incorrecte syntaxiquement : if else (id = = id) Modifiez le parseur pour qu’il imprime la dérivation de l’entrée. Implémentez-le en Java. IFT313 © Froduald Kabanza
Observations Il est facile d’écrire un analyseur syntaxique récursif manuellement. Pour que l’approche précédente fonctionne il faut que : La partie droite de chaque production commence par un terminal Parce que le switch de chaque fonction X() se fait sur les terminaux qui commencent les partie droite des production dont X est la partie gauche. Deux productions ayant la même partie gauche doivent avoir des parties droites commençant par des préfixes différents. Parce que les deux règles ont la même fonction d’analyse (c-à-d., la fonction correspondant au non terminal dans la partie gauche de chaque production). Si elle partagent le même préfixe, le switch ne pourra pas tenir compte des deux à la fois. IFT313 © Froduald Kabanza
Observations Il est facile d’écrire un analyseur syntaxique récursif manuellement. Pour que l’approche précédente fonctionne il faut que : La partie droite de chaque production commence par un terminal. Deux productions ayant la même partie gauche doivent avoir des parties droites commençant par des préfixes différents. Ces conditions nous garantissent que la fonction d’analyse pour chaque non terminal est déterministe. En d’autre mots, on peut prédire la production appropriée, simplement en lisant le prochain token. IFT313 © Froduald Kabanza
Observations Il est très facile d’écrire un analyseur syntaxique récursif manuellement. Pour que l’approche précédente fonctionne il faut que : La partie droite de chaque production commence par un terminal Deux productions ayant la même partie gauche doivent avoir des parties droites commençant par des préfixes différents. Ces conditions nous garantissent que la fonction d’analyse pour chaque non terminal est déterministe. Nous avons vu que seulement la première condition n’est pas nécessairement requise pour un parseur LL(1) non récursif. Comment généraliser l’approche récursive pour que la condition 1 ne soit pas nécessaire ? IFT313 © Froduald Kabanza
Exemple G = (V, A, R, E) : V = {E, E’, T, T’, F} A = {(, ), +, *, n} R = { E TE’ E’ + TE’ | ε T FT’ T’ *FT’ | ε F ( E ) | n } Avec l’approche précédente on s’attendrait à quelque chose du genre : void E() { switch (a) case ?? : T(); Eprime(); break; default : error()} Mais qu’est-ce qu’on met aux endroits indiqués par « ?? » ? Vu que la production E TE’ ne commence pas par un terminal, notre approche ne fonctionne plus. Pour résoudre ce problème, il faut utiliser la table d’analyse LL(1) de la grammaire, pour implémenter les cas de l’instruction switch. De cette façon, on obtient un parser LL(1) récursif, équivalent au parser LL(1) non récursif. Cette grammaire illustre les limites de l’approche précédente. Par exemple, quelle est la fonction d’analyse pour le non terminal E ? IFT313 © Froduald Kabanza
Analyse syntaxique LL(1) récursif En général, pour avoir un analyseur syntaxique récursif, il faut utiliser une table d’analyse LL(1) afin d’implémenter les cas du switch: Pour une fonction d’analyse X() donnée, les cas de l’instruction switch correspondent aux tokens a, tels que les entrées [X,a] sont non vides dans la table d’analyse. La séquence d’appels pour chaque chaque cas est une séquence de match et de fonction d’analyse correspondants à la partie droite de la production dans l’entrée [X,a] de la table d’analyse. IFT313 © Froduald Kabanza
Exemple case EOF : break; Table d’analyse G = (V, A, R, E) : V = {E, E’, T, T’, F} A = {(, ), +, *, n} R = { E TE’ E’ + TE’ | ε T FT’ T’ *FT’ | ε F ( E ) | n } n + * E E TE’ $ E’ E’+TE’ E’ ε T T FT’ TFT’ T’ T’ ε T’*FT’ F F n F(E) ( ) void E() { switch (a) case n : T(); Eprime(); break; case ( : T(); Eprime(); default : error()} void E’() { switch (a) case + : match(+); T();E’(); break; case ) : break; case EOF : break; default : error()} IFT313 © Froduald Kabanza
Stratégies de recouvrement d’erreurs Une erreur apparaît lorsque la chaîne d’entrée n’est pas syntaxiquement correcte, c-à-d. elle n’est pas dérivable de la grammaire. En pratique, on ne veut pas arrêter l’analyse à la toute première erreur. On veut continuer l’analyse syntaxique jusqu’à un certain nombre d’erreurs préfixé ou jusqu’à un certain niveau de sévérité de l’erreur. Les stratégies de recouvrement typiques consistent à réparer la chaîne d’entrée pour que l’analyse continue. En particulier : On peut insérer des tokens. Supprimer des tokens. Remplacer des tokens. IFT313 © Froduald Kabanza
Recouvrement d’erreurs par insertion de tokens Pour insérer un token manquant de l’input, on n’a pas besoin de l’ajouter explicitement à la chaîne d’entrée. Il suffit de prétendre que le token est présent, imprimer un message approprié et retourner normalement tel qu’illustré par les exemples suivants pour E() et Eprime(). void E’() { switch (a) case + : match(+); T();E’(); break; case ) : break; case EOF : break; default : print(“Expected +, ), or EOF.”); } void E() { switch (a) case n : T(); Eprime(); break; case ( : T(); Eprime(); default : print(“Expected num or )”;} IFT313 © Froduald Kabanza
Recouvrement d’erreurs par insertion de tokens Le recouvrement d’erreurs par insertion de tokens est à utiliser avec précaution parce que une cascade d’erreurs risque de mener à une à une boucle sans fin : tokens sont insérés (ou supposés présents) sans cesse, de sorte que la chaine d’entrée n’est jamais vidée. void E’() { switch (a) case + : match(+); T();E’(); break; case ) : break; case EOF : break; default : print(“Expected +, ), or EOF.”); } void E() { switch (a) case n : T(); Eprime(); break; case ( : T(); Eprime(); default : print(“Expected num or )”;} IFT313 © Froduald Kabanza
Recouvrement d’erreurs par suppression de tokens Le recouvrement d’erreurs par suppression de tokens est plus sécuritaire parce qu’il garantie toujours que la chaîne d’entrée va être vidée. Pour une fonction d’analyse X(), la stratégie est, en cas d’erreur, de sauter (supprimer) les prochains tokens jusqu’au premier token qui est dans Follow(X). Follow[Eprime] = { ), $ } void E’() { switch (a) case + : match(+); T();E’(); break; case ) : break; case EOF : break; default : print(“Expected +, ), or EOF.”); skipTo(Follow[E’]);} skipTo(A) supprime les prochains tokens jusqu’au premier dans A. IFT313 © Froduald Kabanza
Générateurs d’analyseurs LL(1) récursifs Un générateur d’analyseur LL(1) récursif reçoit comme entrée une grammaire et donne comme sortie un analyseur LL(1) récursif correspondant. Pour ce faire : Il génère une table d’analyse LL(1) Génère un patron (template) des fonctions d’analyse à partir des règles de production, utilisant la table d’analyse pour implémenter le switch. Ajoute le code pour la méthode (fonction) match. Il n’y a plus de pile explicite. Elle est implicitement implémentée par la pile d’appels des fonctions (la pile de récursivité). JavaCC et ANTLR sont des exemple de générateurs d’analyseurs LL récursifs. IFT313 © Froduald Kabanza