Cours 5

Htmligure
Version & licenses
Creative Commons License

Combinateurs de parsing

Guyslain
Thursday, 21 July 2016

Retour au sommaire.

Les combinateurs de lecture.

Quels sont les combinateurs dont nous avons besoin pour pouvoir écrire des grammaires ? D'évidence, les grammaires sont formés de terminaux, de non-terminaux, et pour chaque non-terminal, une alternative entre plusieurs règles, pour chaque règle une action et une suite de terminaux et de non-terminaux. Il serait tentant, mais maladroit, d'introduire un type pour chacun de ces objets. Les terminaux et les non-terminaux correspondent à des valeurs à lire dans la séquence de caractères, ce sont donc des valeurs de notre type reader. Les suites de terminaux et non-terminaux de chaque règle sont par définition des combinaisons de terminaux et non-terminaux, il faut donc un opérateur de séquencement. Chaque non-terminal peut avoir plusieurs règles, il nous faut donc aussi un opérateur d'alternative. Quand à l'action, elle s'applique à une séquence, ou peut-être à un parseur, mais on peut légitimement penser qu'elle va être réalisée par un combinateur aussi.

L'alternative.

L'alternative est le combinateur le plus simple. Il doit permettre un choix entre deux façons de parser, nous n'avons donc pas vraiment le choix pour son type :

  1. val or_else : 'value reader -> 'value reader -> 'value reader

On est bien parti pour avoir un monoïde avec un tel opérateur binaire. Il est associatif, il nous manque donc un neutre. Le neutre du ou est non, le neutre est donc le lecteur qui échoue tout le temps.

  1. val fail : 'value reader

Passons à l'implémentation. or_else essaie le premier lecteur, s'il échoue il essaie le deuxième. 'value reader est TextInput.t -> ('value * TextInput.t) option on peut donc donner en troisième argument à or_else l'entrée de type TextInput.t.

  1. let or_else (reader1: 'value reader) (reader2: 'value reader) : 'value reader =
  2. fun input ->
  3. match reader1 input with
  4. | Some result -> Some result
  5. | None -> reader2 input

  6. let (||) = or_else

On lit avec le premier lecteur, s'il échoue on lit avec le second. Dans une version plus avancée, il faudrait dans le cas où les deux échouent choisir de retourner celui qui échoue le plus loin dans la lecture.

fail est un lecteur, donc une fonction prenant une entrée. Elle échoue toujours, autrement dit elle retourne toujours None.

  1. let fail : 'any reader = fun input -> None

Le séquencement.

Le séquencement consiste à utiliser un lecteur pour lire une première valeur, puis utiliser un second lecteur pour lire une deuxième valeur, et produire un résultat à partir de ces deux valeurs. La principale interrogation est : quelle valeur retournée, en fonction des deux valeurs lues ? Une solution naïve serait de former un couple. Techniquement c'est réalisable, mais c'est une mauvaise idée. Potentiellement les séquences de lecteurs que nous pouvons vouloir écrire peuvent être arbitrairement longues. En couplant les résultats, sur une longue séquence de lecteurs, on va obtenir une longue imbrication de couples de couples de couples... La valeur obtenue au final ne sera pas pratique à utiliser.

La bonne façon de faire est la suivante. L'opération de base lorsqu'on a deux valeurs en programmation fonctionnelle, c'est d'appliquer l'une à l'autre. Autrement dit, on va supposer que la première valeur lue est une fonction, et que la deuxième est son argument. Cela peut paraître absurde car il n'y a pas de raisons que la première valeur que l'on souhaite lire soit une fonction, et il va effectivement falloir résoudre ce problème. Commençons néanmoins par écrire le combinateur.

Quel est son type ? On prend un lecteur d'une fonction, un lecteur d'un argument pour la fonction et cela nous donne un lecteur d'une image de la fonction.

  1. val apply : ('arg -> 'im) reader -> 'arg reader -> 'im reader

Si la lecture de la fonction ou de l'argument échoue, la lecture de la séquence échoue. Sinon, les deux résultats sont combinés par une application et l'image obtenue est retournée. Comme précédemment, on cherche à construire un lecteur donc nous avons un troisième argument, l'entrée.

  1. let apply (fct_reader: ('arg -> 'im) reader) (arg_reader: 'arg reader)
  2. : 'im reader =
  3. fun input ->
  4. let open Option in
  5. fct_reader input >>= fun (fct,remaining_input) ->
  6. arg_reader remaining_input >>= fun (arg,final_input) ->
  7. Some (fct arg, final_input)

  8. let (<*>) = apply

On utilise ici (>>=) qui permet d'appliquer une fonction au contenu d'une option, du moment que la fonction elle-même retourne une option. Autrement dit, (fct,remaining) est le contenu de l'option obtenue par fct_reader (si cette option est définie) et similairement (arg,final_input) est le contenu de l'option obtenue par arg_reader remaining_input.

L'avantage d'utiliser ce combinateur, qui combine les valeurs lues en applicant la deuxième comme argument de la première, est la bonne composabilité. Par exemple, si fct_reader : int -> int -> int et int_reader : int reader, on peut écrire :

  1. (fct_reader <*> int_reader <*> int_reader) : int_reader

On peut donc lire une fonction à $n$ arguments puis séquencer avec $n$ lecteurs, un par argument, tout cela simplement avec le combinateur (<*>).

Reste à résoudre la question de comment lire une fonction. En fait c'est très simple : en général, les fonctions ne viendront pas de la séquence de caractères à lire, mais du programme. On va donc faire semblant de les lire, avec une fonction construisant un lecteur à partir d'une valeur : ce lecteur consiste simplement à ne rien lire mais retourner sa valeur.

  1. let return : 'value -> 'value reader =
  2. fun value input -> Some (value,input)

On peut maintenant écrire :

  1. (return fun i j k -> i + j * k) <*> int_reader <*> int_reader <*> int_reader

Il est même possible de simplifier le return avec le premier (<*>) :

  1. let ( *> ) : ('arg -> 'im) -> 'arg reader -> 'im reader
  2. = fun fct reader -> return fct <*> reader

  3. let rule =
  4. (fun i j k -> i + j * k) *> int_reader <*> int_reader <*> int_reader

(*>) n'est en fait rien d'autre que map (on peut interpréter cela comme une confirmation que cette façon de faire est la bonne). Et on a donc maintenant une jolie syntaxe pour ajouter des règles de dérivation à une grammaire.

Les terminaux.

Il nous manque une seule chose : de pouvoir lire des terminaux. Le plus basique est de lire des caractères, les autres terminaux pourront ensuite s'écrire par des grammaires en utilisant les caractères comme terminaux. Le plus simple est d'avoir plusieurs façons de lire les caractères :

  • lire n'importe quel caractère,
  • lire un caractère précis,
  • lire un caractère parmi un ensemble de caractères possibles (par exemple lire un chiffre)

Pour lire un caractère arbitraire, il suffit d'observer l'entrée et d'agir en fonction.

  1. let char : char reader = fun input ->
  2. match TextInput.observe input with
  3. | None -> None
  4. | Some ((char,position),remaining_input) -> Some (char,remaining_input)

Les deux autres cas se construisent en rajoutant une condition sur la lecture. En fait, on peut vouloir mettre des conditions sur les lecteurs de façon plus générale, autrement dit avoir un combinateur de cette forme :

  1. val satisfying : condition:('value -> bool) -> 'value reader -> 'value reader

C'est assez facile : on lit, on vérifie la condition, sinon on retourne None.

  1. let satisfying ~condition (reader: 'value reader) : 'value reader = fun input ->
  2. match reader input with
  3. | Some (result,remaining) as output when condition result -> output
  4. | Some (bad,_) -> None
  5. | None -> None

  6. let (@?) condition reader = satisfying ~condition reader

  7. let this_char (c:char) : char reader =
  8. char |> satisfying ~condition:(fun c' -> c = c')

  9. let char_satisfying (char_set:char -> bool) : char reader =
  10. char |> satisfying ~condition:char_set

Les deux autres lecteurs de caractères s'en déduisent facilement.

Retour au sommaire.