Nous allons illustrer les fonctions précédentes au moyen d'un petit interpréteur pour un langage très simple, capable de faire des opérations arithmétiques sur des entiers. C'est pas la joie, c'est sûr, mais c'est un début ! En réalité, nous n'allons même pas décrire la façon d'analyser un code source pour le transformer en programme, mais partir d'une expression OCaml toute faite. Cette partie (appelée « analyse syntaxique ») ne nous intéresse pas ici, et demanderait trop de temps pour être détaillée. Si cela vous intéresse, vous pourrez toujours lire
un tutoriel qui en parle.
Un interpréteur naïf
Type de données
Notre langage sera donc décrit directement en OCaml, à l'aide de types sommes, une fois de plus. Un tel type correspond à ce que l'on nomme « syntaxe abstraite » du langage que nous décrivons : c'est une façon de représenter les programmes du langage en étant « assez proche » de la syntaxe, sans pour autant s'embarrasser de phases d'analyse syntaxique. En gros, pour représenter l'opération
6 * (5 + 7), on manipulera une expression OCaml du style de
Mul (Const 5, Add (Const 5, Const 7)).
Une expression arithmétique va alors être soit une constante (un entier), soit une opération binaire (addition, multiplication, soustraction ou division). Voici un (début de) type qui traduit ces différentes possibilités :
Code : OCaml | type expr =
| Const of int
| Add of expr * expr
| …
|
Exercice : Complétez cette déclaration de type, avec les constructeurs
Mul,
Sub et
Div.
Fonction d'évaluation
Une fois le type défini, il nous faut une fonction d'évaluation. Celle-ci devra recevoir une
expr, et produire un
int (du moins pour l'instant). Elle devra être récursive : pour évaluer une branche
Add, nous devons calculer les deux sous-expressions portées par celle-ci !
Ici encore, voici le début :
Code : OCaml | let rec eval e = match e with
| Const n -> n
| Add (e1, e2) ->
let n1 = eval e1 in
let n2 = eval e2 in
n1 + n2
| …
|
Exercice : Vous savez ce que vous avez à faire. Cependant, inutile (pour l'instant) de compléter toutes les branches — contentez-vous de l'opération
Div. Testez ensuite votre fonction sur des petits codes à base d'
Add et de
Div, et, en particulier, essayez de diviser par 0.
Une réécriture bénigne
Nous le voyons bien lorsque nous utilisons l'interpréteur OCaml (en ligne de commande, donc) pour écrire des bêtises :
une erreur au niveau du langage interprété ne doit pas se traduire par un arrêt de l'interpréteur. Celui-ci, loin de perdre son sang-froid, doit signaler sa bourde à l'utilisateur, pour qu'il puisse la corriger.
Nous devons donc faire de même : ici, la seule erreur possible est (pour le moment) la division par zéro, et, comme nous ne savons rien faire d'autre, nous allons utiliser un type option. Plus précisément, notre fonction
eval va désormais renvoyer un
int option, indiquant son éventuel échec.
Pour comprendre quelles modifications apporter à notre code, nous allons supposer pendant un moment que
return et
(>>=) travaillent non plus sur des
option, mais sur des types quelconques. Ils sont donc respectivement de types
'a -> 'a et
'a -> ('a -> 'b) -> 'b.
Exercice : Redéfinissez ces opérateurs.
Nous modifions maintenant notre fonction
eval, juste pour comprendre où faire des modifications :
Code : OCaml | let rec eval e = match e with
| Const n -> return n
| Add (e1, e2) ->
eval e1
>>= fun n1 -> eval e2
>>= fun n2 -> return (n1 + n2)
| etc.
|
Les noms choisis pour les deux opérateurs de la partie précédente semblent plutôt logiques : pour l'un, il s'agit « juste » de renvoyer un résultat, sans faire de calcul particulier. Pour l'autre, il s'agit de combiner un résultat et un calcul, à l'aide d'une fonction qui utilise ce résultat.
Exercice : Réécrivez le code de la branche
Div pour qu'il ressemble à celui-ci. Rajoutez également le code nécessaire (à la déclaration du type
expr ainsi que dans la fonction
eval) pour mettre au carré une expression (par exemple, implémentez un constructeur
Squ) — forcez-vous à utiliser
(>>=) !
Une gestion des erreurs basiques
Si vous avez bien compris le fonctionnement de nos deux opérateurs, alors… repassons à la version qui utilise des types options. Pour l'instant, nous avons donc le code suivant :
Code : OCaml 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 | let return x = Some x
let (>>=) x f =
match x with
| None -> None
| Some y -> f y
type expr =
| Const of int
| Add of expr * expr
| Mul of expr * expr
| Sub of expr * expr
| Div of expr * expr
let rec eval e = match e with
| Const n -> return n
| Add (e1, e2) ->
eval e1
>>= fun n1 -> eval e2
>>= fun n2 -> return (n1 + n2)
| Div (e1, e2) ->
eval e1
>>= fun n1 -> eval e2
>>= fun n2 -> return (n1 + n2)
| etc.
|
Nous voulons maintenant implémenter un système d'erreur basique pour notre langage. Il est clair que multiplier ou additionner deux nombres ne pose pas de problème… seule la division par 0 peut provoquer une erreur. Seulement, si on veut calculer par exemple
Add (Div (3, 0), Const 5), il faut propager l'erreur correctement (c'est à dire la faire sortir du
Add).
Or c'est précisément ce que fait l'opérateur
(>>=) : propager les
None. Par conséquent, nous avons déjà le bon code, sauf dans le cas du
Div (a, b) : dans cette branche seulement, il faut évaluer et tester la valeur de
b, car si elle est contient la valeur
0, nous échouons.
Voici une portion du code modifié :
Code : OCaml | let fail = None
…
let rec eval e = match e with
…
| Div (e1, e2) ->
eval e1
>>= fun n1 -> eval e2
>>= fun n2 ->
if n2 = 0 then fail else return (n1 / n2)
| etc.
|
Pour plus de lisibilité, nous définissons également une valeur
fail, qui deviendra une fonction par la suite (cf. exercices). Vous pouvez maintenant tester votre code : quand nous faisons un calcul sans erreur, tout va bien, mais quand une division par 0 apparaît, nous récupérons
None pour toute l'expression.
Et map alors ?
Notre exemple illustre plutôt mal l'intérêt de la fonction
map que nous avons définie précédemment. Pour combler cette lacune, un petit exercice :
Exercice : Rajoutez la possibilité (à votre type
expr et dans
eval) de calculer l'opposé d'un nombre (par exemple
Neg (Const 3) sera évalué en
Some -3). Trouvez comment utiliser
map et une fonction
(|>) : 'a -> ('a -> 'b) -> 'b.
Rappelez-vous que nous avons défini
map directement avec un
match. L'exercice précédent devrait normalement vous sembler redondant : en définissant
(|>) : 'a -> ('a -> 'b) -> 'b, on fait apparaître une autre façon de combiner nos fonctions, qui est plus générale et ne dépend pas du type option. Nous aurions clairement pu y arriver avec
(>>=) et
return.
Exercice : Montrez que
map peut être écrite grâce à ces deux fonctions, et n'a pas besoin d'être définie avec un
match explicite.