Objectif
L'objectif du parseur est de transformer le lexer en un arbre syntaxique abstrait (AST en anglais).
Dans cet arbre, il n'y a plus de parenthèses, et l'ordre des éléments insérés définit l'ordre dans lequel les opérations vont être calculées.
Par exemple, pour l'expression "(56+18)*3", correspondant donc au lexer "(", "56", "+", "18", ")", "*", "3", l'arbre correspondant serait le suivant :
On voit bien que l'addition 56+18 sera effectuée en premier, puis ensuite sera effectuée la multiplication par 3 du résultat de 56+18.
Toute la difficulté est d'obtenir cet arbre : l'opérateur de puissance à la priorité sur la multiplication et la division, qui ont la priorité sur l'addition et la soustraction, et les parenthèses ont la priorité sur l'ensemble des opérateurs !
Comment s'en sortir ?
La BNF
Théorie
On voit ici que réfléchir de cette façon est délicate. Il faudrait écrire tout cela mathématiquement.
Deux chercheurs en informatique,
John Backus et
Peter Naur, également confrontés à un problème similaire, ont alors inventés la BNF, pour
Backus Naur Form.
L'objectif était de pouvoir décrire rigoureusement un langage de programmation.
La BNF est constituée de méta-symboles, de règles terminales et de règles non terminales.
Les méta-symboles sont les symboles propres à la BNF.
Chaque règle est constituée de deux parties : son nom et sa définition.
nom_regle ::= definition_de_la_regle.
Lorsque la règle appelle une autre règle, elle est dite non-terminale.
Les méta-symboles de la BNF sont "(", ")", "+", "*", "[", "]", "|", et "'".
Le symbole "|" signifie "ou". Chaque mot ou expression recherché est entouré d'apostrophes.
Ainsi, on peut définir un nombre de cette façon :
Code : Autre1
| Num ::= ('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9') (ø|Num) |
Cela signifie que chaque nombre est constitué d'un chiffre suivi soit de rien, soit d'un autre chiffre.
Le symbole "*" signifie répéter 0 fois ou plus, et le symbole "+" signifie répéter au moins une fois et le symbole "?" signifie "doit être présent" 0 ou une fois.
Ainsi Num peut se définir également de cette façon :
Code : Autre1
| Num ::= ('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9')+ |
Et de cette façon :
Code : Autre1
2
| Num ::= Chiffre(Chiffre)*
Chiffre ::= '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' |
Retour au problème : BNF d'une expression mathématique
Dans une expression mathématique, on peut classer les opérateurs par ordre de priorité :
- les parenthèses : règle pth ;
- l'opérateur puissance : règle pow ;
- les opérateurs de division et de multiplication : règle ope_high ;
- les opérateurs d'addition et de soustraction : règle ope_low.
On peut donc définir la grammaire des expressions mathématiques de cette façon :
Code : Autre1
2
3
4
| ope_low ::= ope_high(('+'|'-')ope_high)*
ope_high ::= pth(('*'|'/')pth)*
pth ::= ('('ope_low')')|num
num ::= ('0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9')+ |
L'idée, c'est que plus l'opérateur est fort en terme de priorité, plus il doit être appelé "vite", car ce sont les opérateurs forts qui doivent se retrouver en bas de l'arbre (pour être exécutés en premier).
La règle pth est la plus forte : elle est donc exécutée en premier (car ope_low appelle d'abord ope_high qui appelle d'abord pth).
Ensuite il y a ope_high et ope_low.
Entre les parenthèses, on revient à ope_low, c'est un autre calcul qui commence.
Dans cet exemple très simple, la fonction qui valide la syntaxe de la phrase et la fonction qui transforme le lexer en arbre binaire n'en sont qu'une seule.
Chaque fonction (règle) renverra un arbre binaire.
Tous les nombres qui seront dans l'arbre sont des éléments terminaux de l'arbre. Par exemple, dans l'arbre correspondant à "5 + 3", 5 et 3 n'ont pas d'enfants :
Donc dans la règle pth, quand le
token sera un nombre, on renverra un arbre qui contient juste le nombre en noeud.
Dans les autres règles, ope_low, ope_high et pow, on renvoie un arbre ou le noeud est l'opérateur, ou la partie droite est la fonction de priorité supérieure et la partie gauche est cette même fonction.
Par exemple, pour ope_low, l'arbre serait :
Vous comprenez le principe ?
Bien, maintenant passons à l'implémentation.
Implémentation du parseur
Tout d'abord, définissons la structure de l'arbre binaire que l'on va ressortir :
Code : OCaml | type tree =
| Nb of int
| Op of tree * string * tree
|
Le type arbre est composé soit d'une feuille terminale (Nb), soit d'un sous arbre (Op) composé d'un
string (le nom de l'opérateur) et de deux sous-arbres, à gauche et à droite.
C'est donc un type récursif.
Pour implémenter la grammaire décrite, on peut soit utiliser des outils comme
Bison, ou le faire à la main.
Dans ce cas, la technique consiste à voir chaque règle comme une fonction.
Chaque fonction renvoie un arbre, et la valeur renvoyée à la fin est un arbre constitué d'un empilement des arbres renvoyés par les fonctions.
Implémentons la règle ope_low :
Code : OCaml | (* ope_low ::= ope_high(('+'|'-')ope_high)* *)
let rec ope_low =
let rec aux gauche = parser
[<'Kwd (("+" | "-") as op); droite = ope_high; s >] -> aux (Op (gauche, op, droite)) s
| [< >] -> gauche
in
parser [< gauche = ope_high; s >] -> aux gauche s
|
La fonction ope_low prend en argument le lexer (grâce au mot clé "parser"), traite le ope_high (qui renvoie un arbre binaire, appelons-le "gauche"), et
match le reste du lexer.
S'il y a un opérateur faible qui vient juste après, et que le reste du lexer vérifie la règle ope_high (arbre binaire "droite"), on renvoie un arbre binaire constitué de l'opérateur en noeud, de "gauche" pour la branche gauche et de "droite" pour la branche droite, puis on recommence (rappel de la fonction aux jusqu'à ce que les
tokens suivants ne respectent plus le motif
[<'Kwd (("+" | "-") as op); droite = ope_high; s >]
.
De la même manière, on peut implémenter toutes les autres règles :
Code : OCaml 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 | let rec ope_low =
let rec aux gauche = parser
[<'Kwd (("+" | "-") as op); droite = ope_high; s >] -> aux (Op (gauche, op, droite)) s
| [< >] -> gauche
in
parser [< gauche = ope_high; s >] -> aux gauche s
and ope_high =
let rec aux gauche = parser
[<'Kwd (("*" | "/" | "mod") as op); droite = pth; s >] -> aux (Op (gauche, op, droite)) s
| [< >] -> gauche
in
parser [< gauche = pth; s >] -> aux gauche s
and pth = parser
| [< 'Kwd "("; e = ope_low; 'Kwd ")" >] -> e
| [< 'Int n >] -> Nb n
|
On voit que le code de chaque fonction respecte la forme de la règle qui lui correspond.
On observe également que le code de la fonction ope_high et le code de la fonction ope_low sont très proches.
On peut donc "factoriser" les deux fonctions :
Code : OCaml | let op_parser op_list next_level =
let rec aux gauche next_level = parser
[<'Kwd op when List.mem op op_list; droite = next_level; s >] -> aux (Op (gauche, op, droite)) next_level s
| [< >] -> gauche
in parser [< gauche = next_level; s >] -> aux gauche next_level s
let rec ope_low l = op_parser ["+"; "-"] ope_high l
and ope_high l = op_parser ["*"; "/"] pth l
and pth = parser
| [< 'Kwd "("; e = ope_low; 'Kwd ")" >] -> e
| [< 'Int n >] -> Nb n
|