Dans ce chapitre, vous n'allez plus uniquement utiliser ghci : la première partie va vous montrer comment écrire du code dans un fichier, et le charger dans ghci pour tester les fonctions définies dedans. Dans les deux parties suivantes, vous allez découvrir comment utiliser les conditions, mais aussi une technique très utilisée en Haskell : le filtrage de motif.
Déclarations dans un fichier
Variables dans un fichier et chargement
Créez un fichier nommé declaration.hs avec pour contenu :
1 | reponse = 42 |
On va maintenant le charger. Pour cela, ouvrez une console, naviguez jusqu'au répertoire où se trouve votre fichier et lancez ghci.
1 2 3 4 5 | Prelude>:l declaration.hs [1 of 1] Compiling Main ( declaration.hs, interpreted ) Ok, modules loaded: Main. *Main> reponse 42 |
On a chargé le fichier avec la commande :l
. ghci indique qu'il a réussi à charger le fichier. Maintenant, la variable reponse vaut 42.
Vous l'aurez compris, on déclare une variable comme ceci : nomVariable = valeur
Comme quand on définit des variables dans ghci, on peut réutiliser le résultat des calculs précédents.
1 2 | foo = 42 foo = 1337 |
Si on définit deux fois une variable, le compilateur se plaint :
1 2 3 4 5 6 7 8 | Prelude> :l multi.hs [1 of 1] Compiling Main ( multi.hs, interpreted ) multi.hs:2:0: Multiple declarations of `Main.foo' Declared at: multi.hs:1:0 multi.hs:2:0 Failed, modules loaded: none. |
ghci indique ici qu'il n'a pas pu charger le fichier car foo est défini deux fois: à la ligne 1 et à la ligne 2.
Définir des fonctions simples
On peut aussi déclarer des fonctions.
Un exemple vaut mieux qu'un long discours :
1 | perimetreCercle r = 2 * pi * r |
Cette ligne de code définit une fonction perimetreCercle, qui prend un argument r, et renvoie 2*pi*r
.
Vous pouvez charger ce fichier dans ghci pour tester la fonction :
1 2 3 4 5 | Prelude> :l fonction Prelude> perimetreCercle 5 31.41592653589793 Prelude> 2*pi*5 31.41592653589793 |
Pour appeler la fonction, on utilise la même syntaxe que pour les fonctions prédéfinies.
Ce qui se passe, c'est que le corps de la fonction est exécuté, avec dans la variable r qui correspond à l'argument la valeur de l'argument donnée quand on appelle la fonction. C'est pour ça que, à la place de perimetreCercle 5
, on aurait très bien pu écrire 2*pi*5
.
Si vous avez déjà programmé dans un langage comme le C, vous remarquerez que la définition d'une fonction en Haskell ressemble plus à la définition d'une fonction en maths qu'à la définition d'une fonction en C. $f(x)=2*\pi*x$
1 2 3 | double perimetreCercle(double r) { return 2*pi*r; } |
À part les indications de type, la différence principale, c'est qu'une fonction C est une suite d'instructions, alors qu'une fonction Haskell est une expression (un calcul, un appel de fonction, …), et donc qu'il n'y a pas d'équivalent de return.
D'ailleurs, en Haskell, mettre plusieurs instructions dans une fonction n'aurait aucun sens, puisque les instructions d'avant n'auraient aucune influence sur l'exécution du programme (le seul moyen d'influencer l'exécution du programme serait par des effets de bords, comme la modification d'une variable globale, mais ceux-ci sont interdits en Haskell).
On peut aussi définir des fonctions prenant plusieurs arguments :
1 | perimetreRectangle longueur largeur = 2.0*(longueur+largeur) |
Cette fonction calcule le périmètre d'un rectangle : vous pouvez la tester dans ghci. On peut aussi réutiliser les fonctions déjà définies. Par exemple, sachant qu'un carré est un rectangle dont les côtés ont même longueur, comment calculeriez-vous le prérimètre d'un carré ?
1 2 | perimetreRectangle longueur largeur = 2.0*(longueur+largeur) perimetreCarre cote = perimetreRectangle cote cote |
On définit le périmètre d'un carré de côté c comme le périmètre d'un rectangle de longueur c et de largeur c.
Pour recharger un fichier après l'avoir modifié, dans ghci, utilisez la commande :r
Maintenant, que se passerait-il si on chargeait ce code, où l'ordre des définitions est inversé ?
1 2 | perimetreCarre cote = perimetreRectangle cote cote perimetreRectangle longueur largeur = 2.0*(longueur+largeur) |
La réponse est : rien. Le compilateur est capable de comprendre les définitions, même si elles font référence à des fonctions définies plus tard dans le fichier. On peut d'ailleurs faire la même expérience avec des variables qui dépendent l'une de l'autre.
Commentaires
Il est souvent utile de commenter son code, pour le rendre plus compréhensible. Deux types de commentaires sont disponibles en Haskell :
- Les commentaires sur une ligne. Ils commencent par – et le commentaire continue jusqu'à la fin de la ligne.
1 | reponse = 42 -- commentaire à propos de cette déclaration |
- Les commentaires sur plusieurs lignes. Ils commencent par {- et se terminent par -}. Ils peuvent même être imbriqués :
1 2 3 4 5 6 7 8 9 | {- Un commentaire sur plusieurs lignes -} variable = "test" {- un commentaire {- imbriqué. -} le commentaire continue -} message = "ceci n'est pas dans un commentaire" |
Conditions et filtrage de motif
Cette deuxième partie va vous apprendre à définir des fonctions un peu plus intéressantes.
if/then/else
Une construction utile est if. if renvoie le résultat d'une expression ou d'une autre suivant qu'une condition est vraie ou fausse.
Elle s'écrit comme ceci : if condition then expression 1 else expression 2
.
condition est une expression qui donne un booléen, c'est-à-dire vrai ou faux. Si la condition vaut True, expression 1 est renvoyée, sinon expression 2 est renvoyée. En pratique, seule l'expression renvoyée est calculée.
Pour utiliser if, il est donc essentiel de savoir manipuler les booléens. Un booléen a deux valeurs possibles : True (vrai) et False (faux). Les noms sont sensibles à la casse, donc n'oubliez pas la majuscule.
Opérateurs de comparaison
Dans une condition, ce qui nous intéressera en général, c'est de comparer des objets. Pour cela, il existe des opérateurs de comparaison, qui prennent deux arguments et renvoient un booléen :
Opérateur |
Renvoie True si… |
== |
les deux arguments sont égaux |
/= |
les deux arguments sont différents |
< |
le premier argument est inférieur au deuxième |
> |
le premier argument est supérieur au deuxième |
<= |
le premier argument est inférieur ou égal au deuxième |
>= |
le premier argument est supérieur ou égal au deuxième |
La ligne la plus importante à retenir est celle en gras : on écrit /=, et non pas !=.
Certaines choses ne sont pas comparables, par exemple les fonctions. On ne peut comparer des paires que si elles sont composées d'éléments comparables.
Testons ces opérations sur quelques valeurs :
1 2 3 4 5 6 7 8 9 10 11 12 13 | Prelude> 42 == 1337 False Prelude> 4 < 5 True Prelude> (2*7+6) >= (7*7-23) False Prelude> (1,7,3) == (4,2) <interactive>:1:11: Couldn't match expected type `(t, t1, t2)' against inferred type `(t3, t4)' In the second argument of `(==)', namely `(4, 2)' In the expression: (1, 7, 3) == (4, 2) In the definition of `it': it = (1, 7, 3) == (4, 2) |
Comme vous le voyez, on ne peut comparer (même pour l'égalité) que des valeurs du même type.
Combiner des booléens
Quand ces conditions ne sont pas suffisantes, on peut les combiner. Pour cela, on dispose de trois fonctions déjà définies.
La fonction not prend un argument et l'inverse simplement : not False
donne True et not True
donne False.
Si on veut que deux conditions soient vraies, on peut utiliser l'opérateur et, noté &&. Cet opérateur ne renvoie True que si ses deux arguments sont égaux à True. Par exemple, True && False
donne False, et True && True
donne True.
Enfin, l'opérateur || (ou) permet de tester si au moins une des deux conditions est vraie. Donc, False || False
renvoie False, et True || False
renvoie True.
Pour montrer comment fonctionnent ces trois fonctions, on va coder un exemple qui utilise les trois. Le but est de code une fonction "ou exclusif" (ou xor) qui prend deux arguments et renvoie True si un seul de ses arguments vaut True, False sinon. Vous pouvez essayer de trouver comment le faire vous-même. Si vous ne trouvez pas (ou si vous voulez vérifier votre solution), regardez la solution.
On va appeler les deux arguments x et y. On se rend compte que xor x y
vaut True seulement si deux conditions sont respectées : x ou y doit valoir True (donc x || y
doit donner True). De plus, x et y ne doivent pas être tous les deux vrais : x && y
doit donner False, donc not (x && y)
doit donner True.
Finalement, on aboutit à ceci :
xor x y = (x || y) && not (x && y)
Définir des opérateurs:
Appeller une fonction xor dans le code n'est pas toujours très pratique : ça demande plus de parenthèses et on voit moins facilement le sens du code. On pourrait écrire xor
à la place, mais ça fait quelques caractères en plus. L'autre solution est de définir un opérateur.
Ici, on définit l'opérateur |&
, qui fait la même chose que la fonction xor.
1 2 | a |& b = (a || b) && not (a && b) test = True |& False |
Pour définir un opérateur, il faut donc faire comme pour une déclaration de fonction, mais écrire le nom de l'opérateur au milieu des deux arguments. Le nom d'un opérateur ne doit pas comprendre de lettres ou de chiffres, et ne peux pas commencer par : (deux points). Si vous avez un doute sur la validité d'un nom, le mieux est de tester.
Utiliser if
Vous pouvez tester if dans ghci :
1 2 3 4 5 6 | Prelude> let x = 7 Prelude> if x > 5 then 42 else 0 42 Prelude> let x = 2 Prelude> if x > 5 then 42 else 0 0 |
La partie else est obligatoire. Si vous avez fait du C ou du php, if ressemble beaucoup à l'opérateur ternaire (qui revoie une valeur)
Les deux branches doivent renvoyer des valeurs du même type, sinon ça ne compilera pas !
Maintenant, une astuce utile. Prenons les fonctions suivantes
1 2 | nul x = if x == 0 then True else False nonNul x = if x == 0 then False else True |
On peut faire plus court : quand notre if renvoie des booléens, on peut enlever le if, comme ceci :
1 2 | nul x = x == 0 nonNul x = not (x==0) |
On va utiliser if pour écrire une fonction qui prend un entier et renvoie "Negatif"
s'il est strictement inférieur à 0, "Positif"
sinon.
1 | signe x = if x >= 0 then "Positif" else "Negatif" |
On pourrait écrire ce code sur plusieurs lignes :
1 2 3 | signe x = if x >= 0 then "Positif" else "Negatif" |
Mais on ne peut pas écrire ça :
1 2 3 | signe x = if x >= 0 then "Positif" else "Negatif" |
En effet, l'indentation est importante en Haskell : ce qui est à l'intérieur de la fonction doit être plus indenté que le début de la déclaration de la fonction.
Filtrage de motif
case of
L'autre structure conditionnelle importante est case of. Observons là sur un exemple simple :
1 2 3 4 5 | enLettres x = case x of 0 -> "Zero" 1 -> "Un" 2 -> "Deux" _ -> "Trop grand!" |
Cette construction peut vous faire penser à un switch en C.
On écrit case variable of
, et en dessous une série de motifs ainsi que ce qu'il faut renvoyer quand variable correspond à un de ces motifs. Donc x est comparé aux motifs dans l'ordre, et on obtient le résultat de l'expression associée au premier motif qui correspond. Si aucun motif ne correspond, on obtient une erreur.
Dans cet exemple, on a deux types de motifs : une valeur (0, 1, 2) et _ qui est un motif qui correspond à n'importe quelle valeur.
1 2 3 4 5 | enLettres x = case x of _ -> "Trop grand!" 0 -> "Zero" 1 -> "Un" 2 -> "Deux" |
Puisque les motifs sont testés dans l'ordre, si on changeait l'ordre des motifs, on obtiendrait des résultats différents. Ici, enLettres renverra toujours "Trop grand!"
.
On peut aussi écrire des motifs plus compliqués :
1 2 3 4 5 | ouEstZero x = case x of (0,0) -> "Gauche et droite" (0,_) -> "Gauche" (_,0) -> "Droite" _ -> "Nul part" |
Ici, on voit une nouvelle façon de construire des motifs : on peut utiliser _ à l'intérieur de structures plus compliquées, pour dire qu'on ne se soucie pas d'une partie de cette structure. Donc le motif (0,_)
correspond à toutes les paires donc le premier élément est 0.
On peut aussi utiliser le filtrage de motif pour décomposer une paire.
1 2 | sommePaire t = case t of (x,y) -> x+y |
Quand on met un nom de variable dans un motif, cela ne signifie pas que cette partie du motif doit être égale à la variable. Un nom de variable se comporte plutôt comme un _, c'est-à-dire qu'il correspond à tout, mais en plus, dans l'expression à droite du motif, cette variable vaudra ce qu'il y avait à sa place dans le motif.
Par exemple, si on filtre la valeur (0,7)
avec le motif et le résultat (0,x) -> x+1
, on aura x=7
donc on obtiendra 8.
On peut combiner toutes ces idées pour créer des fonctions plus compliquées. Cette fonction renvoie le premier élément non nul d'une paire, ou 0.
1 2 3 4 5 | premierNonNul t = case t of (0,0) -> 0 (0,y) -> y (x,0) -> x (x,y) -> x |
On remarque que certains motifs se recoupent. Par exemple, les cas (0,0) -> 0 et (0,y) -> y peuvent se réécrire avec un seul motif (0,y) -> y De même, on peut remplacer les cas (x,0) -> x et (x,y) -> x par un seul cas, (x,_) -> x On obtient un code avec seulement deux cas :
1 2 3 | premierNonNul t = case t of (0,y) -> y (x,_) -> x |
On ne peut pas mettre deux fois la même variable dans un motif (donc il est impossible de faire un motif (x,x)
). Dans chaque cas, les valeurs renvoyées doivent être du même type.
Style déclaratif
Le filtrage de motif est un outil puissant, et on se rend compte qu'on fait très souvent un filtrage sur les arguments de la fonction. Quand on doit prendre en compte la valeur de plusieurs arguments, le filtrage finit par donner des choses assez peu claires. Ici, on prend comme exemple une version de premierNonNul qui prend deux arguments au lieu de prendre une paire de nombres :
1 2 3 | premierNonNul x y = case (x,y) of (0,y) -> y (x,_) -> x |
On doit construire une paire avec les deux arguments, ce qui finit par donner des codes pas très naturels.
1 2 | premierNonNul 0 y = y premierNonNul x _ = x |
On préfère en général écrire le filtrage de cette façon, quand c'est possible.
Il est aussi possible de remplacer dans certains cas if par des gardes :
1 2 3 4 | signePremier (x,_) | x > 0 = "Positif" | x < 0 = "Negatif" | otherwise = "Nul" |
Les gardes permettent d'exécuter du code différent suivant des conditions : si le motif correspond, l'expression correspondant à la première garde qui renvoie True est exécutée. La garde otherwise permet de prendre en compte tous les cas pas encore traités (en réalité, otherwise est une constante qui vaut True). Il ne faut pas mettre de signe égal entre le motif et les gardes, sous peine de récolter une erreur de syntaxe.
n-uplets
Vous avez déjà vu les paires. Mais en fait, ce ne sont qu'un exemple d'un type de données plus général : les n-uplets. Les paires sont des n-uplets à 2 éléments, mais on peut écrire des n-uplets avec plus d'éléments.
Par exemple (1,2,3,True)
. On utilise la même notation pour le filtrage de motif sur les n-uplets que pour les paires.
Cependant, fst (1,2,3,True)
donne une erreur de type : les fonctions sur les n-uplets ne fonctionnent que pour des n-uplets de taille fixée. Mais vous pouvez, comme exercice, coder les fonctions fst3, snd3 et thr3 qui permettent d'obtenir respectivement le premier, deuxième et troisième élément d'un triplet en utilisant le filtrage de motif.
Solution :
1 2 3 | fst3 (a,_,_) = a snd3 (_,b,_) = b thr3 (_,_,c) = c |
Si vous lisez des articles en anglais sur Haskell, les n-uplets sont appelés tuples.
Définir des valeurs intermédiaires
Parfois il peut être utile dans une fonction de définir des valeurs intermédiaires. Par exemple, on veut créer une fonction qui donne le nombre de racines réelles d'un polynôme du second degré (de la forme $ax^2+bx+c$). On sait que le discriminant est donné par $\Delta=b^2-4ac$, et que s'il est positif, il y a deux racines réelles, s'il est nul, il y en a une, et s'il est négatif, il n'y en a pas. Donc on peut penser notre fonction comme ceci : on calcule d'abord le discriminant, puis on regarde son signe pour donner le nombre de racines. Pour faire cela, on a besoin de définir une variable locale à notre fonction. Il y a deux façons de faire ça.
let … in …
La première méthode est d'utiliser let
. On l'utilise ainsi : let variable = valeur in expression
.
Par exemple, on pourrait coder notre fonction nombreDeRacines ainsi :
1 2 3 4 | nombreDeRacines a b c = let delta = b^2 - 4*a*c in if delta > 0 then 2 else if delta == 0 then 1 else 0 |
where
On peut aussi déclarer une variable locale avec where
.
Par exemple :
1 2 3 4 | nombreDeRacines' a b c = if delta > 0 then 2 else if delta == 0 then 1 else 0 where delta = b^2 - 4*a*c |
On peut aussi déclarer plusieurs variables avec un seul where, comme dans cet exemple qui ne fait rien d'utile :
1 2 3 | diffSommeProd a b = produit - somme where produit = a*b somme = a+b |
where est sensible à l'indentation ! Il doit toujours être plus indenté que le début de la déclaration de la fonction.
Un peu d'exercice ?
Il est temps de mettre en pratique ce que vous avez appris. Ces exercices ne sont pas corrigés, mais vous pouvez tester votre code : s'il marche, c'est bon signe. Une bonne habitude à prendre est d'essayer toujours de trouver les cas qui font que le code ne marche pas.
- Des fonctions myMin et myMax qui prennent chacune deux arguments et renvoient respectivement le minimum et le maximum des deux arguments
- À partir de ces fonctions, codez une fonction qui donne le minimum ou le maximum de 4 nombres
- En utilisant myMin et myMax, codez une fonction bornerDans qui prend trois arguments et renvoie le troisième argument s'il est dans l'intervalle formé par les deux premiers, ou renvoie la borne de l'intervalle la plus proche. Exemples:
1 2 3 | bornerDans 5 7 6 = 6 -- dans l'intervalle bornerDans 5 7 4 = 5 -- trop petit bornerDans 5 7 9 = 7 -- trop grand |
- Codez une fonction qui prend trois arguments et dit si le troisième argument est dans l'intervalle fermé formé par les deux premiers arguments (on considèrera que le premier argument est inférieur ou égal au deuxième)
- En n'utilisant qu'une seule comparaison, codez une fonction qui prend une paire de nombre et renvoie cette paire triée
- Codez une fonction qui prend deux vecteurs représentés par des paires de nombres, et donne la somme de ces deux vecteurs
- Codez une fonction qui prend un vecteur et renvoie sa norme
- Codez une fonction qui prend un nombre et un vecteur, et renvoie le produit du vecteur par ce nombre
- Codez une fonction qui prend deux vecteurs et renvoie le produit scalaire de ces deux vecteurs
Plus de filtrage de motif
Pour l'instant, vous n'avez vu que deux applications du filtrage de motif : soit vous prenez plusieurs valeurs d'un type pour lequel il n'y a qu'un seul choix (par exemple un couple d'entiers ne peut pas être autre chose qu'un couple), soit vous distinguez les valeurs d'un type qui peut en prendre plusieurs (par exemple, True ou False pour les booléens). Mais en fait, le filtrage de motifs permet de faire les deux choses à la fois.
Manipuler des listes
Vous avez vu au chapitre précédent que toutes les listes pouvaient être construites à l'aide de la liste vide [] et de l'opérateur : qui combine un élément et une liste pour former une nouvelle liste avec l'élément ajouté en premier. C'est pratique pour construire des listes, mais on peut aussi s'en servir pour les détruire, c'est à dire examiner ce qu'il y a à l'intérieur, par du filtrage de motif.
Par exemple, si on veut créer une fonction qui renvoie 0 si la liste vide, son premier élément sinon, on peut la coder en utilisant un filtrage (puisqu'elle ne peut renvoyer qu'un type, cette fonction ne marche qu'avec des listes de nombres) :
1 2 3 4 5 6 7 8 9 10 11 | -- Version sans filtrage premier l = if null l then 0 else head l -- Version avec filtrage premier' l = case l of [] -> 0 x:_ -> x -- Ou même : premier'' [] = 0 premier'' (x:_) = x |
Dans les deux versions avec filtrage, on distingue les deux formes possibles pour la liste donnée en argument. Les parenthèses sont nécessaires dans la deuxième version avec filtrage, comme pour l'application de fonction : premier'' x:_
serait compris comme (premier'' x):_
.
On peut aussi profiter d'une syntaxe spéciale pour créer un motif qui correspond à une liste de longueur connue, par exemple dans ce code qui additionne les deux premiers éléments d'une liste :
1 2 3 | addition [] = 0 addition [x] = x addition (x:y:_) = x + y |
De cette façon, on peut recoder quelques fonctions sur les listes, par exemple la fonction head
qui retourne le premier élément d'une liste (mais vous pourriez aussi code tail
ou null
de la même façon) :
1 | head' (x:_) = x |
Quelque chose pourrait vous choquer dans ce code : il ne traite pas tous les cas possibles. Qu'est-ce qui se passe si on donne la liste vide à la fonction ? Rien de bien : on obtient une erreur.
1 | *** Exception: <interactive>:1:4-18: Non-exhaustive patterns in function head'
|
Par défaut, GHC ne vérifie pas que le filtrage traite tous les cas possibles, mais vous pouvez activer un avertissement avec l'option -fwarn-incomplete-patterns. L'erreur qu'on obtient dans le cas de la liste vide n'est pas très claire, mais il est possible de renvoyer son propre message d'erreur avec la fonction error
:
1 2 | head' [] = error "Liste vide" head' (x:_) = x |
Le filtrage de motif est quelque chose de très pratique (essayez de coder "addition" sans : le code serait beaucoup moins clair), mais pas magique : on ne peut pas utiliser n'importe qu'elle fonction dans un filtrage. Par exemple, on aimerait bien créer une fonction qui renvoie le prénom d'une personne lorsqu'on lui donne une chaîne de la forme "Bonjour <prénom>!"
. Une chaîne de caractères est une liste de caractères, donc on peut utiliser les même fonctions dessus, dont la fonction ++ qui concatène deux listes.
1 2 3 | prenom msg = case msg of "Bonjour " ++ prenom ++ "!" -> prenom _ -> "Anonyme" |
Mais ce genre de code ne marche pas : on ne peut pas utiliser n'importe quelle fonction dans un filtrage de motif. En fait, les seules choses qu'on a le droit d'utiliser sont les constructeurs du type qu'on cherche à filtrer. Ils correspondent à la façon dont le type a été défini, et aussi à la représentation en mémoire du type. Pour les listes, les constructeurs sont : et []. Pour les booléens, ce sont True et False, et pour les nombres, c'est n'importe quel nombre : par exemple, on peut utiliser 0 comme un motif.
Gestion des erreurs
Le filtrage de motifs et les types sommes (pour lesquels on a plusieurs constructeurs possibles) ne servent pas qu'avec les listes. Par exemple, ils servent pour la gestion des erreurs.
Maybe : pour représenter l'absence de valeur
Le type Maybe a deux constructeurs : Nothing qui ne prend pas de paramètre, et Just, qui prend une valeur en paramètre. Par exemple, on peut écrire Nothing
, Just 42
ou même Just Nothing
et Just (Just 42)
(on a alors un Maybe qui contient lui-même un Maybe).
On l'utilise souvent pour la valeur de retour d'une fonction qui peut échouer : par exemple, si on a une annuaire, et une fonction qui recherche le numéro de téléphone d'une personne à partir de son nom, elle renverrait Nothing
si la personne n'est pas dans l'annuaire, et Just (numéro)
si la personne est dans l'annuaire.
Pour manipuler ces valeurs, on utilise le filtrage de motif. Par exemple, ici, on a une fonction demanderLeNomDuChat
qui appelle un numéro, et renvoie le nom du chien. Mais cette fonction peut échouer : par exemple, la personne qu'on appelle peut ne pas répondre. Elle doit donc renvoyer une valeur de type Maybe. Maintenant, si on veut faire une fonction qui renvoie (peut-être) le nom du chat d'une personne à partir de son nom, on récupère d'abord le numéro de téléphone dans Maybe. Pour vérifier si on a vraiment un numéro de téléphone, on utilise le filtrage :
1 2 3 | nomDuChat nom = case numeroDeTelephone nom of Nothing -> Nothing Just numero -> demanderLeNomDuChat numero |
En fait, Nothing peut être vu comme la valeur null
qui est disponible dans certains langages de programmation, et qui sert aussi à indiquer l'absence de valeur intéressante. Cependant, il y a une différence importante : les types ne portent pas de valeur nulle par défaut, mais seulement lorsqu'on les "enveloppe" dans Maybe. L'avantage, c'est qu'on ne peut pas accidentellement passer la valeur nulle à une fonction qui ne l'attend pas, puisque le type attendu est différent. C'est pour ça qu'il faut faire bien attention à ne pas oublier Just. Par exemple, le code ci-dessous est incorrect :
1 2 3 4 5 | -- Renvoie l'adresse ip du serveur d'un site web adresseIP "www.siteduzero.com" = "92.243.25.239" adresseIP "progmod.org" = "178.33.42.21" adresseIP "google.fr" = "66.249.92.104" adresseIP _ = Nothing |
Pourquoi ? Parce que dans presque tous les cas, on renvoie une valeur simple, qui ne peut jamais valoir Nothing, alors qu'on renvoie Nothing si le site n'est pas trouvé. Les types de retour ne correspondent pas, donc ce code est invalide. Il faut penser à mettre à chaque fois les valeurs définies dans Just :
1 2 3 4 5 | -- Renvoie l'adresse ip du serveur d'un site web adresseIP "www.siteduzero.com" = Just "92.243.25.239" adresseIP "progmod.org" = Just "178.33.42.21" adresseIP "google.fr" = Just "66.249.92.104" adresseIP _ = Nothing |
Une dernière chose : pour rechercher dans un "annuaire" représenté par une liste de couples (clé, valeur), vous pouvez utiliser la fonction lookup
: lookup cle liste
renvoie Just valeur
si la clé est trouvée dans la liste, et Nothing
sinon. Exemples :
1 2 3 4 5 6 | Prelude> let ips = [("www.siteduzero.com","92.243.25.239"), ("progmod.org", "178.33.42.21"), ("google.fr", "66.249.92.104")] Prelude> lookup "www.siteduzero.com" ips Just "92.243.25.239" Prelude> lookup "reddit.com" ips Nothing |
Exemple : des fonctions mathématiques
Vous savez surement qu'il y a des opérations qu'on n'a pas le droit de faire : par exemple, on ne peut pas diviser par 0. Mais il arrive souvent qu'on fasse des calculs avec des nombres dont on ne peut pas être sûr à l'avance si ils sont égaux à 0 ou pas. Par défaut, ce genre d'erreurs donne une exception qui arrête l'éxécution du programme (il y a des moyens de les intercepter, mais vous ne verrez pas ça tout de suite). Mais on pourrait adopter une autre solution : puisque la division peut échouer, pourquoi ne pas lui faire renvoyer une valeur dans Maybe ?
Par exemple, on pourrait faire cela :
1 2 3 4 | -- On utilise la fonction div, qui correspond à la division euclidienne -- On la met entre ` ` pour l'utiliser comme un opérateur divise _ 0 = Nothing divise x y = Just (x `div` y) |
Ce code marche très bien :
1 2 3 4 | *Main> 12 `divise` 5 Just 2 *Main> 12 `divise` 0 Nothing |
Mais, quand on essaye de faire des calculs compliqués, on a des problèmes, puisque les autres opérations n'attendent pas "peut-être un nombre", mais un nombre normal. Il y a alors deux solutions : soit on utilise à chaque fois case
sur le résultat de divise
pour distinguer les différents cas, soit on adapte les opérations mathématiques pour prendre des valeurs dans Maybe
comme arguments. C'est cette deuxième approche qui nous intéresse ici.
On va commencer par la fonction plus
:
1 2 3 4 5 | -- Cas faciles : lorsqu'un argument est "Nothing", on renvoie Nothing pour propager l'erreur plus Nothing _ = Nothing plus _ Nothing = Nothing -- Le cas intéressant : pas d'erreur plus (Just a) (Just b) = Just (a + b) |
On teste la fonction :
1 2 3 4 | *Main> Nothing `plus` Just 1 Nothing *Main> Just 2 `plus` Just 3 Just 5 |
On peut faire de même pour plus
et fois
qui ne peuvent pas non plus échouer :
1 2 3 4 5 6 7 | moins Nothing _ = Nothing moins _ Nothing = Nothing moins (Just a) (Just b) = Just (a - b) fois Nothing _ = Nothing fois _ Nothing = Nothing fois (Just a) (Just b) = Just (a * b) |
Il ne reste plus qu'à coder la division, où on doit aussi vérifier si on ne divise pas par Just 0
:
1 2 3 4 5 6 | -- Propager les erreurs divise Nothing _ = Nothing divise _ Nothing = Nothing -- la division par 0 donne un résultat indéfini divise _ (Just 0) = Nothing divise (Just a) (Just b) = Just (a `div` b) |
On peut ensuite faire quelques calculs pour tester :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | *Main> let n0 = Just 0 *Main> let n1 = Just 1 *Main> let n2 = Just 2 *Main> let undef = Nothing *Main> n1 `plus` n2 Just 3 *Main> n1 `fois` undef Nothing *Main> n2 `divise` n1 Just 2 *Main> n2 `divise` n0 Nothing *Main> (n2 `plus` n1) `divise` (n1 `moins` n1) Nothing |
Et voilà, on a maintenant un moyen d'effectuer des opérations mathématiques sans faire de division par zéro, et sans vérifier à chaque fois par quoi on divise. Les notations ne sont pas très pratique, mais vous verrez bientôt comment redéfinir les opérateurs mathématiques (+,-,*,div, …).
On remarque aussi qu'une partie du code est répétée à chaque fois, et que les définitions se ressemblent toutes. La gestion des erreurs demande beaucoup de code supplémentaire, mais vous verrez dans le chapitre sur les monades qu'on peut faire la même chose avec un code beaucoup plus court et sans répétition.
Either : un choix
Either est un type qui ressemble beaucoup à Maybe. Il y a deux constructeurs, qui prennent chacun une valeur : Left
et Right
. On peut par exemple les utiliser pour représenter un choix : soit un marchand de glaces livre à domicile, et dans ce cas on renvoie une valeur Left adresse
, soit il fait des glaces à emporter, et dans ce cas on renvoie Right (adresse du marchand)
. Le mangeur de glace peut alors faire un filtrage de motif pour choisir d'aller acheter une glace, ou de la commander par téléphone.
Mais on utilise aussi souvent Either pour gérer des erreurs : si il n'y a pas d'erreur, on renvoie Right (valeur demandée)
, sinon Left (détails de l'erreur)
(utiliser Left pour indiquer l'échec et Right la réussite est une convention presque toujours respectée). L'avantage, c'est qu'on peut de cette façon donner plus de détails sur l'erreur qu'avec un simple Nothing
. On pourrait adapter nos fonctions mathématiques pour utiliser Either :
1 2 3 4 5 6 7 8 | plus (Left erra) _ = Left erra plus _ (Left errb) = Left errb plus (Right a) (Right b) = Right (a + b) divise (Left erra) _ = Left erra divise _ (Left errb) = Left errb divise (Right _) (Right 0) = Left "Division par 0" divise (Right a) (Right b) = Right (a `div` b) |
Encore une fois, il est pénible de gérer tous les cas, mais les monades nous permettront de le faire sans efforts.
Ce chapitre est terminé. Si vous ne deviez n'en retenir qu'une chose, le filtrage de motif est le point le plus important : il est très utilisé en Haskell et permet de faire beaucoup de manipulations sur les types impossibles autrement.