Précédent Index Suivant

Syntaxe

Grâce à l'analyse lexicale, nous sommes à même de découper notre flot d'entrée en unités plus structurées : les unités lexicales. Il faut encore savoir assembler ces unités de façon à former des phrases syntaxiquement correctes d'un langage donné. Les règles d'assemblage syntaxique sont définies au moyen de règles de grammaires. C'est un formalisme originaire de la linguistique que les mathématiciens théoriciens des langages et les informaticiens ont su reprendre avec profit. Nous avons déjà vu par anticipation page ??, un exemple de grammaire pour le langage Basic. Nous allons reprendre cet exemple afin d'introduire les concepts de base des grammaires.

Grammaire

Formellement, une grammaire est constituée de quatre éléments :
  1. Un ensemble de symboles dits terminaux. Ces symboles représentent les unités lexicales du langage. En Basic, on y trouve les symboles opérateurs et de relations arithmétiques et logiques (+, &, <, <=, ..) ; les mots clés du langage (GOTO, PRINT, IF, THEN, ..) ; les entiers (unité entier) et les variables (unité variable).
  2. Un ensemble de symboles dits non terminaux. Ces symboles représentent les composantes syntaxique du langage. Par exemple, un programme Basic est composé de lignes, on a donc la composante Ligne ; une ligne peut contenir une Expression, etc.
  3. Un ensemble de règles dites de production. Elles décrivent comment s'articulent les symboles terminaux et ceux non terminaux pour produire une composante syntaxique. Une ligne en Basic, est formée de son numéro suivi d'une instruction. C'est le sens de la règle suivante :
    Ligne ::= entier Instruction
    Une même composante peut être produite de plusieurs façons. On sépare les alternatives par le symbole | comme dans
    Instruction ::= LET variable = Expression
      | GOTO entier
      | PRINT Expression
    etc.
  4. Enfin, parmi tous les non terminaux, on en distingue un que l'on appelle axiome. C'est sa règle de production qui dénote la règle de production du langage dans son entier.

Production et reconnaissance

Les règles de production peuvent servir à reconnaître qu'une suite de lexèmes appartient à un langage.

Considérons, par exemple un langage simple d'expressions arithmétiques :
Exp ::= entier (R1)
  | Exp + Exp (R2)
  | Exp * Exp (R3)
  | ( Exp ) (R4)
où (R1) (R2) (R3) et (R4) sont les noms que nous donnons à nos règles. Après passage par l'analyse lexicale, l'expression 1*(2+3) devient la suite de lexèmes :
entier * ( entier + entier )
Pour analyser cette phrase et reconnaître qu'elle appartient bien au langage des expressions arithmétiques, nous allons utiliser nos règles de droite à gauche : si une sous-expression correspond à un membre droit de l'une des règles, nous la remplaçons par le membre gauche correspondant et nous recommençons jusqu'à avoir réduit notre expression au non terminal axiome (ici, Exp). Voici les étapes d'une telle analyse3.
entier * ( entier + entier ) ¬¾(R1) Exp * ( entier + entier )
  ¬¾(R1) Exp * ( Exp + entier )
  ¬¾(R1) Exp * ( Exp + Exp )
  ¬¾(R2) Exp * ( Exp )
  ¬¾(R4) Exp * Exp
  ¬¾(R3) Exp
En partant de la dernière ligne qui ne contient plus que Exp et en remontant les flèches, on lit comment a pu être produite notre expression en partant de la règle axiome Exp : c'est donc une phrase bien formée du langage défini par la grammaire.

La traduction des grammaires en programmes capables de reconnaître qu'une suite de lexèmes appartient au langage défini par une grammaire est un problème beaucoup plus complexe que celui de l'utilisation des expressions rationnelles. En effet, un résultat mathématique nous dit que tout ensemble (de mots) défini au moyen du formalisme des expressions rationnelles peut aussi être défini dans un autre formalisme : les automates finis déterministes. Et ces derniers sont faciles à exprimer comme des programmes prenant en entrée une suite de caractères. On ne dispose pas d'un tel résultat pour le cas des grammaires en général. Cependant, on dispose de résultats plus faibles établissant l'équivalence entre certaines classes de grammaires et des automates un peu plus riches : les automates à pile. Nous ne voulons pas entrer dans les détails de ces résultats ni donner une définition exacte de ce que sont les automates. Cependant, nous ne pourrons pas faire l'économie d'une petite étude des grammaires afin d'identifier quelle classe de grammaire peut être utilisée dans les outils de génération d'analyseurs syntaxiques ou pour implanter directement un analyseur.

Analyse descendante

La décomposition de l'expression 1*(2+3) proposée au paragraphe précédent n'est pas unique : on aurait pu tout aussi bien commencer par réduire les entiers de droite à gauche, ce qui aurait permis d'utiliser plutôt la règle (R2) pour réduire d'abord 2+3. Ces deux façons de faire constituent deux types d'analyses : l'analyse ascendante (de droite à gauche) et l'analyse descendante (de gauche à droite). Cette dernière est facilement réalisable à partir de flots de lexèmes en utilisant le module Stream. L'analyse ascendante est celle réalisée par l'outil ocamlyacc, elle met en jeu un mécanisme explicite de pile comme cela a déjà été illustré pour l'analyse syntaxique des programmes Basic. Le choix du type d'analyse n'est pas indifférent car suivant la forme de grammaire utilisée pour spécifier un langage, on peut ou non utiliser l'analyse descendante.

Le cas simple

Le cas d'école de l'analyse descendante est la notation préfixée des expressions arithmétiques définie par :
Expr ::= entier
  | + Expr Expr
  | * Expr Expr
Dans un tel cas, il suffit de connaître le premier lexème pour savoir quelle règle de production a pu être utilisée. Cette prédictibilité immédiate permet de ne pas gérer explicitement la pile de l'analyse et de s'en remettre à l'empilement des appels récursifs de l'analyseur. Il est alors très facile d'écrire un programme implantant l'analyse descendante en utilisant les fonctionnalités des modules Genlex et Stream. La fonction infix_of ainsi obtenue prend une expression préfixée et retourne son équivalent infixé.

# let lexer s =
let ll = Genlex.make_lexer ["+";"*"]
in ll (Stream.of_string s) ;;
val lexer : string -> Genlex.token Stream.t = <fun>
# let rec stream_parse s =
match s with parser
[<'Genlex.Ident x>] -> x
| [<'Genlex.Int n>] -> string_of_int n
| [<'Genlex.Kwd "+"; e1=stream_parse; e2=stream_parse>] -> "("^e1^"+"^e2^")"
| [<'Genlex.Kwd "*"; e1=stream_parse; e2=stream_parse>] -> "("^e1^"*"^e2^")"
| [<>] -> failwith "Parse error"
;;
val stream_parse : Genlex.token Stream.t -> string = <fun>
# let infix_of s = stream_parse (lexer s) ;;
val infix_of : string -> string = <fun>
# infix_of "* +3 11 22";;
- : string = "((3+11)*22)"


Il faut prendre garde à ce que l'analyse lexicale est assez fruste. Il est conseillé d'intercaler systématiquement un espace entre les diverses unités lexicales de la chaîne d'entrée.

# infix_of "*+3 11 22";;
- : string = "*+"


Un cas moins simple

L'analyse syntaxique utilisant les flots est prédictive. Elle impose deux conditions sur les grammaires.
  1. Il ne doit pas y avoir de récursivité gauche dans les règles de grammaire. Une règle est récursive à gauche lorsque que le membre droit commence par le non-terminal qui est le membre gauche de la règle, comme dans Exp ::= Exp + Exp ;
  2. Il ne doit pas y avoir deux règles commençant par la même expression.
La grammaire usuelle des expressions arithmétiques donnée page ?? ne convient pas directement à l'analyse descendante : elle ne satisfait aucun des deux critères ci-dessus. Pour pouvoir utiliser une analyse descendante, il faut reformuler la grammaire de façon à supprimer la récursion gauche et le non déterminisme des règles. Pour les expressions arithmétiques, on peut donner, par exemple :
Expr ::= Atom SuiteExpr
SuiteExpr ::= + Atom
  | - Atom
  | * Atom
  | / Atom
  | e
Atom ::= entier
  | ( Expr )
Remarquons l'usage du mot vide e dans la définition de SuiteExp. Il est nécessaire si nous voulons qu'un entier tout seul soit une expression.

Notre grammaire permet l'implantation de l'analyseur suivant qui n'est qu'une traduction simple des règles de production. Cet analyseur produit l'arbre de syntaxe abstraite des expressions arithmétiques.

# let rec suite = parser
[< 'Lsymbol "+"; e2 = atom >] -> Some (PLUS,e2)
| [< 'Lsymbol "-"; e2 = atom >] -> Some (MOINS,e2)
| [< 'Lsymbol "*"; e2 = atom >] -> Some (MULT,e2)
| [< 'Lsymbol "/"; e2 = atom >] -> Some (DIV,e2)
| [< >] -> None
and atom = parser
[< 'Lint i >] -> ExpInt i
| [< 'Lsymbol "("; e = expr ; 'Lsymbol ")" >] -> e
and expr s =
match s with parser
[< e1 = atom >] ->
match suite s with
None -> e1
| Some (op,e2) -> ExpBin(e1,op,e2) ;;
val suite : lexeme Stream.t -> (op_bin * expression) option = <fun>
val atom : lexeme Stream.t -> expression = <fun>
val expr : lexeme Stream.t -> expression = <fun>


La difficulté d'utilisation de l'analyse descendante vient de la nécessité d'avoir des grammaires d'une forme très restreinte. Et lorsque le langage visé se formule naturellement, comme dans le cas des expressions infixées, avec une grammaire récursive à gauche, il n'est pas toujours trivial de trouver une grammaire équivalente (i.e. qui définit le même langage) qui satisfasse aux exigences de l'analyse descendante. C'est pourquoi les outils yacc et ocamlyacc mettent plutôt en oeuvre un mécanisme d'analyse ascendante qui autorise la définition de grammaires plus naturelles. Nous allons cependant voir que là encore, tout ne sera pas possible.

Analyse ascendante

Page ??, nous avons présenté intuitivement les principes de l'analyse ascendante : avancer (shift) ou réduire (reduce). À chacune de ces actions l'état de la pile est modifié. On peut déduire dette suite d'actions des règles de grammaire, si toutefois, comme dans le cas de l'analyse descendante, la grammaire s'y prète ! Ici encore, la difficulté viendra du non déterminisme des règles qui ne permettra pas de choisir entre avancer et réduire. Nous allons illustrer le fonctionnement de l'analyse ascendente et ses causes d'échec en considérant les sempiternelles expressions arithmétiques en notation postfixée et en notation infixée.

Pour le meilleur
La grammaire simplifiée des expressions arithmétiques postfixées est :
Expr ::= chiffre (R1)
  | Expr Expr + (R2)
  | Expr Expr * (R3)
Cette grammaire est duale de celle des expressions préfixées : il faut attendre la fin de l'analyse pour savoir, en fin de compte, quelle règle a été utilisée, mais une fois cette fin atteinte, on sait exactement comment faire. En fait, l'analyse ascendente de telles expressions est extrêmement proche d'un mécanisme d'évaluation avec une pile. Simplement, au lieu d'empiler les valeurs résultant du calcul, on empile les symboles de la grammaire. L'idée étant que si l'on démarre avec une pile vide il faut obtenir une pile ne contenant que le non terminal axiome lorsque l'entrée est épuisée. Les modifications de la pile sont les suivantes : si l'on avance, on empile le non terminal courant ; si l'on réduit, c'est que les premiers éléments de la pile correspondent à un membre droit (inversé) de règle, auquel cas on remplace ces éléments par le membre gauche correspondant.

La figure 11.2 illustre comment l'analyse ascendante traite l'expression : 1 2 + 3 * 4 +. L'unité lexicale lue est soulignée. La fin de l'entrée est marquée par un $.


Action Entrée Pile
  12+3*4+$ []
Avancer  
  2+3*4+$ [1]
Réduire (R1)  
  2+3*4+$ [Expr]
Avancer    
  +3*4+$ [2Expr]
Réduire (R1)  
  +3*4+$ [Expr Expr]
Avancer, Réduire (R2)  
  3*4+$ [Expr]
Avancer, Réduire (R1)  
  *4+$ [Expr Expr]
Avancer, Réduire (R3)  
  4+$ [Expr]
Avancer, Réduire (R1)  
  +$ [Expr Expr]
Avancer, Réduire (R2)  
  $ [Expr]

Figure 11.2 : Exemple d'analyse ascendante


Pour le pire
Toute la difficulté du passage de la grammaire au programme de reconnaissance du langage est de déterminer quel type d'action appliquer. Nous allons illustrer cette difficulté sur trois exemples provoquant trois genres d'indétermination.

Le premier exemple est une grammaire pour les expressions n'utilisant que l'addition :
E0 ::= entier (R1)
  | E0 + E0 (R2)
L'indécision de cette grammaire vient de la règle (R2). Supposons la situation suivante:
Action Entrée Pile
:    
  +... [E0 + E0 ...]
:    
Dans un tel cas, il n'est pas possible de déterminer s'il faut avancer et empiler le + ou réduire selon (R2) les deux E0 et le + présents dans la pile. Nous sommes en présence d'un conflit avancer-réduire (shift/reduce). Il provient du fait que l'expression entier + entier + entier peut être produite, par dérivation droite, de deux façons.

Première façon : E0 ¾®(R2) E0 + E0
    ¾®(R1) E0 + entier
    ¾®(R2) E0 + E0 + entier
  etc.

Seconde façon : E0 ¾®(R2) E0 + E0
    ¾®(R2) E0 + E0 + E0
    ¾®(R1) E0 + E0 + entier
  etc.

Les expressions obtenues par les deux dérivations peuvent sembler similaire du point de vue de l'évaluation d'une expression :
(entier + entier) + entier et entier + (entier + entier)
mais diffèrent pour la construction d'un arbre syntaxique (voir figure 6.3 page ??).

Le deuxième exemple de grammaire engendrant un conflit entre avancer et réduire relève du même genre d'ambigüité : un parenthésage implicite. Mais contrairement au cas précédent, le choix en avancer et réduire modifie le sens de l'expression analysée. Soit donc la grammaire :
E1 ::= entier (R1)
  | E1 + E1 (R2)
  | E1 * E1 (R3)
On retrouve avec cette grammaire le même conflit que ci-dessus à la fois pour + et *. Mais s'y rajoute un conflit entre + et *. Ici encore, une même expression peut être produite de deux façons. Il existe deux dérivations droite de

entier + entier * entier

Première façon : E1 ¾®(R3) E1 * E1
    ¾®(R1) E1 * entier
    ¾®(R2) E1 + E1 * entier
  etc.

Seconde façon : E1 ¾®(R2) E1 + E1
    ¾®(R3) E1 + E1 * E1
    ¾®(R1) E1 + E1 * entier
  etc.

Ici, les deux paranthésages (implicites) ne sont pas équivalents :
(entier + entier) * entier ¹ entier + (entier * entier)

Ce problème a déjà été rencontré pour les expressions Basic (voir page ??). Il a été résolu en attribuant des priorités aux opérateurs : on réduit (R3) avant (R2). Ce qui revient à parenthéser les produits.

On peut également résoudre le problème du choix entre + et * en modifiant la grammaire. On introduit deux nouveaux terminaux : T, pour terme, et F, pour facteur. Ce qui donne :
E ::= E + T (R1)
  | T (R2)
T ::= T * F (R3)
  | F (R4)
F ::= entier (R5)
Il n'y a maintenant plus qu'une seule façon de démarrer la séquence de production de entier + entier * entier : en utilisant la règle (R1).

Le troisième et dernier exemple concerne les instructions conditionnelles des langages de programmation. Un langage comme Pascal offre deux conditionnelles : le if .. then et le if .. then .. else. Imaginons la grammaire suivante :
Instr ::= if Exp then Instr (R1)
  | if Exp then Instr else Instr (R2)
  | etc...
Et dans la situation suivante :
Action Entrée Pile
:    
  else... [Instr then Exp if...]
:    
On ne peut pas déterminer si les premiers éléments de la pile correspondent à la conditionnelle de (R1), au quel cas il faut réduire, ou au premier Instr de la règle (R2), auquel cas il faut avancer.

Outre les conflits avancer-réduire, l'analyse ascendante provoque également des conflits réduire-réduire.

Nous présentons maintenant l'outil ocamlyacc qui utilise cette technique et peut rencontrer ces conflits.

L'outil ocamlyacc

L'outil ocamlyacc est construit sur le même principe que ocamllex : il prend en entrée un fichier de description d'une grammaire dont chaque règle est attachée à une action sémantique et il engendre deux fichiers Objective CAML d'extension .ml et .mli contenant la fonction d'analyse syntaxique et son interface.

Format général
Les fichiers de description syntaxique pour ocamlyacc utilisent par convention l'extension .mly et ont le format suivant :

%{  
  en-tête
}%  
  déclarations
%%  
  règles
%%  
  suite-et-fin

Le format d'une règle est :
non-terminal : symbole...symbole { action sémantique }
  | ...
  | symbole...symbole { action sémantique }
  ;

Un symbole est soit un terminal, soit un non terminal. Les sections << en-tête >> et << suite-et-fin >> jouent le même rôle qu'en ocamllex à la différence près que l'en-tête n'est visible que des règles et non des déclarations. En particulier, cela entraîne que les ouvertures de module (open) ne sont pas prises en compte dans la partie déclarative et donc que les types doivent être qualifiés.

Actions sémantiques
Les actions sémantiques sont des morceaux de code Objective CAML exécutés lorsque l'analyseur réduit la règle à laquelle elles sont associées. Le corps d'une action sémantique peut faire référence aux composants du membre droit de la règle. Ceux-ci sont numérotés de gauche à droite en commençant par 1. Le premier composant est référencé par $1, le deuxième par $2, etc.

Axiomes
On peut déclarer plusieurs non terminaux axiomes de la grammaire en écrivant, dans la partie déclaration :
 %start non-terminal .. non-terminal
Pour chacun d'eux sera engendré une fonction d'analyse. Il faut préciser, toujours dans la partie déclaration, quel sera le type de retour de ces fonctions.
 %type <type-retour> non-terminal
Le type-retour doit être qualifié.

Warning


Les symboles non terminaux deviennent les noms des fonctions d'analyse. Ils ne doivent donc pas commencer par une majuscule qui est l'initiale réservée aux constructeurs.


Unités lexicales
Les règles de grammaire font référence aux unités lexicales qui sont les symboles terminaux des règles.

Un (ou des) lexème(s) se déclare(nt) de la façon suivante :
 %token PLUS MOINS MULT DIV MOD
Certaines unités lexicales, comme les identificateurs, représentent un ensemble de chaînes de caractères. Lorsque l'on rencontre un identificateur on veut pouvoir récupérer cette chaîne. On indique à l'analyseur lexical que ces lexèmes ont une valeur associée en donnant entre < et > le type de cette valeur :
 %token <string> IDENT

Warning


Après traitement par ocamlyacc toutes ces déclarations sont transformées en constructeurs du type token. Ils doivent donc impérativement commencer par une majuscule.


On peut utiliser des chaînes de caractères comme terminaux implicites comme dans :
expr : expr "+" expr   { ... }
     | expr "*" expr   { ... }
     | ...             
     ;
auquel cas, il est inutile de déclarer un symbole les représentant : ils sont directement traités par l'analyseur syntaxique sans avoir à passer par l'analyseur lexical. Par soucis d'uniformité, nous ne conseillons cependant pas cette manière de faire.

Priorité, associativité
Nous avons vu que nombre de conflits de l'analyse ascendante proviennent de règles implicites d'association d'un opérateur ou de conflits de priorité entre opérateurs. Pour éliminer ces conflits, on peut déclarer des règles d'associativité par défaut (à gauche ou à droite ou aucune) pour les opérateurs ainsi que des règles de priorité. La déclaration suivante indique que les opérateurs + (lexème PLUS) et * (lexème MULT) sont associatifs à droite par défaut et que * a une priorité supérieure à celle de + car MULT est déclaré après PLUS.
 %left PLUS
 %left MULT
Deux opérateurs déclarés sur une même ligne ont même priorité.

Options de la commande
ocamlyacc possède deux options :
Utilisation conjointe avec ocamllex
On peut composer les deux outils ocamllex et ocamlyacc de sorte que la transformation du flot de caractères en flot de lexèmes soit l'entrée de l'analyseur syntaxique. Pour se faire, Le type lexeme doit être commun aux deux. Ce type est défini dans les fichiers d'extension .mli et .ml engendrés par ocamlyacc à partir des déclarations des token du fichier d'extension .mly correspondant. Le fichier .mll importe ce type; ocamllex traduit ce fichier en une fonction Objective CAML de type Lexing.lexbuf -> lexeme. L'exemple page ?? illustre cette interaction et décrit les différentes phases de compilation.

Grammaires contextuelles

Les analyseurs engendrés par ocamlyacc traitent de langages engendrés par des grammaires dites non contextuelles. La poursuite de l'analyse d'un flot de lexème ne dépend pas des valeurs syntaxiques déjà traitées. Ce n'est pas le cas du langage L décrit par la formule suivante :
L ::= wCw | w avec w Î (A|B)*
A, B et C sont des symboles terminaux. On a écrit wCw (avec w Î (A|B)*) et non simplement (A|B)*C(A|B)* car nous voulons avoir le même mot à gauche et à droite du C médian.

Pour analyser les mots de L, il faut garder en mémoire ce qui a été rencontré avant la lettre C pour vérifier que l'on retrouve bien la même chose après. Voici une solution à ce problème qui utilise le parcours d'un flot. L'idée générale de l'algorithme est de construire une fonction d'analyse de flot qui reconnaîtra exactement le sous-mot figurant avant l'éventuelle occurrence de C :

On se donne le type :

# type token = A | B | C ;;


La fonction parse_w1 construit la fonction de mémorisation du premier w sous forme d'une liste de fonctions d'analyse de flot atomique (i.e. pour un seul token) :

# let rec parse_w1 s =
match s with parser
[<'A; l = parse_w1 >] -> (parser [<'A >] -> "a")::l
| [<'B; l = parse_w1 >] -> (parser [<'B >] -> "b")::l
| [< >] -> [] ;;
val parse_w1 : token Stream.t -> (token Stream.t -> string) list = <fun>


Le résultat des fonctions construites par parse_w1 est simplement la chaîne de caractères contenant l'unité lexicale analysée.

La fonction parse_w2 prend en argument une liste construite par parse_w1 pour composer chacun de ses éléments en une seule fonction d'analyse :

# let rec parse_w2 l =
match l with
p::pl -> (parser [< x = p; l = (parse_w2 pl) >] -> x^l)
| [] -> parser [<>] -> "" ;;
val parse_w2 : ('a Stream.t -> string) list -> 'a Stream.t -> string = <fun>


Le résultat de l'application de parse_w2 sera la chaîne représentant le sous-mot w. Par construction, la fonction parse_w2 ne pourra reconnaître que le sous-mot parcouru par parse_w1.

En utilisant la possibilité de nommer dans les flots les résultats intermédiaires, on écrit la fonction de reconnaissance des mots du langage L :

# let parse_L = parser [< l = parse_w1 ; 'C; r = (parse_w2 l) >] -> r ;;
val parse_L : token Stream.t -> string = <fun>


Voici deux petits exemples. Le premier donne en résultat la chaîne contenant le mot entourant C, le second échoue car les deux mots entourant C sont différents :

# parse_L [< 'A; 'B; 'B; 'C; 'A; 'B; 'B >];;
- : string = "abb"
# parse_L [< 'A; 'B; 'C; 'B; 'A >];;
Uncaught exception: Stream.Error("")



Précédent Index Suivant