Algorithmes d'analyse syntaxique
Un algorithme d'analyse syntaxique est un algorithme permettant de reconnaître l'existence d'un mot selon un langage, en cas général il est appliqué pour les langages informatiques et valide ou non un code source pour la syntaxe d'un langage informatique dans tous les compilateurs et interpréteurs. Mais la plupart des analyseurs syntaxiques ne se contentent pas de seulement valider l'entrée, ils extraient aussi la construction de cette entrée sous la forme d'un arbre abstrait de syntaxe détenant le sens de la chaîne d'entrée.
Les grammaires
Les symboles de grammaire
Toutes les grammaires sont composées de symboles regroupés sous deux catégories, les terminaux et le non-terminaux.
Les terminaux sont les symboles les plus petits ne pouvant être subdivisé, ils forment la chaîne d'entrée. Dans l'exemple d'une expression mathématique simple ces symboles sont tous les chiffres, tous les opérateurs et ponctuations.
Les non-terminaux sont des symboles pouvant être subdivisé, il servent à regrouper d'autre symboles. Il sont toujours associés à des productions décrivant les subdivisions possible. Par exemple une grammaire pourrait définir le non-terminal expr qui englobe toutes le expressions mathématique simple soit toutes les compositions valide de chiffres, opérateurs et ponctuations.
TODO exemple production 5 + 2 - 3
Les productions de grammaires
Les productions décrivent les subdivisions de non-terminaux, dans le cas d'une grammaire non-contextuelle toutes les productions sont de la forme :
: un non-terminal. : une composition de terminaux et non-terminaux.
Un non-terminal peut être dérivé par une des ses productions associée, il est alors réécrit par la partie droite d'une production. Cette opération mène à une proto-phrase, cette proto-phrase est une phrase si elle est constituée seulement de terminaux. De même il est possible de réduire une proto-phrase en un non-terminal par une des productions ayant en partie droite la proto-phrase.
La hiérachie de Chomsky
La hiérachie de Chomsky définit quatre classes de grammaires, chacune pouvant décrire toutes les règles des grammaires de hiérarchie plus faible, les grammaires générales couvrent toutes les grammaires possibles.
Le niveau le plus faible est attribué aux grammaires régulières.
- Les grammaires régulières sont d'une forme linéaire gauche ou droite, chaque non-terminal peut être associé à une composition de terminaux et un non-terminal en préfixe ou suffixe seulement.
- Les grammaires algébrique (ou non-contextuelles) offrent une plus grande liberté en autorisant d'associer à un non-terminal n'importe quelle combinaison de terminaux et non-terminaux.
- Les grammaires contextuelles sont semblables aux grammaires algébrique mise à part l'ajout d'un contexte autour des productions en partie gauche et partie droite.
- Les grammaires régulières rendent possible l'association de n'importe quelle composition de non-terminaux et terminaux vers une autre composition.
Les grammaire non-contextuelles
Les grammaires non-contextuelles sont définits de la forme .
- : l'ensemble des non-terminaux de la grammaires.
- : l'ensemble des terminaux.
- : le non-terminal axiome.
- : l'ensemble des productions associant un non-terminal à une composition de non-terminaux et terminaux : .
Dans un contexte plus technique il suffit de préciser l'axiome et les productions car ces dernières listent tout les non-terminaux et terminaux existant. L'axiome joue un rôle important dans la validation d'un mot par une grammaire, si des dérivations successives partant de l'axiome mènent à une phrase correspondant au mot d'entrée alors l'entrée est validé. De même si la phrase correspondante au mot d'entrée et réduite jusqu'à obtenir l'axiome.
Plus formellement, soit le mot d'entrée : .
Les analyseurs lexicaux
L'analyse lexicale couvrent toutes les règles de la grammaire régulière, elle est utilisée pour faciliter le travail de l'analyseur syntaxique par la reconnaissance de certain groupe de lexème (les noms de variables, les nombres entiers, les nombres flottants) en unité lexicale. Ces unités lexicales seront utilisées comme terminaux par l'analyseur syntaxique et formeront donc la chaine d'entrée.
La plupart des règles d'une grammaire régulière sont décrites avec des expressions rationnelles se basant sur trois opération fondamentales : la concaténation, l'union et l'étoile de Kleene. Chacune de ces opération produit un ensemble de mot pouvant être reconnu par le motif.
- La concaténation permet de créer un singleton avec la concaténation de deux mots, autrement dit reconnaître que la concaténation de deux mots.
- L'union crée un ensemble constituée des deux mots, elle reconnaît l'un de deux mots seulement.
- L'étoile de Kleene permet de créer un ensemble contenant toutes les combinaisons d'un ensemble ainsi que le vide.
L'analyseur lexicale sépare en premier la chaine selon les blancs et d'autres séparateurs supplémentaires tels que les signes de ponctuations et les opérateurs, ensuite chaque portion de la chaine d'entrée est analysé selon un ensemble de règles rationnelles pour déterminer son unité lexicale.
Par exemple la chaine d'entrée "float i = 4.2 + 5;" sera découpée en {"float", "i", "=", "4.2", "+", "5", ";"}, ensuite selon les règles suivantes (utilisant le format de regex POSIX) :
- type (int|float|bool)
- id ([A-z]([A-z]|[0-9])*)[^(int|float|bool)]
- op_assign =
- float [0-9]*\.[0-9]*f?
- int [0-9]*
- end ;
- op \+|\-|/|\*
On obtient donc un ensemble des unités lexicales reconnues : {type, id, op_assign, float, op, int, end}.
Les automates finis
Un analyseur lexicale peut être construit avec un automate finis car d'après le théorème de Kleene toutes les grammaires régulière sont reconnu par un automate finis.
Théorème de Kleene : l’ensemble des langages rationnels sur un alphabet A est exactement l’ensemble des langages sur A reconnaissables par automate fini.
L'automate suivant reconnaît le motif
Malheureusement la limite des analyseur lexicaux et très vite atteint notamment avec le motifs de la forme . Dans ce cas il faut trouver un moyen de compter le nombre de a consommé pour essayer de consommer le même nombre de b, or les automates finis ne dispose pas de mémoire supplémentaire où placer ce compteur. Ce type de motif est fortement utilisé dans les languages de programmations pour vérifier le bon nombre de parenthèses, accolades etc…
Les analyseurs syntaxiques
L'analyse syntaxique suit l'analyse lexical en se basant sur les unités lexicales définit par ce dernier nommé lexèmes. Ces unités lexicales sont utilisées en tant que terminaux par l'analyseur syntaxique dans une liste de lexèmes.
Les algorithmes d'analyseur syntaxiques se basent tous sur le même type d'automates : les automates finis à pile. Les deux majeurs techniques connus sont l'analyse descendante et l'analyse ascendante. La première consiste à dériver l'axiome de la grammaire jusqu'à obtenir le mot d'entrée, la seconde technique consiste à réduire le mot d'entrée jusqu'à obtenir l'axiome. Ces deux techniques sont considéré comme opposé par leur façon de traiter les proto-phrases, l'analyseur descendant va toujours dériver et l'analyseur ascendant toujours réduire. Malgré cette symétrie, ces deux types d'analyseur n'utilisent pas les mêmes ensembles de règles.
Les automates finis à pile
Les automates finis à pile forment une extension des automates finis par l'adjonction d'une mémoire organisé en pile.
Ils permettent de couvrir les limitations des analyseurs lexicaux en reconnaissant les motifs de la forme .
Ce type d'automate est utilisé pour les analyseurs syntaxique tant descendant que ascendant.
Les analyseurs descendants
Le principe de l'analyse descendante est assez simple à mettre en œuvre. L'analyseur initialise une proto-phrase contenant l'axiome de la grammaire, à chaque étape l'analyseur va lire le symbole à gauche de la proto-phrase. Si celui ci est un terminal alors on le compare avec le début de la chaîne d'entrée, si il correspond on consomme le début de la chaine d'entrée ainsi que le terminal au début de la proto-phrase, au contraire on signale une erreur. Si le début de la proto-phrase est un non-terminal alors on le remplace par la partie droite de l'une de ses productions.
Le pseudo code correspondant est le suivant :
initialiser une pile contenant S Tant que la pile et la chaine d'entrée sont non-vide soit C le premier lexème de la chaine d'entrée soit T le sommet de la pile Si T est non-terminal choisir une production P de T dépiler le sommet de la pile empiler la partie droite de P Sinon si T équivaut C dépiler le sommet de la pile consommer le lexème C Sinon erreur
Si nous utilisons la grammaire contenant les deux productions et ( pour ne rien consommer ou ajouter), est l'axiome de la grammaire implicitement. La trace de l'algorithme descendant du mot d'entrée "aabb" est alors :
Pile | Entrée | Opération |
---|---|---|
S | aabb | |
aSb | aabb | dériver par |
Sb | abb | consommer a |
aSbb | abb | dériver par |
Sbb | bb | consommer a |
bb | bb | dériver par |
b | b | consommer b |
consommer b et valider le mot |
Malgré la simplicité de cette technique posent deux problèmes majeurs sont présents et mènent tous deux à utiliser un ensemble restreint de règles de grammaire par rapport aux grammaires algébrique.
Le premier problème concerne la récursivité gauche. Si l'algorithme ne sait pas exactement comment choisir les productions à appliquer et que la grammaire possède des productions de la forme ou autrement dit des productions associant le même non-terminal à gauche et à droite de la production, alors la dérivation du sommet d'une proto-phrase contenant mènera toujours à avoir le même non-terminal au sommet et l'algorithme ne pourra jamais arrêter de dériver.
- …
Pour contrer ce problème il existe une méthode permettant de transformer la récursivité gauche en une récursivité droite d'un non-terminal secondaire :
Soit
En effet, les règles initiales mènent intuitivement aux motifs de la forme . Remplacer la récursivité gauche consisterait en réécrire les règles donnant les mêmes motifs, soit en préfixe suivi d'une règle linéaire droite pour les .
Le second problème des analyseurs descendant et le determinisme lors du choix des productions à dériver. L'algorithme doit dériver un non-terminal selon la production qui à le plus de chance de réussir l'analyse. Dans l'exemple de la trace de l'analyse du mot "aabb", il fallait lors de la troisième dérivation dériver selon seulement. Dans le cas contraire non-deterministe, l'algorithme pourrait autoriser de revenir en arrière dans l'analyse lors d'erreur et d'appliquer les productions restantes jusqu'à ne plus avoir de production et donc signaler une véritable erreur.
Les analyseurs à symbole de prévision
Un analyseur à symbole de prévision, nommé LL(k) ou k est le nombre de symboles, lors d'une dérivation va lire k symboles au début de la chaine d'entrée pour déterminer la production à dériver contenant ces k symboles en préfixe. La plupart des analyseurs se contentent de 1 symbole de prévision.
En amont de l'analyse un algortihme est utilisé pour générer une table de la forme .
- : Le non-terminal courant (au sommet de la proto-phrase).
- : Le lexème courant (au sommet de la chaine d'entrée) utilisé comme préfixe.
- : la production ayant pour préfixe
Dans le cas de productions sans , l'algorithme va pour chaque non-terminal lire toutes les productions associées. Pour chacune des productions déterminer les préfixes possibles, en parfois dérivant jusqu'à obtenir un terminal à gauche, puis associer cette production avec ses préfixes dans la table.
Dans l'exemple d'une grammaire reconnaissant les déclarations de variables avec initialisation optionnelle par les règles suivantes :
Cette grammaire reconnait les mots "int i = 0;" et "int i;" de la façon suivante :
- Dérivation de l'axiome :
- Reconnaissance de et par respectivement "int" et "i"
- Dérivation de par l'une de ses productions
- Suite de l'analyse avec l'une des deux productions de
Ici est mis en évidence le branchement lors de la dérivation de , la table des productions par symbole de prévision est :
Non-terminal | Préfixe | Production |
---|---|---|
= | ||
; |
Lors de la dérivation de , l'analyseur va interroger la table de production pour le lexème en sommet d'entrée, dans le cas des deux mots "int i = 0;" et "int i;" ce symbole sera respectivement "=" ou ";". Ces deux symboles donnent dans la table deux productions valide pour continuer l'analyse.
Dans le cas où le préfixe n'est pas associer à une production, l'analyseur peut signaler une erreur car cela signifie que n'importe quelle dérivation mènera à une erreur lors de la reconnaissance du premier symbole terminal.
En ce qui concerne les grammaires possédant des productions en , ces productions sont valide pour n'importe quelle préfixe mais sont soumise à une condition supplémentaire sur les symboles rencontré après l'usage des non-terminaux de ces productions.
Soit une production , nous regardons toutes les productions utilisant le non-terminal de la forme et associant dans la table la production avec tous les préfixes de .
Dans le même exemple de grammaire reconnaissant les déclarations de variables, les règles peuvent s'écrire :
Le non-terminal associé à la production et utilisé seulement dans et le symbole suivant est ";". et donc associé à ";" dans la table comme pour la grammaire précédente sans productions utilisant .
Non-terminal | Préfixe | Production |
---|---|---|
= | ||
; |
Les analyseurs ascendants
Le principe de l'analyse ascendante est de réduire successivement une phrase correspondant à la chaine d'entrée jusqu'à obtenir l'axiome de la grammaire. Ce type d'analyseur nécessite la création en amont d'une table d'analyse difficilement concevable manuellement.
Cette table d'analyse se base sur deux opérations, réduire et décaler. Décaler consiste à déplacer un terminal en sommet de la chaine d'entrée dans la pile d'analyse, réduire consiste à remplacer le sommet de la pile d'analyse correspondant à la partie droite d'une production par le non-terminal associé à cette production.
Pour la grammaire suivante et le mot d'entrée "aabb" :
La trace de l'analyse ascendante est :
Pile | Entrée | Opération |
---|---|---|
aabb | ||
a | abb | décaler |
aa | bb | décaler |
aab | b | décaler |
aS | b | réduire par |
aSb | décaler | |
S | réduire par et valider |
Malheureusement il ne suffit pas de reconnaître la partie droite d'une production sur le sommet de la pile pour réduire par celle ci. Le contre-exemple est le suivant : Soit une grammaire linéaire droite de la forme , et le mot "aa". En premier "a" est décalé, puis réduit par la production , puis le second "a" est décalé pour obtenir sur la pile d'analyse "Aa". Aucune production n'a le motif "Aa" en partie droite pourtant le mot "aa" est bien valide intuitivement. Pour réussir cette analyse il aurait fallu décaler les deux "a" pour obtenir "aa", réduire le dernier en "aA" selon et enfin réduire en "A" selon .
Les réductions valide sont nommées manche, ils dépendent de l'état de la pile.
Les tables d'analyse
La table d'analyse représente les différents états de l'automate à pile formant l'analyseur, ces états sont calculé en se basant sur la notion d'item de production.
Un item de production représente l'état actuel de validation d'une production, ou autrement dit, le nombre de symbole validé lors de l'analyse.
Pour une production de la forme il existe 4 items, en cas générale il existe n + 1 items pour une production de n symboles en partie droite. Ces items sont notés :
Par exemple l'item signifie que l'analyse à reconnu et espère reconnaître . Le dernier item signifie que tous les symboles de la productions ont été reconnu et donc la production peut servir de réduction.
Tout d'abord pour pouvoir d'écrire des items de l'axiome de la grammaire, nous devons définir une grammaire augmenté similaire à la grammaire initiale mais avec l'adjonction d'un non-terminal et de la production . Ainsi l'état initiale de l'analyse est décrit par , aucun symbole n'est reconnu.
Deux fonctions vont être utilisés sur ces items, en particulier sur des ensembles d'items. La fonction et .
prend en argument un ensemble d'item. Soit un item de , si est un terminal alors ajouter cet item dans le résultat, sinon si est un non-terminal alors concaténer au résultat où et l'ensemble de toutes les items des productions de avec le point en première position :
prend en argument un ensemble d'item et un terminal, Soit , concaténer au résultat .
Ces deux fonctions vont permettre de déterminer les états de l'automates, chaque état et un ensemble d'items faisant partie de la collection canonique de l'analyseur. est l'état initiale puis détermine tous les états atteignables par un décalage de c, et enfin un état où aucune transition n'est possible mais contenant un item de la forme produira une réduction par .
La grammaire ci-dessus produits les états suivant :
L'automate résultant est le suivant :
L'algorithme représentant un analyseur ascendant simple peut être de la manière suivante :
TODO latex dans pre
Les arbres abstraits de syntaxe
Les arbres abstraits de syntaxe contiennent le minimum de sens de la chaine d'entrée, soit dans la plupart des languages de programmation les opérateurs, les opérandes et les structures de donnée.
Dans l'exemple d'un bout de code HTML "<h1>Titre</h1>" nous voudrions obtenir un arbre nous indiquant seulement la présence de la balise "<h1>" contenant le mot "Titre" :
Pour obtenir cette arbre il faut transformer l'arbre de dérivation en sortie de l'analyseur syntaxique avec des attributs de productions.
Les arbres de dérivation
Les arbres de dérivation notent toutes les dérivation ou réduction de production au cours de l'analyse. Lors de la dérivation d'une production nous produirons un sous arbre avec en racine le non-terminal et en nœuds enfants les symboles et :
Malheureusement la majorité des grammaires utilisées par les analyseurs descendant produisent des arbres de dérivation éloigné des arbres abstraits de syntaxe, comme la grammaire suivante :
Cette grammaire optimisée pour l'analyse descendante par la suppression de l'ambiguité et l'utilisation de préfixes unique, produit pour le mot d'entrée "5 + 2 - 3" l'arbre de dérivation :
Les grammaires attribuées
Les transformations entre arbre de dérivation et arbre abstrait de syntaxe sont décrites par des règles attribués à chacune des productions. Ses règles permettent de lire ou écrire des attributs de symbole. Pour éviter toute ambiguité entre les symboles dans les productions, les symboles de même nom sont renommés en , devient .
Les règles se regroupent en deux catégories, les règles synthétisées et les règles héritées.
Les règles synthétisées modifient le non-terminal à gauche d'une production en fonction des attributs des symboles à droite de la production. En gardant le même exemple de grammaire, la production doit produire un noeud vide, elle est associée à . De même la production définit le noeud avec l'operateur : .
Les règles héritées modifient des symboles à droite de la production en fonction des autres symboles à droite, les nœuds voisins, ou le non-terminal à gauche de la production. Ainsi la production doit basculer la valeur dans le nœeud , elle est donc associée à la règle .
La totalité des règles est :
Dans notre cas les attributs contiennent les nœuds de l'arbre abstrait de syntaxe et donc est l'arbre abstrait de syntaxe entier. Un problème se pose toujours lors de l'ordre d'application des règles. En effet la règle nécessite que contienne déjà un attribut valide. Pour résoudre ce problème, il est possible de préfixer l'ordre d'exécution des règles manuellement lors de leur conception ou déterminer par une analyse l'ordre d'exécution par la construction d'un arbre de dépendance entre les règles. Dans ce cas, les règles n'ayant aucune dépendance sont exécuté puis les autres.
Le programme exemple
Pour mettre en application les algorithmes d'analyse syntaxique, un programme à été écrit avec le support de l'analyse descendante et ascendante simple. Ce programme est disponible dans le dépôt : https://github.com/panzergame/analyseur_cmi
Un fois compilé, l'exécutable build/analyzer prend quatre arguments :
- input_file : Fichier texte à analyser.
- bnf_file : Fichier de description des productions.
- regex_file : Fichier de description des règles lexicales et des séparateurs.
- method : La méthode d'analyse, naive pour LL récursif, stack pour LL avec pile et slr pour SLR.
- Résultat : un historique de l'analyse en console et un arbre de dérivation au format .dot : derivation_tree.dot.
Les fichiers de description de règles bnf_file et regex_file utilise le format Backus Naur Form, encadrant chaque non-terminal par "<>" et associant des producitons à des non-terminaux grâce à "::=", ainsi s'écrit :
N ::= a b
Pour analyser les expressions simple de la forme "a + b", il faut tout d'abord définir les règles de l'analyseur lexical ainsi que les séparateurs additionnels :
<separator> ::= +-/* <integer> ::= [0-9]* <float> ::= [0-9]*\.[0-9]*f? <identifier> ::= [A-z]([A-z]|[0-9])* <operator> ::= \+|\-|/|\*
Ici quatre unités lexicales sont définit pour reconnaitre les entiers (integer), les flottants (float), les noms (identifier) et les opérateurs (operator). De plus les opérateurs sont aussi utilisés pour séparer la chaîne d'entrée.
Ensuite pour un analyseur ascendant les règles seront au format BNF :
<value> ::= identifier | float | integer <expr> ::= <value> operator <expr> | <value> <root> ::= <expr>
Ici nous validons les mots composés de valeurs entremêler d'opérateurs, où les valeurs peuvent être des noms, entiers ou flottants. Le nom-terminal
<root>
et le nom prédéfinit pour l'axiome de la grammaire.
Pour le fichier input_file contenant "5 + 4.2 - 80", l'exécution de la commande avec le méthode slr produit en console un récapitulatif des règles présente puis un historique de l'analyse et un affichage de l'arbre de dérivation qui se retrouve aussi dans derivation_tree.dot :