Grammaires non contextuelles
Cours complet · informatique (MP2I/MPI), chapitre 30 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
30.1 Au-delà des automates
Le chapitre chap:automates s'est achevé sur une limite précise : un automate fini n'a qu'une mémoire bornée, et ne peut donc pas reconnaître . Or c'est exactement ce dont on a besoin pour décrire un langage de programmation : les parenthèses doivent s'équilibrer, et leur nombre n'est pas borné.
Les grammaires lèvent cette limite. Le programme en donne l'usage : « les grammaires formelles ont pour principal intérêt de définir des syntaxes structurées, en particulier celles des langages informatiques (langage de programmation, langage de requête, langage de balisage) ».
Le B.O. les nomme : « sont hors programme : les automates à pile, les grammaires syntagmatiques générales, la hiérarchie de Chomsky ». Et pour l'analyse : « on ne parle pas d'analyseur LL ou lr. On ne présente pas de théorie générale de l'analyse syntaxique. »
On apprend donc à écrire une grammaire, à dériver un mot, à construire un arbre d'analyse et à écrire un analyseur ad hoc par descente récursive. Rien de plus.
30.2 Grammaires
Une grammaire non contextuelle est la donnée :
- d'un ensemble de symboles terminaux — les lettres du mot final ;
- d'un ensemble de symboles non-terminaux — les catégories intermédiaires ;
- d'un symbole initial, non-terminal ;
- de règles de production , où est un non-terminal et une suite de symboles.
Le programme fixe les notations : pour la production, pour la dérivation immédiate — remplacer un non-terminal par le membre droit d'une de ses règles — et pour une suite de dérivations.
Le langage engendré est l'ensemble des mots de terminaux dérivables du symbole initial. Un langage est non contextuel s'il est engendré par une telle grammaire.
Le membre gauche d'une règle est un seul non-terminal. On peut donc remplacer par quel que soit son voisinage — sans regarder le contexte. C'est cette restriction qui rend l'analyse praticable, et c'est elle qui donne son nom à la classe.
Une dérivation : . Le langage engendré est — celui-là même dont le lemme de l'étoile a établi qu'aucun automate fini ne le reconnaît.
La grammaire y arrive parce que la récursivité de « mémorise » implicitement le nombre de , dans la profondeur de la dérivation. Là où l'automate n'avait que ses états, la grammaire dispose d'une structure imbriquée sans limite.
Le programme demande de « montrer comment définir une expression arithmétique ou une formule de la logique propositionnelle par une grammaire ».
Les trois niveaux , , ne sont pas décoratifs : ils encodent la priorité des opérateurs. Comme un ne peut apparaître qu'au niveau , qui est plus haut que , l'arbre place nécessairement le plus bas — donc plus près des feuilles, donc évalué d'abord. La priorité n'est écrite nulle part : elle est dans la forme de la grammaire.
Le programme demande cette « non contextualité des langages réguliers ».
Démonstration
Soit un automate déterministe . On prend un non-terminal par état, comme symbole initial, et les règles
Une dérivation reproduit exactement une exécution de l'automate : la grammaire engendre donc le même langage.
est non contextuel et n'est pas régulier : les langages non contextuels forment donc une classe strictement plus large. Le programme s'arrête là — la hiérarchie de Chomsky, qui poursuit cette échelle, est hors programme.
30.3 Arbre d'analyse et ambiguïté
L'arbre d'analyse d'une dérivation a le symbole initial pour racine, un nœud par application de règle, et les terminaux pour feuilles — lues de gauche à droite, elles donnent le mot.
C'est l'arbre du chapitre chap:arbres, et c'est l'objet que produit un compilateur avant toute autre chose.
Une dérivation est à gauche si l'on remplace toujours le non-terminal le plus à gauche, à droite dans l'autre cas. Un même arbre d'analyse correspond à exactement une dérivation gauche et une dérivation droite : c'est l'ordre des remplacements qui change, pas leur résultat.
Une grammaire est ambiguë s'il existe un mot admettant deux arbres d'analyse distincts. Deux grammaires sont faiblement équivalentes si elles engendrent le même langage — sans que leurs arbres coïncident.
Avec la grammaire simplifiée , le mot admet deux arbres : l'un évalue , l'autre . La grammaire ne dit pas lequel est correct — c'est ce qui rend une grammaire ambiguë inutilisable pour un compilateur.
La grammaire à trois niveaux de la section précédente engendre le même langage — elle lui est faiblement équivalente — et n'est pas ambiguë. C'est tout l'objet de ses niveaux.
Le dangling else :
Sur l'instruction
si c1 alors si c2 alors a sinon a
le sinon peut se rattacher au premier si ou au second : deux arbres, deux comportements différents à l'exécution.
Comment les vrais langages tranchent : par une règle extérieure à la grammaire — « le sinon se rattache au si le plus proche » — ou par une grammaire remaniée qui interdit le cas litigieux. C'est aussi pourquoi tant de langages exigent un marqueur de fin explicite (fi, end, des accolades) : l'ambiguïté disparaît alors par construction.
30.4 Analyse syntaxique par descente récursive
« On peut présenter au tableau un algorithme ad hoc d'analyse syntaxique par descente récursive (algorithme top-down) pour un langage de balisage fictif — par exemple, la grammaire de symbole initial et de règles de production , sur l'alphabet . »
C'est cette grammaire précise qu'on traite.
Méthode : Une fonction par non-terminal
Le principe de la descente récursive tient en une phrase : on écrit une fonction par non-terminal, et le corps de la fonction est la règle.
(* Analyseur pour S -> T S | c et T -> a S b, sur l'alphabet {a, b, c}.
pos est la position courante dans le mot ; les fonctions l'avancent.
Lèvent Echec si le mot n'appartient pas au langage. *)
exception Echec
let analyser mot =
let n = String.length mot in
let pos = ref 0 in
let lire c =
if !pos < n && mot.[!pos] = c then incr pos else raise Echec
in
let rec s () =
if !pos < n && mot.[!pos] = 'a' then begin t (); s () end (* S -> T S *)
else lire 'c' (* S -> c *)
and t () =
lire 'a'; s (); lire 'b' (* T -> a S b *)
in
s ();
if !pos <> n then raise Echec (* tout le mot doit être consommé *)
La fonction s choisit entre ses deux règles en regardant un seul caractère : si c'est un a, la seule règle possible est , car commence forcément par a ; sinon ce doit être .
Cette propriété — un caractère d'avance suffit à décider — est ce qui rend la descente récursive possible. Le programme interdit d'en faire la théorie (« on ne parle pas d'analyseur LL ou lr »), mais l'idée pratique est là : la structure du code est celle de la grammaire, et la récursivité du langage porte la récursivité de la structure.
La règle traduite littéralement donne :
let rec e () = e (); lire '+'; t () (* NE TERMINE JAMAIS *)
La fonction s'appelle elle-même sans avoir consommé un seul caractère : il n'y a pas de variant, donc pas de terminaison — c'est exactement le défaut du chapitre chap:recursivite.
La grammaire des expressions arithmétiques, si commode pour les priorités, est donc inutilisable telle quelle en descente récursive. On la réécrit en itératif :
let rec e () = t (); while pointe_sur '+' do lire '+'; t () done
La grammaire la plus élégante n'est pas toujours celle qu'on analyse. C'est une contrainte réelle de l'écriture des compilateurs.
Le programme demande de « faire le lien avec la définition par induction de certaines structures de données (listes, arbres, formules de logique propositionnelle) ». Il est exact : une grammaire non contextuelle est une définition inductive, et l'arbre d'analyse est la valeur du type correspondant.
Écrire un analyseur, c'est passer d'un mot — la syntaxe concrète — à une valeur du type inductif — la syntaxe abstraite. Le chapitre chap:arbres évaluait déjà ces arbres ; ce chapitre montre d'où ils viennent.
30.5 Ce qu'il faut retenir
- Une grammaire non contextuelle a un seul non-terminal en membre gauche : on remplace sans regarder le contexte.
- Elle dépasse les automates parce que la récursivité lui donne une mémoire non bornée : engendre .
- L'arbre d'analyse porte la structure. Les niveaux , , encodent la priorité des opérateurs — elle n'est écrite nulle part ailleurs.
- Une grammaire ambiguë donne deux arbres pour un mot, donc deux sens : vaut ou . Le « sinon pendant » en est le cas classique.
- La descente récursive écrit une fonction par non-terminal. Elle échoue sur les grammaires récursives à gauche — faute de variant — et l'on réécrit alors la règle en boucle.