I. Introduction▲
Le parsing (ou analyse syntaxique) est une opération, très courante en informatique, qui consiste à « comprendre » un texte selon des règles prédéterminées (c.-à-d. sa grammaire). Du parsing est réalisé dans les compilateurs mais également dans n'importe quel programme qui lit des documents structurés (XML, YAML…), qui reçoit des requêtes (HTTP…) ou encore qui propose une interface textuelle.
Sans entrer dans le domaine de la compilation, il existe de nombreux algorithmes de parsing, notamment l'analyse LALR (utilisée dans Lex/Yacc) ou l'analyse LL (utilisée dans Antlr). Les combinateurs de parseurs monadiques sont une façon particulière d'implémenter des parseurs. Ils ont été proposés dans les années 1990 avec le langage Haskell. Ce dernier permet de les implémenter de façon simple, élégante et facile à utiliser.
Dans ce tutoriel, on va partir d'un exemple de base (une mini-calculatrice), avec un parseur « classique ». On va ensuite modifier ce parseur successivement jusqu'à aboutir à une implémentation basée sur les combinateurs de parseurs monadiques. On ne donnera ici que les sections de code les plus importantes mais le code complet est disponible en ligne. Enfin, on suppose ici que le lecteur connaît les notions et syntaxes de base de Haskell, notamment les types algébriques et les classes de types.
II. Exemple de base▲
II-A. Présentation▲
On veut écrire une calculatrice basique permettant d'ajouter ou de multiplier des entiers positifs, en respectant les priorités des opérateurs. Par exemple, on veut pouvoir gérer les expressions suivantes.
12+3
123
2+5*8
2*5+8
C'est un exemple très classique, mais qui a l'avantage d'avoir une grammaire simple. En sortie, on veut évaluer les expressions analysées et générer leur code LISP équivalent.
II-B. Organisation générale▲
Le parsing est généralement la première étape d'un processus plus complet. En effet, le texte d'entrée est d'abord analysé de façon à en construire une structure de données interne. Celle-ci peut ensuite être analysée, optimisée ou servir à générer du code de sortie (code machine, code source dans un autre langage…).
II-C. Représentation intermédiaire▲
À partir du texte d'entrée analysé, le parsing génère une représentation intermédiaire (par exemple, un arbre syntaxique abstrait). Celle-ci contient ce qui a été compris concrètement de l'entrée et qui peut être utilisé ensuite dans le reste du programme.
Pour implémenter une représentation intermédiaire, on peut définir des types de données algébriques. Ces types sont très pratiques pour écrire des structures de données arborescentes. Par exemple, pour notre calculatrice en Haskell, on peut définir le type Expr suivant.
2.
3.
4.
5.
data
Expr
=
ExprVal Int
|
ExprAdd Expr Expr
|
ExprMul Expr Expr
deriving
(Eq
, Show
)
On notera la nature récursive du type Expr. Par exemple, la valeur ExprAdd, qui modélise l'addition, contient deux sous-expressions, de type Expr également.
Ainsi, on peut représenter l'expression 2*5+8 par la valeur ExprAdd (ExprMul (ExprVal 2
) (ExprVal 5
)) (ExprVal 8
), ce qui correspond à l'arbre suivant.
|
II-D. Évaluation directe▲
Une fois la représentation intermédiaire implémentée par des types algébriques, il est très facile de manipuler les données analysées par notre calculatrice. Par exemple, pour évaluer une expression construite lors du parsing, on peut utiliser la fonction eval suivante.
2.
3.
4.
eval ::
Expr ->
Int
eval (ExprVal v) =
v
eval (ExprAdd e1 e2) =
eval e1 +
eval e2
eval (ExprMul e1 e2) =
eval e1 *
eval e2
Cette fonction fait du pattern matching sur les différentes valeurs possibles du type Expr et retourne le calcul correspondant au pattern. Là encore, la récursivité se fait très naturellement : par exemple, évaluer une valeur ExprAdd revient à ajouter l'évaluation de ses deux sous-expressions.
II-E. Génération de code LISP▲
De façon similaire, on peut générer du code LISP à partir de notre représentation intermédiaire.
2.
3.
4.
5.
6.
toLisp :: Expr -> String
toLisp (ExprVal v) =
show v
toLisp (ExprAdd e1 e2)
=
"(+ "
++
toLisp e1 ++
" "
++
toLisp e2 ++
")"
toLisp (ExprMul e1 e2)
=
"(* "
++
toLisp e1 ++
" "
++
toLisp e2 ++
")"
II-F. Programme principal▲
Finalement, notre calculatrice va permettre de saisir une expression au clavier puis la parser et afficher sa représentation intermédiaire, son code LISP et son évaluation.
2.
3.
4.
5.
6.
7.
8.
9.
>
12+3
ExprAdd (ExprVal 12
) (ExprVal 3
)
(+
12
3
)
15
>
2*5+8
ExprAdd (ExprMul (ExprVal 2
) (ExprVal 5
)) (ExprVal 8
)
(+
(*
2
5
) 8
)
18
On peut coder cela en Haskell avec la fonction run suivante.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
run ::
(String
->
Either
String
Expr) ->
IO
()
run parseExpr =
do
putStr "\n> "
hFlush stdout
input <-
getLine
when (input /=
""
) $
do
case
parseExpr input of
Left
err ->
putStrLn $
"error: "
++
err
Right
expr ->
do
print expr
putStrLn $
toLisp expr
print $
eval expr
run parseExpr
Il nous reste désormais à écrire la fonction de parsing (parseExpr) passée en paramètre. Du point de vue d’un utilisateur, parseExpr est une fonction qui prend un texte et retourne soit un message d'erreur (si le parsing a échoué), soit une représentation intermédiaire (c.-à-d. une valeur de type Expr).
II-G. Grammaire▲
Avant de détailler le parsing proprement dit, on doit préciser les « règles » que doit respecter le texte d'entrée de notre calculatrice. Pour cela, on peut utiliser la grammaire formelle suivante (en notation BNF).
2.
3.
4.
5.
6.
7.
Additive ::=
Multitive '+'
Additive
|
Multitive
Multitive ::=
Decimal '*'
Multitive
|
Decimal
Decimal ::=
Nat
Intuitivement, cela signifie que pour produire un Additive à partir du flux d'entrée, il y a deux possibilités : soit parser un Multitive puis un caractère '*'
puis un Additive (récursivement donc), soit, si la première possibilité a échoué, parser un Multitive. Le principe est le même pour les règles Multitive et Decimal.
Enfin, Nat représente les entiers naturels habituels, mais on peut également détailler cette règle.
2.
3.
4.
5.
6.
Nat ::=
Digits
Digits ::=
Digit Digits
|
Digit
Digit ::=
'0'
|
'1'
|
..
. |
'9'
III. Les parseurs récursifs descendants▲
III-A. Présentation▲
Dans la section précédente, on a vu le processus complet réalisé par notre calculatrice (et, plus généralement, par un programme qui lit un texte structuré et fait quelque chose avec les données trouvées). Il nous reste cependant à implémenter le parseur, c'est-à-dire la fonction qui lit du texte et retourne sa représentation intermédiaire (de type Expr) selon la grammaire donnée.
Avec la grammaire de notre calculatrice, le parsing revient à essayer de produire la règle Additive, ce qui nécessite de produire l'une de ses deux sous-règles, en privilégiant celle la plus à gauche. C'est typiquement ce que fait un parseur récursif descendant avec retour arrière.
Pour implémenter ce genre de parseur, il est plus facile de partir des règles de bas-niveau (ici Digit, Digits, Nat, etc.), car on peut les tester immédiatement, sans avoir besoin des règles de plus haut niveau. C'est ce qu'on va voir dans la suite de cette section.
III-B. Un type Result▲
Du point de vue d’un utilisateur, notre parseur retourne un Either
String
Expr, c'est-à-dire soit une valeur Left
avec un message d'erreur (quand le parsing a échoué), soit une valeur Right
avec l'expression arithmétique trouvée.
Pour ce qui concerne l’implémentation, on va écrire des parseurs spécifiques à chaque règle de notre grammaire. Ainsi, le parseur spécifique à une règle pourra être appelé « dans une règle de plus haut-niveau ». Pour cela, on a besoin que ces parseurs retournent un peu plus d'information, par exemple grâce au type Result vsuivant.
type
Result v =
Either
String
(v, String
)
Ainsi un parsing réussi fournit maintenant un tuple (v, String
) où le premier élément est la valeur parsée et le second élément est le reste (non-consommé) du texte à parser. On notera que Result v est un type paramétré, qui peut donc représenter les résultats des différents parseurs. Par exemple, un parseur d'entiers retournera un Result Int
alors qu'un parseur pour la règle Multitive retournera un Result Expr.
III-C. Parseurs bas-niveau▲
À partir du type Result v précédent, on peut maintenant écrire le code des différents parseurs, en partant du bas-niveau. La règle la plus basse de notre grammaire est Digit ::=
'0'
|
'1'
|
..
. |
'9'
. Pour la parser, on peut donc écrire une fonction pDigit qui prend le texte d'entrée et retourne un Result Char
.
2.
3.
4.
5.
6.
pDigit ::
String
->
Result Char
pDigit ""
=
Left
"no input"
pDigit (c:cs) =
if
isDigit c
then
Right
(c, cs)
else
Left
"not a digit"
Si le texte d'entrée est vide, on retourne le message d'erreur "no input"
. Si on a lu un caractère et que celui-ci est un chiffre, on retourne le caractère et le texte restant (dans un tuple). Enfin, si le caractère lu n'est pas un chiffre, on retourne le message d'erreur "not a digit"
.
Comme prévu, on peut tout de suite tester ce parseur, par exemple dans l'interpréteur interactif de Haskell et sur le texte d'entrée "12"
.
2.
*
Mycalc.Parser.Recursive>
pDigit "12"
Right
('1'
,"2"
)
Ici, le parsing a bien réussi : la valeur parsée est le caractère '1'
et le texte restant "2"
.
À partir de ce parseur de chiffres, on peut maintenant écrire un parseur de « nombres », en suivant la règle de grammaire Digits ::=
Digit Digits |
Digit. Cette règle correspond soit à parser un chiffre puis encore un nombre (récursivement), soit à parser juste un chiffre. Ceci revient donc à consommer tous les chiffres possibles dans le flux d'entrée.
2.
3.
4.
5.
6.
7.
pDigits ::
String
->
Result String
pDigits s0 =
case
pDigit s0 of
Left
err ->
Left
err
Right
(v1, s1) ->
case
pDigits s1 of
Right
(v2, s2) ->
Right
(v1:v2, s2)
Left
_
->
Right
([v1], s1)
La fonction pDigits est une implémentation directe de la règle de grammaire Digits. Dans le premier case
, on essaie d'abord de parser un chiffre, grâce au parseur pDigit précédent. En cas d'erreur, cela signifie que l'entrée n'a aucun chiffre et on propage donc l'erreur. En cas de succès, on a donc parsé un chiffre et on essaie alors de parser d'autres chiffres, avec l'appel récursif du deuxième case
. Si l'appel récursif a réussi, on retourne le nombre trouvé, précédé du chiffre qu'on avait déjà trouvé dans le premier case
. Si l'appel récursif a échoué, cela signifie juste que le reste de l'entrée ne commence pas par un chiffre et qu'il faut donc retourner uniquement le chiffre trouvé dans le premier case
.
On peut tester notre parseur pDigits, par exemple sur l'entrée "12"
.
2.
*
Mycalc.Parser.Recursive>
pDigits "12"
Right
("12"
,""
)
On notera que pDigits retourne une chaîne de caractères correspondant à un nombre, mais pas un type de nombres comme Int
. Pour cela, on peut écrire le parseur pNat suivant, qui appelle tout simplement pDigits et construit un Int
à partir de son résultat en cas de succès, grâce à la fonction read de Haskell.
2.
3.
4.
5.
pNat ::
String
->
Result Int
pNat s0 =
case
pDigits s0 of
Right
(v, s1) ->
Right
(read v, s1)
Left
err ->
Left
err
III-D. Parseurs de Expr▲
De la même façon que pNat retourne un Result Int
, on peut très facilement écrire un parseur pDecimal qui retourne un Result Expr (il suffit d'appeler pNat et de construire une valeur ExprVal à partir de son Int
).
Toujours selon la même méthode, on peut implémenter la règle Multitive ::=
Decimal '*'
Multitive |
Decimal. On a juste un case
de plus, car il faut également détecter le caractère '*'
.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
pMultitive ::
String
->
Result Expr
pMultitive s0 =
alt1 where
-- 1er cas : Decimal '*' Multitive
alt1 =
case
pDecimal s0 of
Right
(vleft, s1) ->
case
s1 of
('*'
:s2) ->
case
pMultitive s2 of
Right
(vright, s3) ->
Right
(ExprMul vleft vright, s3)
_
->
alt2
_
->
alt2
_
->
alt2
-- 2e cas : Decimal
alt2 =
pDecimal s0
Pour la règle Additive, on écrit un parseur pAdditive, de façon très similaire.
Finalement, on écrit la fonction de parsing principale, qui consiste juste à appeler pAdditive et à retourner uniquement les informations voulues (message d'erreur ou Expr).
IV. Notion de foncteur▲
IV-A. Rappel sur les foncteurs▲
En Haskell, un Functor
est un type « à l'intérieur duquel on peut appliquer une fonction », grâce à la fonction fmap ou à l'opérateur <$>
. Par exemple, les listes sont des Functor
.
2.
Prelude>
(*
2
) <$>
[2
, 21
]
[4
,42
]
De même, les types Either
sont des Functor
, où la fonction s'applique sur les valeurs Right
.
2.
3.
4.
5.
6.
7.
8.
9.
Prelude>
x1 =
Right
21
::
Either
String
Int
Prelude>
x2 =
Left
"foo"
::
Either
String
Int
Prelude>
(*
2
) <$>
x1
Right
42
Prelude>
(*
2
) <$>
x2
Left
"foo"
IV-B. Intérêt pour les parseurs▲
Pour rappel, le type Result v, utilisé pour écrire nos parseurs précédents, est défini de la façon suivante.
type
Result v =
Either
String
(v, String
)
C'est un Either
, donc un Functor
. On peut donc « fmapper » une fonction sur la valeur de succès d'un parseur. Par exemple, on avait écrit pDecimal de la façon suivante.
2.
3.
4.
pDecimal ::
String
->
Result Expr
pDecimal s0 =
case
pNat s0 of
Right
(v, s1) ->
Right
(ExprVal v, s1)
Left
err ->
Left
err
En fait, pDecimal est exactement pNat, mais où l'on a juste appliqué la fonction ExprVal sur le premier élément du tuple des valeurs Right
.
Comme Result v est un foncteur, on peut réécrire pDecimal plus simplement, en « fmappant » une fonction sur le résultat de pNat.
2.
pDecimal ::
String
->
Result Expr
pDecimal s0 =
(\(x,y) ->
(ExprVal x, y)) <$>
pNat s0
Pour résumer, on a donc défini un type Result v représentant le résultat d'un parsing. On peut ensuite définir des fonctions de parsing qui retournent des valeurs de ce type. Et comme Result v est un foncteur, on peut définir de nouveaux parseurs simplement en « fmappant » des fonctions sur les résultats de parseurs existants.
En fait, on peut même aller plus loin et définir un type non pas pour le résultat d'un parsing, mais pour la fonction de parsing elle-même. Et si ce nouveau type (qu'on appellera Parser) est un foncteur, on pourra de même définir de nouveaux parseurs directement en « fmappant » sur des parseurs existants. C'est cette idée qui est développée dans les sections suivantes.
V. Implémenter des parseurs par des types▲
V-A. Un type Parser▲
Comme annoncé précédemment, on peut définir, en plus du typeResult v, un type Parser v qui représente une fonction de parsing.
2.
3.
type
Result v =
Either
String
(v, String
)
newtype
Parser v =
Parser { runParser ::
String
->
Result v }
Ainsi, le type Parser v représente des valeurs Parser comportant un champ runParser. Ce champ est une fonction qui prend un String et retourne un Result v.
V-B. Exemple : parser le caractère '0'▲
Pour illustrer l'usage de notre type Parser v, on va considérer un problème simple : parser le caractère '0'
. Si l’on utilise la méthode précédente, avec les parseurs récursifs descendants, on peut écrire la fonction pZero suivante, qui prend un String
et retourne un Result Char
.
2.
3.
pZero ::
String
->
Result Char
pZero ('0'
:xs) =
Right
('0'
, xs)
pZero str =
Left
("no parse"
, str)
Mais on peut également définir un Parser Char
permettant de parser le caractère '0'
. Pour cela, il suffit de construire une valeur Parser dont le champ runParser est la fonction pZero précédente.
2.
zeroP ::
Parser Char
zeroP =
Parser { runParser =
pZero }
Pour utiliser ce parser zeroP, il suffit de récupérer son champ runParser, c'est-à-dire la fonction de parsing, et ensuite d'appliquer cette fonction de parsing sur le flux d'entrée.
2.
3.
4.
5.
>
runParser zeroP "0foobar"
Right
('0'
, "foobar"
)
>
runParser zeroP "foobar"
Left
("no parse"
, "foobar"
)
On notera qu'on peut également définir zeroP sans expliciter le nom du champ.
2.
zeroP ::
Parser Char
zeroP =
Parser pZero
En fait, on peut même écrire directement la fonction de parsing dans la définition du parseur, par exemple avec une lambda.
2.
3.
4.
5.
zeroP ::
Parser Char
zeroP =
Parser (\str ->
case
str of
('0'
:xs) =
Right
('0'
, xs)
_
=
Left
("no parse"
, str))
V-C. Parseurs de bas niveau▲
Revenons maintenant à notre calculatrice et réécrivons-la en utilisant le type Parser v. Tout d'abord, on peut écrire un parseur itemP, qui extrait simplement un caractère du flux d'entrée.
2.
3.
4.
5.
itemP ::
Parser Char
itemP =
Parser $
\s0 ->
case
s0 of
(x:xs) ->
Right
(x, xs)
[] ->
Left
"no input"
On peut ensuite écrire nos autres parseurs à partir de itemP, par exemple digitP pour parser un chiffre.
2.
3.
4.
5.
6.
7.
8.
digitP ::
Parser Char
digitP =
Parser $
\s0 ->
case
runParser itemP s0 of
Right
(c, cs) ->
if
isDigit c
then
Right
(c, cs)
else
Left
"not a digit"
Left
err ->
Left
err
Et là, c'est le drame : l'implémentation est nettement plus compliquée qu'avec les parseurs récursifs descendants. En effet, on avait écrit pDigit de la façon suivante.
2.
3.
4.
5.
6.
pDigit ::
String
->
Result Char
pDigit (c:cs) =
if
isDigit c
then
Right
(c, cs)
else
Left
"not a digit"
pDigit _
=
Left
"no input"
Le problème vient du fait que notre fonction de parsing est désormais « à l'intérieur d'un type », ce qui signifie qu'on doit extraire la fonction de parsing de itemP puis l'appliquer pour récupérer son résultat, et intégrer tout cela dans une fonction et dans une valeur de type Parser v.
Fort heureusement, on a déjà un début de solution à ce problème : les foncteurs. En effet, si le type Parser v est un foncteur, alors on peut définir des parseurs en « fmappant » une fonction sur un parseur précédent.
V-D. Les combinateurs de parseurs monadiques▲
V-E. Motivation▲
Dans la section précédente, on a défini un type Parser v pour représenter des parseurs. On a vu que ce type ne simplifiait pas à lui seul l'implémentation de nos parseurs mais qu'il pouvait servir de support à des « outils » de plus haut niveau comme les foncteurs.
En Haskell, les classes de types permettent d'implémenter des fonctionnalités communes à tout un ensemble de types, un peu comme les interfaces en POO. Par exemple, tous les types de classe Show
implémentent une fonction show; n'importe quelle valeur d'un de ces types peut donc être passée en argument à la fonction print (qui est définie par print x =
putStrLn (show x)).
Par conséquent, si on instancie la classe Functor pour notre type Parser v, alors on pourra utiliser fmap pour définir nos parseurs plus simplement. De plus, il existe d'autres classes de types qui peuvent être intéressantes pour manipuler des parseurs : Applicative, Monad
, Alternative.
V-F. Classes instanciables pour le type Parser▲
On a déjà parlé de la classe Functor
, qui permet d'appliquer une fonction « à l'intérieur du type ». Pour cela, elle définit la fonction fmap ou, de façon équivalente, l'opérateur <$>
.
(<$>
) ::
Functor
f =>
(a ->
b) ->
f a ->
f b
Ainsi, si l’on instancie cette classe pour notre type Parser v, on peut, par exemple, définir un parseur de Int
à partir d'un parseur de chiffre, de la façon suivante (digitToInt retourne l'entier correspondant à un chiffre).
2.
3.
4.
5.
digitP ::
Parser Char
digitP =
..
.
intP ::
Parser Int
intP =
digitToInt <$>
digitP
La classe Applicative représente les foncteurs applicatifs. L'opérateur <*>
est assez similaire à <$>
, mais avec une fonction qui est elle-même « à l'intérieur du type ». Concrètement, cela permet de « fmapper » des fonctions à plusieurs paramètres. Applicative définit également la fonction pure qui « met à l'intérieur du type ».
2.
3.
(<*>
) ::
Applicative f =>
f (a ->
b) ->
f a ->
f b
pure ::
Applicative f =>
a ->
f a
Dans notre contexte de parseurs, si l’on reprend le parseur intP précédent, on peut définir un nouveau parseur qui parse deux chiffres, les convertit en Int
et retourne leur somme, de la façon suivante.
2.
addP ::
Parser Int
addP =
(+
) <$>
intP <*>
intP
La classe Alternative correspond aux « foncteurs avec une structure de monoïde ». Plus concrètement, il s'agit de l'opérateur d'alternative <|>
et de l'élément neutre empty.
2.
3.
(<|>
) ::
Alternative f =>
f a ->
f a ->
f a
empty ::
Alternative f =>
f a
Dans le contexte des parseurs, il s'agit d'une classe intéressante, car elle permet d'essayer un parseur et, en cas d'échec, d'en essayer un autre. Par exemple, si zeroP parse le caractère '0'
et unP le caractère '1'
, alors on peut définir le parseur zeroOuUnP qui parse '0'
ou sinon '1'
, de la façon suivante.
2.
3.
4.
5.
6.
7.
8.
zeroP ::
Parser Char
zeroP =
..
.
unP ::
Parser Char
unP =
..
.
zeroOuUnP ::
Parser Char
zeroOuUnP =
zeroP <|>
unP
Enfin la classe Monad
permet de représenter une « suite d'actions ». Elle comporte l'opérateur >>=
, qui permet de chaîner des actions et la fonction return, qui est l'équivalent de pure, mais dans le contexte des monades. Généralement, on définit également une fonction fail qui permet d'interrompre une chaîne d'actions, avec un message d'erreur.
2.
3.
4.
5.
(>>=
) ::
Monad
m =>
m a ->
(a ->
m b) ->
m b
return ::
Monad
m =>
a ->
m a
fail ::
Monad
m =>
String
->
m a
Par exemple, on peut écrire le parseur addP précédent dans un style monadique, de la façon suivante.
2.
3.
4.
addP ::
Parser Int
addP =
intP
>>=
\i1 ->
intP
>>=
\i2 ->
return (i1 +
i2)
Avec les monades, on peut également utiliser la notation do
, qui est généralement plus lisible.
2.
3.
4.
5.
addP ::
Parser Int
addP =
do
i1 <-
intP
i2 <-
intP
return (i1 +
i2)
V-G. Utilisation pour nos parseurs▲
Les classes de types précédentes permettent de simplifier l'écriture de nos parseurs. Ainsi le parseur pDigit était initialement :
2.
3.
4.
5.
6.
pDigit ::
String
->
Result Char
pDigit (c:cs) =
if
isDigit c
then
Right
(c, cs)
else
Left
"not a digit"
pDigit _
=
Left
"no input"
En style monadique, on peut écrire le parseur digitP équivalent :
2.
3.
4.
5.
6.
digitP ::
Parser Char
digitP =
do
c <-
itemP
if
isDigit c
then
return c
else
fail "not a digit"
Pour pDigits, on avait :
2.
3.
4.
5.
6.
pDigits ::
String
->
Result String
pDigits s0 =
case
pDigit s0 of
Right
(v1, s1) ->
case
pDigits s1 of
Right
(v2, s2) ->
Right
(v1:v2, s2)
Left
_
->
Right
([v1], s1)
Left
err ->
Left
err
que l'on peut réécrire en style monadique :
2.
3.
4.
5.
digitsP ::
Parser String
digitsP =
do
c <-
digitP
cs <-
(digitsP <|>
return ""
)
return (c:cs)
ou encore, en style « foncteur applicatif » :
2.
digitsP ::
Parser String
digitsP =
(:
) <$>
digitP <*>
(digitsP <|>
return ""
)
et même, en utilisant la fonction some du module Control.Applicative :
2.
digitsP ::
Parser String
digitsP =
some digitP
Ce qui est clairement plus simple que la première implémentation pDigits.
De même, pour pMultitive, on avait :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
pMultitive ::
String
->
Result Expr
pMultitive s0 =
alt1 where
alt1 =
case
pDecimal s0 of
Right
(vleft, s1) ->
case
s1 of
('*'
:s2) ->
case
pMultitive s2 of
Right
(vright, s3) ->
Right
(ExprMul vleft vright, s3)
_
->
alt2
_
->
alt2
_
->
alt2
alt2 =
pDecimal s0
Avec le type Parser v et en style monadique, on obtient :
2.
3.
4.
5.
6.
7.
multitiveP ::
Parser Expr
multitiveP
=
(do
e1 <-
decimalP
_
<-
charP '*'
e2 <-
multitiveP
return $
ExprMul e1 e2)
<|>
decimalP
Ou encore, en style « applicatif » :
2.
3.
4.
multitiveP ::
Parser Expr
multitiveP
=
(ExprMul <$>
decimalP <*>
(charP '*'
*>
multitiveP))
<|>
decimalP
V-H. Implémentation des instances de classes▲
Dans les sections précédentes, on a vu qu'on pouvait définir un type Parser v, que ce type pouvait être de classe Functor
, Applicative, Alternative ou Monad
, et que ceci permettait de simplifier grandement l'écriture de nos parseurs.
Cependant, on a passé sous silence l'instanciation de ces classes pour le type Parser v, c'est-à-dire comment écrire concrètement les fonctions et opérateurs correspondants (<$>
, <*>
, pure, etc.). Ceci est donné ci-dessous à titre informatif, mais ne sera pas détaillé, car c'est assez technique et à ne faire qu’une fois pour toutes avec le type Parser v tel qu'on l'a défini.
2.
3.
4.
5.
6.
instance
Functor
Parser where
fmap fct (Parser p) =
Parser $
\s0 ->
do
(x, s1) <-
p s0
return (fct x, s1)
2.
3.
4.
5.
6.
7.
8.
9.
instance
Applicative Parser where
pure x =
Parser $
\s ->
Right
(x, s)
(Parser p1) <*>
(Parser p2) =
Parser $
\s0 ->
do
(fct, s1) <-
p1 s0
(x, s2) <-
p2 s1
return (fct x, s2)
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
instance
Alternative (Either
String
) where
empty =
Left
"empty"
Left
_
<|>
e2 =
e2
e1 <|>
_
=
e1
instance
Alternative Parser where
empty =
Parser $
const empty
(Parser p1) <|>
(Parser p2)
=
Parser $
\input ->
p1 input <|>
p2 input
2.
3.
4.
5.
6.
7.
8.
9.
instance
Monad
Parser where
(Parser p) >>=
fct =
Parser $
\s0 ->
do
(x1, s1) <-
p s0
(x2, s2) <-
runParser (fct x1) s1
return (x2, s2)
fail msg =
Parser $
\_
->
Left
msg
VI. Les bibliothèques « à la Parsec »▲
Il existe de nombreuses bibliothèques Haskell implémentant les combinateurs de parseurs monadiques : parsec, attoparsec, megaparsec… Concrètement, ces bibliothèques implémentent un type Parser avec les instances de classes correspondantes, des parseurs de base et enfin quelques fonctions pour lancer le parsing global, gérer les erreurs, etc.
Cette dernière section explique comment écrire le parseur de notre calculatrice avec ReadP.
VI-A. Le module ReadP▲
ReadP est un module de la bibliothèque base, qui ne nécessite donc aucune installation particulière. Il suffit d'importer le module Text.ParserCombinators.ReadP puis d'utiliser le type ReadP et les fonctions fournies (voir la documentation de ReadP).
VI-B. Exemple d'utilisation▲
En utilisant ReadP, on peut implémenter très facilement le parseur de notre calculatrice. Pour les parseurs « bas niveau » de notre grammaire, on utilise satisfy (qui contruit un parseur à partir d'un prédicat sur un Char) et many1 (qui réalise une ou plusieurs occurrences d'un parseur donné).
2.
3.
4.
5.
6.
7.
8.
digitP ::
ReadP Char
digitP =
satisfy isDigit
digitsP ::
ReadP String
digitsP =
many1 digitP
natP ::
ReadP Int
natP =
read <$>
digitsP
Enfin, pour les règles de plus haut niveau, on utilise simplement les parseurs précédents et les opérateurs classiques de Functor
, Applicative, Alternative et Monad
.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
decimalP ::
ReadP Expr
decimalP =
ExprVal <$>
natP
multitiveP ::
ReadP Expr
multitiveP -- en style applicatif
=
(ExprMul <$>
decimalP <*>
(char '*'
*>
multitiveP))
<|>
decimalP
additiveP ::
ReadP Expr
additiveP -- en style monadique
=
do
e1 <-
multitiveP
_
<-
char '+'
e2 <-
additiveP
return $
ExprAdd e1 e2
<|>
multitiveP
Comme on peut le constater, si les concepts et implémentations des combinateurs de parseurs monadiques demandent un peu de réflexion, leur utilisation concrète est plutôt très simple.
VII. Conclusion▲
Dans ce tutoriel, on a abordé les combinateurs de parseurs monadiques à partir d'un exemple de calculatrice basique en Haskell. Il s'agit du problème classique de parsing, c'est-à-dire comment analyser un texte structuré selon des règles précises (expression arithmétique, document XML, code source en C…). En première approche, un parseur est une fonction qui prend un texte et retourne une valeur d'un type attendu (Char, Int… ou plus généralement un type représentant les données que l'on veut manipuler).
En seconde approche, on a vu qu'on pouvait également représenter un parseur par un type algébrique et en instancier des classes de types comme Functor, Monad, etc. Ainsi, un parseur peut être vu comme une action qui retourne une valeur, dans un contexte de parsing (c'est-à-dire avec un flux de texte que l'on peut consommer). En pratique, on peut définir et combiner des parseurs, de façon simple et élégante, jusqu'à obtenir le parseur complet voulu.
Les combinateurs de parseurs monadiques peuvent s'implémenter dans différents langages de programmation. En Haskell, cette implémentation est assez naturelle et de nombreuses bibliothèques sont effectivement proposées et couramment utilisées. Par exemple, le projet bond est un framework de manipulation de données structurées, largement développé et utilisé par Microsoft, et dont le compilateur est écrit en Haskell (grâce à la bibliothèque Megaparsec).
Certains développeurs Haskell sont même plus prosélytes et vont jusqu'à dire que « Haskell est un langage incroyable pour écrire votre propre compilateur. Si vous écrivez un compilateur dans un autre langage, vous devriez vraiment songer à changer. » Gabriel Gonzales, State of the Haskell ecosystem.
VIII. Quelques références▲
- Graham Hutton and Erik Meijer, Monadic parser combinators, 1996 : http://www.cs.nott.ac.uk/~pszgmh/bib.html#monparsing
- Graham Hutton, Functional Parsing, 2020 : https://www.youtube.com/watch?v=dDtZLm7HIJs
- Tsodin, JSON Parser 100% From Scratch in Haskell, 2019 : https://www.youtube.com/watch?v=N9RUqGYuGfw
- Haskell basic libraries, Text.ParserCombinators.ReadP : https://hackage.haskell.org/package/base/docs/Text-ParserCombinators-ReadP.html
IX. Note de la rédaction de Developpez.com▲
Nous tenons à remercier escartefigue pour la relecture orthographique de ce tutoriel.