Analyse syntaxique et interprétation
Cours complet · OCaml (option informatique), chapitre 21 · prépas MPSI et MP, option informatique
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>21.1 Introduction et motivation
Comment une machine comprend-elle "3 + 4 * 2" et répond 11 (et non 14) ? C'est le travail d'un interpréteur, et c'est l'occasion d'un projet qui rassemble presque tout le livre. Le traitement se fait en trois étapes, une véritable chaîne de montage :
texte \;\; lexèmes \;\; arbre de syntaxe \;\; valeur
L'analyse lexicale (lexing) découpe la chaîne (chapitre 6) en lexèmes (nombres, opérateurs, parenthèses) ; l'analyse syntaxique (parsing) les organise en un arbre de syntaxe (un type somme récursif, chapitre 3) qui respecte les priorités ; l'évaluation parcourt cet arbre (comme au chapitre 3). Le parser s'écrit par descente récursive — un bel usage de la récursion mutuelle (chapitre 1).
21.2 Analyse lexicale
Un lexème est une unité de sens. Pour l'arithmétique, on en a six :
type lexeme = Nombre of int | Plus | Moins | Fois | ParenG | ParenD
L'analyseur lexical parcourt la chaîne, ignore les espaces, reconnaît les opérateurs et les parenthèses, et regroupe les chiffres consécutifs en un nombre.
let rec renverse l = (* utilitaire (chapitre 2) *)
let rec aux acc l = match l with [] -> acc | x :: r -> aux (x :: acc) r in
aux [] l
let lexer s =
let n = String.length s in
let lex = ref [] and i = ref 0 in
while !i < n do
let c = s.[!i] in
if c = ' ' then i := !i + 1
else if c = '+' then begin lex := Plus :: !lex; i := !i + 1 end
else if c = '-' then begin lex := Moins :: !lex; i := !i + 1 end
else if c = '*' then begin lex := Fois :: !lex; i := !i + 1 end
else if c = '(' then begin lex := ParenG :: !lex; i := !i + 1 end
else if c = ')' then begin lex := ParenD :: !lex; i := !i + 1 end
else if c >= '0' && c <= '9' then begin
let v = ref 0 in
while !i < n && s.[!i] >= '0' && s.[!i] <= '9' do
v := !v * 10 + (int_of_char s.[!i] - int_of_char '0');
i := !i + 1
done;
lex := Nombre !v :: !lex
end
else failwith "caractère inattendu"
done;
renverse !lex
lexer "3 + 42" renvoie [Nombre 3; Plus; Nombre 42]. On accumule en tête (donc à l'envers), puis on renverse. La boucle interne lit un nombre à plusieurs chiffres comme au chapitre 6.
21.3 L'arbre de syntaxe
On représente l'expression par un arbre (chapitre 3), où la structure encode les priorités : dans l'arbre de 3 + 4 2, le Fois est plus bas* que le Add, donc évalué d'abord.
type expr =
| Const of int
| Add of expr * expr
| Sub of expr * expr
| Mul of expr * expr
Le est prioritaire sur le + : 3 + 4 2 doit donner l'arbre Add (Const 3, Mul (Const 4, Const 2)). De plus - est associatif à gauche : 8 - 3 - 2 vaut (8 - 3) - 2 = 3 et non 8 - (3 - 2) = 7. C'est l'analyseur syntaxique qui construit l'arbre correct ; l'évaluation n'a plus qu'à le suivre.
21.4 Analyse syntaxique par descente récursive
On décrit la syntaxe par une grammaire à trois niveaux, du moins prioritaire au plus prioritaire :
| expression | terme ( (`+` `-`) terme ) |
|---|---|
| terme | facteur ( `*` facteur ) |
| facteur | nombre `(` expression `)` |
À chaque niveau correspond une fonction, qui consomme des lexèmes et renvoie un couple (arbre construit, lexèmes restants). Les fonctions s'appellent mutuellement (let rec … and …, chapitre 1).
let rec parse_expr lex =
let (g, reste) = parse_terme lex in
boucle_add g reste
and boucle_add g lex =
match lex with
| Plus :: reste -> let (d, r) = parse_terme reste in boucle_add (Add (g, d)) r
| Moins :: reste -> let (d, r) = parse_terme reste in boucle_add (Sub (g, d)) r
| _ -> (g, lex)
and parse_terme lex =
let (g, reste) = parse_facteur lex in
boucle_mul g reste
and boucle_mul g lex =
match lex with
| Fois :: reste -> let (d, r) = parse_facteur reste in boucle_mul (Mul (g, d)) r
| _ -> (g, lex)
and parse_facteur lex =
match lex with
| Nombre v :: reste -> (Const v, reste)
| ParenG :: reste ->
let (e, r) = parse_expr reste in
(match r with
| ParenD :: r2 -> (e, r2)
| _ -> failwith "parenthese fermante attendue")
| _ -> failwith "facteur attendu"
Les fonctions boucle_add et boucle_mul enchaînent les opérations à gauche : en construisant Add (g, d) puis en continuant avec ce résultat comme nouveau membre gauche, on obtient l'associativité à gauche. La descente expr → terme → facteur encode les priorités : un + ne se forme qu'après avoir entièrement analysé les *.
21.5 Interprétation
On assemble la chaîne complète : lexer, parser, évaluer (l'évaluation est celle du chapitre 3, étendue à Sub).
let rec evalue e =
match e with
| Const n -> n
| Add (a, b) -> evalue a + evalue b
| Sub (a, b) -> evalue a - evalue b
| Mul (a, b) -> evalue a * evalue b
let interprete s =
let (e, reste) = parse_expr (lexer s) in
if reste <> [] then failwith "expression mal formee"
else evalue e
interprete "3 + 4 2" vaut 11 ; interprete "(3 + 4) 2" vaut 14. Le test reste <> [] rejette les entrées mal formées (lexèmes en trop, comme "3 4").
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>21.6 Exercices résolus
Niveau (application directe du cours)
Définir lexeme et donner, à la main, le résultat attendu de lexer "(1+2)".
Démonstration
type lexeme = Nombre of int | Plus | Moins | Fois | ParenG | ParenD
(* lexer "(1+2)" = [ParenG; Nombre 1; Plus; Nombre 2; ParenD] *)
Chaque caractère significatif devient un lexème ; les nombres regroupent leurs chiffres. C'est l'alphabet de notre mini-langage.
Écrire evalue et évaluer Add (Const 3, Mul (Const 4, Const 2)).
Démonstration
let rec evalue e =
match e with
| Const n -> n
| Add (a, b) -> evalue a + evalue b
| Sub (a, b) -> evalue a - evalue b
| Mul (a, b) -> evalue a * evalue b
L'arbre Add (Const 3, Mul (Const 4, Const 2)) s'évalue en 3 + (4 * 2) = 11 : la structure de l'arbre impose déjà l'ordre des opérations.
Écrire une version simplifiée de lexer pour des chiffres uniques (pas de nombres à plusieurs chiffres), sur une chaîne sans espaces.
Démonstration
let lexer_simple s =
let lex = ref [] in
for i = String.length s - 1 downto 0 do (* à l'envers : pas de renverse *)
let c = s.[i] in
let l =
if c = '+' then Plus else if c = '-' then Moins
else if c = '*' then Fois else if c = '(' then ParenG
else if c = ')' then ParenD
else Nombre (int_of_char c - int_of_char '0')
in
lex := l :: !lex
done;
!lex
En parcourant de droite à gauche et en ajoutant en tête, on obtient directement la liste dans le bon ordre, sans renversement. (downto déborde du sous-ensemble strict ; avec to et un renversement final, on retrouve la version du cours.)
Niveau (raisonnement intermédiaire)
Écrire lexer gérant les nombres à plusieurs chiffres et les espaces.
Démonstration
let lexer s =
let n = String.length s in
let lex = ref [] and i = ref 0 in
while !i < n do
let c = s.[!i] in
if c = ' ' then i := !i + 1
else if c = '+' then begin lex := Plus :: !lex; i := !i + 1 end
else if c = '-' then begin lex := Moins :: !lex; i := !i + 1 end
else if c = '*' then begin lex := Fois :: !lex; i := !i + 1 end
else if c = '(' then begin lex := ParenG :: !lex; i := !i + 1 end
else if c = ')' then begin lex := ParenD :: !lex; i := !i + 1 end
else if c >= '0' && c <= '9' then begin
let v = ref 0 in
while !i < n && s.[!i] >= '0' && s.[!i] <= '9' do
v := !v * 10 + (int_of_char s.[!i] - int_of_char '0');
i := !i + 1
done;
lex := Nombre !v :: !lex
end
else failwith "caractere inattendu"
done;
renverse !lex
La boucle interne accumule un nombre tant qu'on lit des chiffres (v := !v * 10 + chiffre). On accumule les lexèmes à l'envers, d'où le renverse final.
Écrire parse_facteur (un nombre ou une expression parenthésée). Pourquoi a-t-il besoin de parse_expr ?
Démonstration
let rec parse_facteur lex =
match lex with
| Nombre v :: reste -> (Const v, reste)
| ParenG :: reste ->
let (e, r) = parse_expr reste in
(match r with
| ParenD :: r2 -> (e, r2)
| _ -> failwith "parenthese fermante attendue")
| _ -> failwith "facteur attendu"
and parse_expr lex = ... (* mutuellement récursif *)
Un facteur entre parenthèses contient une expression complète : parse_facteur doit donc appeler parse_expr, qui à son tour redescend jusqu'aux facteurs. D'où la récursion mutuelle (let rec … and …, chapitre 1) entre tous les niveaux de la grammaire.
Expliquer, sur lexer "3 + 4 * 2", comment la grammaire à trois niveaux donne l'arbre correct.
Démonstration
parse_expr appelle d'abord parse_terme, qui analyse 3 (un facteur) ; comme le lexème suivant est Plus (pas Fois), boucle_mul s'arrête : le premier terme est Const 3. De retour dans boucle_add, on voit Plus, donc on analyse un second terme : parse_terme lit 4, voit Fois, et forme Mul (Const 4, Const 2). Résultat : Add (Const 3, Mul (Const 4, Const 2)). Le a été regroupé avant* le +, car il vit à un niveau plus profond de la grammaire.
Niveau (approfondissement)
Écrire parse_expr et ses fonctions associées (descente récursive), gérant +, -, * et les parenthèses.
Démonstration
let rec parse_expr lex =
let (g, reste) = parse_terme lex in
boucle_add g reste
and boucle_add g lex =
match lex with
| Plus :: reste -> let (d, r) = parse_terme reste in boucle_add (Add (g, d)) r
| Moins :: reste -> let (d, r) = parse_terme reste in boucle_add (Sub (g, d)) r
| _ -> (g, lex)
and parse_terme lex =
let (g, reste) = parse_facteur lex in
boucle_mul g reste
and boucle_mul g lex =
match lex with
| Fois :: reste -> let (d, r) = parse_facteur reste in boucle_mul (Mul (g, d)) r
| _ -> (g, lex)
and parse_facteur lex =
match lex with
| Nombre v :: reste -> (Const v, reste)
| ParenG :: reste ->
let (e, r) = parse_expr reste in
(match r with ParenD :: r2 -> (e, r2) | _ -> failwith "parenthese attendue")
| _ -> failwith "facteur attendu"
Cinq fonctions mutuellement récursives reflètent la grammaire. Les boucle_* réalisent les répétitions « » en associant à gauche.
Assembler interprete et donner les valeurs de "2 * (3 + 4) - 5" et "10 - 3 - 2".
Démonstration
let interprete s =
let (e, reste) = parse_expr (lexer s) in
if reste <> [] then failwith "expression mal formee" else evalue e
interprete "2 * (3 + 4) - 5" . interprete "10 - 3 - 2" (associativité à gauche, grâce à boucle_add). La chaîne lexer parser evalue produit la bonne valeur en respectant priorités et associativité.
Étendre l'interpréteur avec la division / (même priorité que *). Indiquer les trois endroits à modifier.
Démonstration
Trois ajouts cohérents traversent toute la chaîne :
- le lexème : ajouter
Divau typelexeme, et le cas'/'danslexer; - l'arbre : ajouter
Divi of expr * exprau typeexpr; - le parser : dans
boucle_mul, traiterDiv :: restecommeFois(même niveau) en construisantDivi; - l'évaluateur : le cas
Divi (a, b) -> evalue a / evalue b.
| Div :: reste -> let (d, r) = parse_facteur reste in boucle_mul (Divi (g, d)) r
(* ... *)
| Divi (a, b) -> evalue a / evalue b
À chaque ajout d'un constructeur à l'arbre, le compilateur signale les match devenus non exhaustifs (chapitre 3) — ici celui d'evalue : il guide l'extension. Mais un filtrage qui finit par _ reste muet : c'est le cas de ceux de l'analyseur sur les lexèmes, qu'il faut donc revoir soi-même. Une grammaire bien structurée se prolonge sans effort.
Donner trois entrées mal formées et expliquer comment l'interpréteur les rejette.
Démonstration
"3 +":boucle_addvoitPlus, appelleparse_termesur la liste vide ;parse_facteur []ne filtre niNombreniParenGfailwith "facteur attendu"."(3 + 4": la parenthèse fermante manque ;parse_facteurne trouve pasParenDfailwith "parenthese attendue"."3 4":parse_expranalyse3, mais il reste[Nombre 4]non consommé ; le testreste <> []dansinterpretele rejette.
Un analyseur correct refuse les entrées invalides au lieu de produire un résultat absurde — c'est la rigueur du contrat (chapitre 1).
- Un interpréteur est une chaîne : texte (analyse lexicale) lexèmes (analyse syntaxique) arbre de syntaxe (évaluation) valeur.
- Lexer : parcourir la chaîne (chapitre 6), produire une liste de lexèmes ; regrouper les chiffres en nombres.
- Arbre de syntaxe : type somme récursif (chapitre 3) dont la structure encode priorités et associativité.
- Descente récursive : une fonction par niveau de grammaire (expression terme facteur), mutuellement récursives (chapitre 1) ; chaque fonction renvoie (arbre, lexèmes restants) ; les boucles
boucle_*donnent l'associativité à gauche. - Évaluer : parcours récursif de l'arbre (chapitre 3). Rejeter les entrées mal formées (lexèmes restants, lexème inattendu).
- Étendre le langage (nouvel opérateur) modifications cohérentes du lexème, de l'arbre, du parser et de l'évaluateur — le typeur guide, sauf là où un
_final avale le nouveau cas.
21.7 Exercices d'entraînement
Légende : application directe, raisonnement intermédiaire, approfondissement ; signale un classique incontournable. La numérotation prolonge celle des dix exercices résolus.
Thème A — Lexique.
- [11.] Étendre
lexerpour accepter le caractère de tabulation comme séparateur. - [12.] Compter, dans une chaîne, le nombre de nombres (lexèmes
Nombre). - [13.] Reconnaître des identifiants (suites de lettres) comme nouveau lexème
Ident of string? Discuter (chapitre 6 : construire une chaîne).
Thème B — Arbre et évaluation.
- [14.] Écrire
taille_expretprofondeur_expr(chapitre 3) sur l'arbre de syntaxe. - [15.] Réafficher l'arbre en notation infixe entièrement parenthésée (
string). - [16.] Réafficher l'arbre en notation postfixe et l'évaluer avec une pile (chapitre 8).
Thème C — Extensions du parser.
- [17.] Ajouter la division (cf. exercice 9) et tester sur
"12 / 3 / 2". - [18.] Ajouter l'opérateur unaire
-(moins préfixe :"-3 + 5"). - [19.] Ajouter la puissance
^, associative à droite (2 ^ 3 ^ 2) ; pourquoi la boucle à gauche ne convient-elle plus ?
Thème D — Vers un vrai langage.
- [20.] Ajouter des variables : un environnement (table de hachage du chapitre 8) associant un nom à une valeur.
- [21.] Évaluer une expression booléenne (chapitre 17) par le même procédé lexer/parser/eval.
- [22.] Discuter : quelles étapes ajouter pour passer de cet interpréteur d'expressions à celui d'un mini-langage avec affectations et boucles ?