Cours 5

Htmligure
Version & licenses
Creative Commons License

Un parseur d'expressions arithmétiques

Guyslain
Thursday, 21 July 2016

Retour au sommaire.

Nous terminons avec un exemple plus complet. Ce serait ambitieux de vouloir parser un langage de programmation complet, on va donc se contenter d'un sous-langage contenant déjà les principales difficultés : les expressions arithmétiques. Nous allons aussi utiliser des combinateurs un peu plus complexes, notamment capable de reporter plus précisément les erreurs, en indiquant quels règles ont été utilisées jusqu'à la situation d'échec.

Nous allons donc considérer les expressions arithmétiques, construites comme des sommes et des produits d'entiers et de variables. Nous pourrions considérer plus d'opérateurs, des nombres flottants, des fonctions, mais cela ne changerait pas fondamentalement le problème.

Commençons par donner une représentation des expressions arithmétiques. Cette représentation servira d'arbre abstrait pour le langage des expressions arithmétiques. Les arbres abstraits sont en général représentés en OCaml pour un type algébrique inductif :

  1. type expr =
  2. | Litteral of int
  3. | Variable of string
  4. | Sum of expr * expr
  5. | Product of expr * expr

Nous allons avoir besoin de la fonction de conversion des listes de caractères vers les chaînes de caractères :

  1. let string_of_list = Core.Std.String.of_char_list
  2. (* or *)
  3. let string_of_list chars =
  4. chars
  5. |> List.map (StringLabels.make 1)
  6. |> String.concat ""

On définit ensuite les actions qui seront appliquées à chaque règle de la grammaire. Le produit et la somme prennent un argument qui correspond à l'opérateur arithmétique, mais cet argument n'est pas utilisé.

  1. let make_litteral i = Litteral i
  2. let make_variable str = Variable str
  3. let make_product left _ right = Product (left,right)
  4. let make_sum left _ right = Sum (left, right)

Nous allons, par convénience et pour un maximum de lisibilité, définir des règles pour les terminaux correspondant aux symboles utilisés dans les expressions arithmétiques : les opérateurs et les parenthèses. On donne un nom à chaque règle de dérivation, pour avoir des explications les plus complètes possibles lors de la détection d'une erreur.

  1. let times = rule "product_operator" (s "*")
  2. let plus = rule "sum operator" (s "+")
  3. let lparen = rule "open parenthesis" (s "(")
  4. let rparen = rule "close parenthesis" (s ")")

Nous passons maintenant au cœur de la grammaire. Les littéraux entiers seront traitables directement avec le parseur d'entiers défini plus tôt. Nous devons donc faire un parseur pour les variables, qui sont des séquences des lettres et de chiffres, commençant par une lettre :

  1. let variable =
  2. (fun letter word -> string_of_list (letter :: word))
  3. *> Char.lowercase
  4. <*> sequence Char.alphanumeric

Nous avons les éléments principaux pour mettre en place la grammaire du langage. Il reste néanmoins une difficulté : gérer les règles de parenthésages, les précédences entre opérateurs. Une façon classique de le faire est décrire la grammaire en séparant les expressions générales, les termes (qui sont sommés) et les facteurs (qui sont multipliés). On obtient alors trois non-terminaux avec plusieurs règles de dérivation pour chacun.

  1. let rec expr () =
  2. fix @@ fun expr ->
  3. rule "sum" (make_sum *> term () <+> plus <+> expr)
  4. || rule "term" (term ())
  5. and term () =
  6. fix @@ fun term ->
  7. rule "product" (make_product *> factor () <+> times <+> term)
  8. || rule "factor" (factor ())
  9. and factor () =
  10. fix @@ fun factor ->
  11. rule "litteral" (make_litteral *> int)
  12. || rule "variable" (make_variable *> variable)
  13. || rule "parenthese" ((fun _ e _ -> e) *> lparen <+> expr () <+> rparen)

Un souci est que toutes ces règles sont mutuellement récursives, et OCaml n'autorise pas certaines formes de récursions. Toute valeur récursive doit soit être une fonction (donc prend un argument), soit commencer par un constructeur. Ici, on choisit d'ajouter un argument inutile à chacune des fonctions pour satisfaire à la règle. Hormis ce détail technique, nous obtenons bien une grammaire. Chaque règle est introduite en lui donnant un nom, puis une action et une séquence de non-terminaux.

Faisons quelques tests, essayons d'abord de lire une expression correcte :

  1. # let res =
  2. "1 + 2 * (foo + 3 * bar)"
  3. |> TextInput.of_string
  4. |> run (expr ());;
  5. val res : expr Parco.result = <abstr>
  6. # let _ = Result.get_value res;;
  7. - : expr option =
  8. Some
  9. (Sum (Litteral 1,
  10. Product (Litteral 2,
  11. Sum (Variable "foo", Product (Litteral 3, Variable "bar")))))

Impeccable. Essayons maintenant avec une expression mal-formée :

  1. # let res =
  2. "1 + 2 * (foo 4 + 3 * bar)"
  3. |> TextInput.of_string
  4. |> run (expr ());;
  5. val res : expr Parco.result = <abstr>

  6. # let _ = Result.get_value res;;
  7. - : expr option = Some (Sum (Litteral 1, Litteral 2))

  8. # let () = show_stack res;;
  9. expecting character ): at 1:15
  10. close parenthesis: at 1:14
  11. parenthese: at 1:9
  12. factor: at 1:9
  13. product: at 1:5
  14. term: at 1:5
  15. sum: at 1:1

L'erreur est détecté au 15 e caractère. Très précisément, il nous dit que le parseur a utilisé la règle sum à partir du caractère 1 , la règle d'un terme à partir du caractère 4 , etc, et il attend donc une parenthèse fermante au caractère 15 , à la place du 4 . Il a bien repéré l'emplacement de l'erreur de syntaxe, mais surtout il nous indique précisément comment il a compris le texte donné.

La fonction show_stack utilisée est la suivante :

  1. let show_stack_item (string, pos) =
  2. let open TextInput.Position in
  3. Printf.printf "%s: at %d:%d\n%!" string pos.line pos.column

  4. let show_stack res =
  5. match Result.get_stack res with
  6. | None -> Printf.printf "None.\n%!"
  7. | Some list -> list |> List.rev |> List.iter show_stack_item

L'ensemble du programme est dans cette archive.

Retour au sommaire.