Quantcast
Channel: Publicis Sapient Engineering – Engineering Done Right
Viewing all articles
Browse latest Browse all 1865

[FastParse] Les Parseurs Combinatoires ou l’art de créer un analyseur syntaxique

$
0
0
[FastParse] Les Parseurs Combinatoires ou l’art de créer un analyseur syntaxique

Introduction

Le développement d’une application peut souvent se décomposer en 3 étapes :

  1. Lecture de paramètres ou données
  2. Cœur du traitement
  3. Mise à disposition des résultats

Lors de cet article, nous nous pencherons sur la première partie et plus précisément sur la lecture de données.

Une lecture de fichier, par exemple, est souvent faite suivant un schéma. Cette phase est souvent qualifiée de parsing de données. En effet, sans ce schéma nous ne saurions comment traiter les informations entrantes dans l’application.

Afin de parser les données, 3 choix s’offrent à nous :

  1. Lire le texte de manière brute et séparer le texte suivant un séparateur précisé en paramètre.
  2. Utiliser des expressions régulières (regex)
  3. Utiliser les parseurs combinatoires (parser combinators en anglais)

L’utilisation de la première méthode est fonctionnelle mais offre très peu de flexibilité. La seconde (les regex), est quant à elle beaucoup plus flexible. Seulement, pour peu que vous ayez manipulé suffisamment de regex, vous savez qu’au bout d’un moment elles peuvent être extrêmement complexe, difficile à lire et donc à maintenir.

Enfin, pour la troisième approche nous la détaillerons dans cette article.

Dans la suite de cet article, nous choisirons Scala comme langage de démonstration.

Qu’est-ce qu’un Parseur Combinatoire

Assez simplement, un parseur, P par exemple, est une fonction qui prend un input et est capable de valider la structure de cet input en le comparant à une grammaire préalablement définie.

Si, un parseur P reconnaît la chaine de caractère “select”. Son utilisation devrait donner quelque chose comme ceci :

val a = "select"
val b = "test"
P(a)
> true

P(b)
> false

Notre parseur reconnaît notre chaine de caractère contenu dans a et rejette celle dans b.

A présent, considérons un parseur P2 capable de reconnaitre la chaine de caractère “name”. Son fonctionnement sera le même que le parseur P.

Maintenant qu’on sait reconnaitre “select” et “name” séparément on aimerait bien pouvoir reconnaitre “select name”, en combinant les deux parseurs que nous avons déjà créés et c’est la que les parseurs combinatoires entre en jeux.

Un parseur combinatoire est une fonction de second ordre qui prend en paramètres plusieurs parseurs en entrées et retourne un nouveau parseur en sortie.

Une fonction de second ordre, est une fonction qui accepte une autre fonction en paramètre. Si ce sujet vous intéresse vous pouvez lire la série d’article sur la programmation fonctionnelle disponible dans le blog.

Parseur combinatoire

Comme fil rouge, nous construirons ensemble un parseur capable de reconnaitre une instruction SQL de base telle que "SELECT * FROM nom_table”. Pour ce faire, nous utiliserons une bibliothèque FastParse qui met à disposition toute la mécanique de manipulation des parseurs combinators.

D’abord, définissons nos différents parseurs pour cet exemple:

  1. Un premier parseur pour reconnaitre exactement la chaine de caractères “select” ;
  2. Un second, pour reconnaitre exactement “*” ;
  3. Ensuite, un parseur pour reconnaitre exactement la chaine de charactère “from”;
  4. Puis, un parseur pour reconnaitre “nom_table” qui sera une suite alphabétique.
  5. Enfin, un parseur pour reconnaitre un espace.

Avec la librairie FastParse:

Les parseurs permettant de reconnaître les chaînes de caractères brutes de notre exemple sont les suivants :

def select[_: P]: P[Unit] = P("select")
def star[_: P]: P[Unit] = P("*")
def from[_: P]: P[Unit] = P("from")

Le parseur, pour reconnaitre un nom de table qui peut être une chaine de caractère aléatoire :

def tablename[_: P]: P[Unit] = P(CharIn("a-zA-Z").rep)

Dans ce cas ci, sachez que FastParse est capable de reconnaitre des regex grâce à la fonction CharIn. Ici, nous lui demandons de reconnaitre n’importe quel caractère entre a et z inclus ainsi que leur équivalent en majuscule. De plus, nous avons appelé la fonction rep de la librairie FastParse pour indiquer que la chaine de caractères sur laquelle elle est appliquée peut se répéter.

Enfin, un parseur pour reconnaitre un espace :

def space[_: P]: P[Unit] = P(" ".rep)

Compte tenu du fait que les utilisateurs peuvent rentrer “select” ou même “SELECT” nous devrions nous parer à ces éventualités. Heureusement, FastParse fournit une fonction nommée IgnoreCase pour … ignorer la casse. En l’utilisant, nous obtenons ceci :

def select[_: P]: P[Unit] = P(IgnoreCase("select"))

def star[_: P]: P[Unit] = P("*")

def from[_: P]: P[Unit] = P(IgnoreCase("from"))

def tablename[_: P]: P[Unit] = P(CharIn("a-zA-Z").rep)

def space[_: P]: P[Unit] = P(" ".rep)

Un point d’attention, ces parseurs retournent tous un parseur de type Unit or dans certains cas, typiquement pour le parseur tablename nous souhaiterons récupérer la valeur lue afin d’interroger la bonne table. Pour ce faire, vous l’aurez deviné, FastParse a également un opérateur ! permettant d’indiquer que nous souhaitons récupérer la valeur lue :

def tablename[_: P]: P[String] = P(CharIn("a-zA-Z").rep).!
  1. Le type du parseur tablename est maintenant passé à P[String] pour indiquer qu’il renvoie une chaine de caractères à présent.

Nous pouvons enfin créer notre parseur qui sera une combinaison de ces parseurs pour reconnaître notre chaine de caractères.

def sqlParser[_: P]: P[String] = P(select ~ space ~ star ~ space ~ from ~ space ~ tablename ~ space ~ End)

Il y a ici 2 choses à noter :

  1. l’opérateur ~ permet de combiner les parseurs
  2. le parseur End vérifie qu’il n’y a aucun caractère qui suit la chaine parsée.

Ce qui est fréquent dans les instructions SQL c’est qu’elles se terminent par un ; . Ici, nous pouvons le rendre facultatif pour plus de flexibilité. Il suffit de créer un parseur pour un point-virgule et de l’ajouter à notre parseur final.

def semicolon[_: P]: P[Unit] = P(";")

Le parseur final devient :

def sqlParser[_: P]: P[String] = P(select ~ space ~ star ~ space ~ from ~ space ~ tablename ~ space.? ~ semicolon.? ~ End)

Ici, l’opérateur ? indique que le parseur semicolon et le dernier parseur space sont facultatifs.

Notre parseur est fin prêt à être utiliser. Pour ce faire, la fonction parse est disponible toujours dans la librairie FastParse. Elle prend en entrée la chaine de caractères à parser et le parseur à utiliser pour parser la chaine et elle renvoie soit Parsed.Success(a, b) avec a étant la valeur renvoyée par le parseur et b le nombre de caractère parsés soit Parsed.Failure(err) avec err représentant l’erreur de parsing.

Sachant cela, son utilisation est toute simple :

parse("select * FROM persons", Parser.sqlParser(_))
> Parsed.Success(persons, 21)

Essayons avec un point-virgule :

parse("select * FROM persons ;", Parser.sqlParser(_))
> Parsed.Success(persons, 23)

Dans le cas où l’on fournit une chaine de caractères non reconnue nous obtenons une erreur :

parse("select *;", Parser.sqlParser(_))
> Parsed.Failure(Position 1:9, found ";")

Conclusion

Dans cet article nous avons pu voir comment manipuler des parseurs combinatoires assez simplement avec la puissance de la librairie FastParse.

Notre exemple terre à terre peut toutefois être amélioré de plusieurs façons, notamment en permettant de renseigner une liste de colonnes au lieu de * ou des filtres, de faire des jointures, etc.

Si vous souhaitez vous entrainer afin de mieux maitriser ce puissant outil vous pouvez le faire en guise d’exercice. La librairie FastParse étant bien documentée vous y trouverez toutes les informations nécessaires. Sachez toutefois qu’il en existe plein d’autres dans plusieurs langages de programmation différents.

Pour la suite, si vous avez des questions, n’hésitez pas à les poster en commentaire.


Viewing all articles
Browse latest Browse all 1865

Trending Articles