Licence CC BY-SA

Modules, IO et compilation

Ce chapitre va aborder quelques points que vous vous attendriez surement à voir arriver avant. D'abord, vous apprendrez comment utiliser des fonctions qui viennent d'autres modules que le Prelude, et à créer vos propres modules. Ensuite, vous apprendrez à utiliser les entrées-sorties. Il est un peu plus difficile d'afficher des choses à l'écran en Haskell que dans les autres langages, mais il y a une bonne raison à cela. Ne vous inquiétez pas pour autant : ce n'est pas très compliqué, mais ça demande quelques explications.

Créer des modules

Importer un module

Pour l'instant, on n'a utilisé que les fonctions du Prelude. Mais maintenant, on veut créer une fonction anagramme :: String -> String -> Bool, qui prend deux chaînes de caractères et vérifie si elles forment un anagramme. En réfléchissant un peu, on se rend compte que si, après avoir enlevé les espaces et les avoir triées, les deux chaînes sont égales, on peut renvoyer True. Il n'y a pas de fonction dans le Prelude pour trier une liste, mais en lisant la documentation, vous vous êtes aperçu que la fonction sort :: [a] -> [a] dans le module Data.List fait exactement ce que vous voulez. Comment l'utiliser ?

Charger un module dans ghci

Pour charger le module dans ghci, vous devez utiliser la commande :m NomDu.Module :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ghci
GHCi, version 6.10.4: http://www.haskell.org/ghc/  :? for help
Loading package ghc-prim ... linking ... done.
Loading package integer ... linking ... done.
Loading package base ... linking ... done.
Prelude> sort [6,5,9,2,1]

<interactive>:1:0: Not in scope: `sort'
Prelude> :m +Data.List
Prelude Data.List> sort [6,5,9,2,1]
[1,2,5,6,9]

Si vous importez beaucoup de modules, le prompt de ghci (la partie Prelude Data.List>) peut devenir très long. Vos pouvez le remplacer par quelque chose de moins long avec la commande :set prompt "ghci> " :

1
2
3
4
5
6
*Main> :m +Data.List
*Main Data.List> :m +Data.Map
*Main Data.List Data.Map> :m +Control.Monad
*Main Data.List Data.Map Control.Monad> :m +Control.Concurrent
*Main Data.List Data.Map Control.Monad Control.Concurrent> :set prompt "ghci> "
ghci>

Pour lire ce code, je vous recommande de lire la documentation des fonctions de Data.Map que vous ne comprenez pas (en particulier celle de la fonction alter). Maintenant, on va tester ce code en le chargeant dans ghci.

Importer un module dans un fichier

Si vous voulez utiliser la fonction sort dans votre code, il faut rajouter en haut du fichier une ligne comme celle-ci :

1
2
3
import Data.List

anagramme xs = xs == reverse xs

Le module Data.Map est très pratique : il fournit un type pour des dictionnaire qui à une clé associent une valeur, et beaucoup d'opérations plus efficaces que sur les listes. D'ailleurs, l'implémentation est basée sur des arbres binaires équilibrés. Voilà le lien vers la documentation de ce module : http://www.haskell.org/ghc/docs/latest/html/libraries/containers/Data-Map.html. Par exemple, imaginons que vous voulez créer une fonction pour compter le nombre de fois qu'une lettre apparait dans une chaîne de caractères. Vous allez donc commencer votre module par :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import Data.Map

-- Pour compter les lettres, on ajoute au fur et à mesure les lettres trouvées dans notre Map
compterLettres :: String -> Map Char Integer
compterLettres = foldr (alter ajouterLettre) empty

-- Cette fonction modifie la valeur associée à un caractère en fonction de la valeur précédente
ajouterLettre Nothing = Just 1 -- On n'avait pas rencontré le caractère avant
ajouterLettre (Just a) = Just (a+1)

nombreLettres :: Map Char Integer -> Char -> Integer
nombreLettres m c = case lookup c m of
                      Nothing -> 0 -- la lettre n'est pas présente
                      Just a -> a

Pour lire ce code, je vous recommande de lire la documentation des fonctions de Data.Map que vous ne comprenez pas (en particulier celle de la fonction alter). Maintenant, on va tester ce code en le chargeant dans ghci :

1
2
3
4
5
6
7
[1 of 1] Compiling Main             ( realworld.hs, interpreted )

realworld.hs:12:25:
    Ambiguous occurrence `lookup'
    It could refer to either `Prelude.lookup', imported from Prelude
                          or `Data.Map.lookup', imported from Data.Map at realworld.hs:1:0-14
Failed, modules loaded: none.

On obtient donc une jolie erreur : la fonction lookup est définie à la fois dans le Prelude et dans le module Data.Map, et ces deux modules sont importés : il est donc impossible de savoir à quelle fonction on se réfère. Pour résoudre ce problème, il y a plusieurs solutions. La première, puisque l'on n'utilise pas la fonction lookup du Prelude, serait de ne pas l'importer. Pour cela, on peut ajouter en haut du fichier une ligne comme celle-ci, pour demander d'avoir toutes les fonctions du Prelude sauf lookup (le Prelude est importé par défaut, cette déclaration annule ce comportement et permet donc de choisir les options souhaitées) :

1
import Prelude hiding (lookup)

Mais cette solution n'est pas très satisfaisante : pas mal d'autres fonctions de Data.Map utilisent un nom déjà utilisé dans le Prelude, et on pourrait vouloir utiliser la fonction lookup du Prelude dans le même module. L'autre solution est alors d'utiliser un préfixe qu'on devra indiquer quand on appelle les fonctions de Data.Map. Pour faire cela, on peut utiliser une déclaration comme ceci :

1
import qualified Data.Map

Il faut alors réécrire tout le code pour ajouter Data.Map. devant le nom de toutes les fonctions de ce module, comme ceci : Data.Map.alter. Mais c'est très long à taper. Il est possible de définir un préfixe personnalisé de cette façon :

1
import qualified Data.Map as M

Voilà, on n'a plus qu'à utiliser M comme préfixe, c'est beaucoup plus court et pratique. Comme il n'y a pas de type Map dans le Prelude, il est même possible d'importer le module de cette façon :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import Data.Map (Map)
import qualified Data.Map as M


-- Pour compter les lettres, on ajoute au fur et à mesure les lettres trouvées dans notre Map
compterLettres :: String -> Map Char Integer
compterLettres = foldr (M.alter ajouterLettre) M.empty

-- Cette fonction modifie la valeur associée à un caractère en fonction de la valeur précédente
ajouterLettre Nothing = Just 1 -- On n'avait pas rencontré le caractère avant
ajouterLettre (Just a) = Just (a+1)

nombreLettres :: Map Char Integer -> Char -> Integer
nombreLettres m c = case M.lookup c m of
                      Nothing -> 0 -- la lettre n'est pas présente
                      Just a -> a

On indique au compilateur qu'on veut importer seulement le type Map de Data.Map sans préfixe, et qu'on veut importer tout le module avec le préfixe M.

Créer un module

Votre premier module !

Vous pouvez aussi créer vos propres modules. Par exemple, reprenons le code des arbres binaires de recherche du chapitre précédent. On veut mettre le code dans un module. D'abord, choisissons un nom pour le module : ce sera ABR. Ensuite, on va mettre tout le code en question dans un fichier nommé ABR.hs.

 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
data Arbre a = Branche a (Arbre a) (Arbre a)
             | Feuille 
               deriving Show


foldArbre f n Feuille = n
foldArbre f n (Branche e d g) = f e (foldArbre f n d) (foldArbre f n g)

-- on est tombé sur une Feuille, donc on n'a rien trouvé
rechercher _ Feuille = False
rechercher e (Branche f g d) | e == f = True
                             | e < f = rechercher e g
                             | e > f = rechercher e d

inserer e Feuille = Branche e Feuille Feuille
inserer e (Branche f g d) | e == f = Branche f g d
                          | e < f = Branche f (inserer e g) d
                          | e > f = Branche f g (inserer e d)

-- construire un arbre à partir d'une liste
construireArbre :: (Ord a) => [a] -> Arbre a
construireArbre = foldr inserer Feuille

aplatir ::  Arbre a -> [a]
aplatir = foldArbre (\e g d -> g ++ [e] ++ d) []
-- version sans foldArbre :
aplatir' Feuille = []
aplatir' (Branche e g d) = aplatir g ++ [e] ++ aplatir d

triABR :: (Ord a) => [a] -> [a]
triABR = aplatir . construireArbre


supprimerPlusGrand (Branche e g Feuille) = (g,e)
supprimerPlusGrand (Branche e g d) = let (d',grand) = supprimerPlusGrand d in 
                                     (Branche e g d', grand) 

supprimerRacine (Branche _ Feuille Feuille) = Feuille
supprimerRacine (Branche _ g Feuille) = g
supprimerRacine (Branche _ Feuille d) = d
supprimerRacine (Branche _ g d) = Branche e' g' d
    where (g',e') = supprimerPlusGrand g

supprimer _ Feuille = Feuille
supprimer e (Branche f g d) | e == f = supprimerRacine (Branche f g d)
                            | e < f = Branche f (supprimer e g) d
                            | e > f = Branche f g (supprimer e d)

arbreVide = Feuille

Ensuite, pour déclarer notre module ABR, il faut rajouter une déclaration tout en haut du fichier :

1
module ABR (Arbre(Branche, Feuille), inserer, rechercher, construireArbre, aplatir, supprimer, arbreVide) where

On a indiqué après le nom du module l'ensemble des fonctions et types qui sont exportés par ce module. Cela nous permet de masquer certaines fonctions qui ne serviraient pas à l'utilisateur du module, comme la fonction aplatir', qui n'est qu'une fonction utilitaire. On a aussi exporté le type Arbre, et ses deux constructeurs Branche et Feuille avec Arbre(Branche,Feuille). Quand on exporte tous les constructeurs d'un type, il est aussi possible d'écrire Arbre(..) (il y a deux points, pas trois). Voilà, vous avez créé votre premier module.

Mais imaginons maintenant que l'utilisateur de votre module ne soit pas très malin (ou trop…), et modifie les valeurs de type Arbre à la main. Il risquerait de créer accidentellement des arbres invalides, et d'introduire des bugs dans son programme. Pour l'empêcher de faire ça, il suffit de ne pas exporter le type Arbre. De cette façon, il est impossible de le manipuler autrement qu'en utilisant les fonctions que vous avez définies. Vous pourrez aussi changer la représentation de l'arbre sans rendre votre module incompatible avec ce qui a été codé avant le changement. Remplacez donc la première ligne par celle-ci :

1
module ABR (inserer, rechercher, construireArbre, aplatir, supprimer, arbreVide) where

Utiliser votre module depuis un autre fichier

Pour pouvoir importer votre module, son chemin d'accès depuis l'endroit où vous lancez ghci (ou le compilateur) doit correspondre au nom du module : par exemple, si je décide de renommer mon module Data.ABR et que je veux l'utiliser depuis le module Test, le chemin de Data.ABR doit être (par rapport au dossier du module Test) Data/ABR.hs. Ensuite, placez-vous dans le bon dossier et lancez simplement ghci Test.hs.

Entrées et sorties

Un problème épineux

Haskell est un langage purement fonctionnel : cela veut dire qu'une fonction ne peut pas modifier l'environnement extérieur (par exemple, une variable globale), et que sa valeur de retour ne doit dépendre que de ses arguments. Cela a des avantages : il est possible de raisonner beaucoup plus facilement sur un programme, par exemple de supprimer un appel de fonction sans risque si son résultat n'est pas utilisé. Le compilateur peut aussi appliquer certaines optimisations beaucoup plus facilement : par exemple, si une fonction est appelée deux fois avec les mêmes arguments, il peut choisir de ne faire le calcul qu'une seule fois. Cependant, cela pose un problème : comment créer une fonction qui communique avec l'extérieur du programme ? Prenons l'exemple d'une fonction qui sert à lire une ligne entrée au clavier par l'utilisateur du programme : son résultat dépend du moment où elle est appelée, donc son résultat ne dépend pas seulement de ses arguments. Pour une fonction qui affiche quelque chose à l'écran, c'est moins compliqué : l'action réalisée ne dépend bien que des arguments. Cependant, cette fonction modifie quand même le monde extérieur, ce qui ne se reflète pas dans sa valeur de retour. Il est aussi impossible d'enlever un appel à cette fonction dont le résultat n'est pas utilisé sans changer le résultat du programme. On pourrait choisir d'ignorer ces problèmes théoriques, et d'utiliser tout de même des fonctions impures, en laissant le soin au compilateur de ne pas y toucher. Cependant, il y a un autre problème : l'évaluation paresseuse fait que les opérations ne sont effectuées qu'au moment où leur résultat est nécessaire. Par exemple, si vous faites un programme qui demande le nom puis le prénom de l'utilisateur, et que vous utilisez le prénom avant le nom, les informations risquent d'être demandées dans le désordre. Pire encore, les opérations dont le résultat ne sert pas, par exemple afficher un texte à l'écran, ne seront pas effectuées.

Rassurez-vous, il est quand même possible de faire des programmes qui servent à quelque chose en Haskell : il existe une solution pour communiquer avec l'extérieur sans rencontrer ce genre de problèmes, que vous allez découvrir progressivement dans ce chapitre.

Le type IO et la notation do

Des actions IO

Pour commencer, nous allons utiliser ghci. La fonction putStrLn permet d'afficher quelque chose à l'écran. Vous pouvez la tester dans ghci :

1
2
Prelude> putStrLn "Hello, World!"
Hello, World !

Voilà, vous avez affiché votre premier texte à l'écran. Maintenant, regardons le type de cette fonction : putStrLn :: String -> IO (). Cette fonction prend donc une valeur de type String et renvoie une valeur de type IO (). Le type () est aussi appelé unit : il ne contient qu'une seule valeur, qui se note aussi (). Cela veut donc dire quelque chose comme « cette valeur n'est pas intéressante ». Mais le type renvoyé est IO (). Vous pouvez voir le type IO comme un conteneur, dans lequel toutes les fonctions qui font des opérations qui interagissent avec l'extérieur renvoient leur valeur. D'ailleurs, IO vient de l'anglais input/output qui signifie entrées-sorties. Pour des raisons que vous verrez plus tard, on peut aussi parler de la monade IO.

On va continuer avec un deuxième exemple : getLine permet de lire une ligne de texte entrée par l'utilisateur. Un petit exemple :

1
2
3
Prelude> getLine
Bonjour
"Bonjour"

La première ligne où il y a écrit Bonjour a été entrée au clavier. La ligne a bien été lue, puisque getLine renvoie comme résultat "Bonjour". Cependant, quelque chose doit vous étonner : getLine n'est pas une fonction, puisqu'on ne lui a pas donné d'arguments. On peut confirmer ce fait en regardant son type : getLine :: IO String. En fait, une valeur de type IO a est une action qui renvoie une valeur de type a, et ghci exécute pour nous les actions qu'on lui donne, puis affiche leur résultat. Mais dans ce cas, putStrLn n'affiche pas non plus directement la valeur qu'on lui donne, mais renvoie plutôt une action, qui est ensuite exécutée par ghci. On peut confirmer cela en se livrant à un petit test :

1
2
3
4
5
6
7
Prelude> let hello = putStrLn "Hello, World !"
Prelude> :t hello
hello :: IO ()
Prelude> hello
Hello, World!
Prelude> hello
Hello, World!

Ici, rien n'a été affiché au niveau du let, même si on a utilisé la fonction putStrLn : on a juste créé une action hello, qui affiche "Hello, World!". Ensuite, on peut utiliser cette action plusieurs fois, et à chaque fois qu'elle est exécutée, le message est affiché.

Composer des actions avec do

Vous savez maintenant exécuter des actions simples, au moins dans ghci, mais seulement une seule à la fois. Comment faire pour exécuter plusieurs actions à la suite, ou réutiliser le résultat d'une action dans une autre action ? Pour cela il faut utiliser la notation do. On va commencer avec un exemple simple : un programme qui affiche deux lignes de texte. Ouvrez un nouveau fichier, et entrez-y le code suivant :

1
2
3
conversation = do
  putStrLn "Bonjour"
  putStrLn "Au revoir"

Maintenant, vous pouvez lancer ce code dans ghci :

1
2
3
4
5
*Main> :t conversation
conversation :: IO ()
*Main> conversation
Bonjour
Au revoir

La notation do permet de combiner plusieurs actions pour en faire une seule action. Elle garantit que les actions sont réalisées dans l'ordre spécifié : plus de problèmes avec l'évaluation paresseuse. Il est bien sûr possible de faire des fonctions qui prennent des arguments :

1
2
3
conversation nom = do
  putStrLn $ "Bonjour " ++ nom
  putStrLn "Au revoir"
1
2
3
*Main> conversation "toi"
Bonjour toi
Au revoir

La notation do permet aussi de récupérer le résultat des actions effectuées. Pour cela on utilise la flèche pour récupérer le résultat :

1
2
3
4
echo = do
  putStrLn "Entrez un mot"
  mot <- getLine
  putStrLn $ "Vous avez dit " ++ mot

Vous pouvez maintenant tester ce code dans ghci :

1
2
3
4
*Main> echo
Entrez un mot
banane
Vous avez dit banane

Vous pouvez définir des valeurs intermédiaires avec let. Cependant, la syntaxe est différente d'un let normal : il n'y a pas besoin d'indiquer "in" à la fin :

1
2
3
4
5
retourner = do
  putStrLn "Entrez un mot"
  mot <- getLine
  let envers = reverse mot
  putStrLn $ "Dites plutôt " ++ envers

Enfin, vous devez savoir que le résultat de la dernière action est renvoyé. Il est donc possible de factoriser le code de cette façon :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
lireMot = do
  putStrLn "Entrez un mot"
  getLine

echo = do
  mot <- lireMot
  putStrLn $ "Vous avez dit " ++ mot

retourner = do
  mot <- lireMot
  let envers = reverse mot
  putStrLn $ "Dites plutôt " ++ envers

Mais imaginons que vous ne voulez pas renvoyer la dernière valeur. Dans ce cas, vous ne pouvez pas écrire un code comme :

1
2
3
4
5
lireMot = do
  putStrLn "Entrez un mot"
  x <- getLine
  putStrLn "Merci !"
  x

En effet, il n'est possible de mettre que des actions IO dans do, et x n'est pas une action. Heureusement, il existe un moyen de transformer une valeur pure en une action qui donne cette valeur : c'est la fonction return :: a -> IO a (vous ne trouverez pas le même type si vous le demandez à ghci, et c'est normal : en réalité, cette fonction est plus générale). Vous pouvez donc écrire un code comme celui-ci :

1
2
3
4
5
lireMot = do
  putStrLn "Entrez un mot"
  x <- getLine
  putStrLn "Merci !"
  return x

Gardez tout de même à l'esprit que return n'est pas une fonction spéciale : il n'y a pas forcément besoin d'un return à la fin d'un bloc do, et un return au milieu d'un bloc do ne fait rien de spécial : l'exécution continue normalement, contrairement au return des langages comme le C.

do, structures conditionnelles et récursion

On peut aussi utiliser les structures conditionnelles classiques. Par exemple, on va créer une fonction qui demande un mot à l'utilisateur, et le compare avec un mot gardé secret :

1
2
3
4
5
6
motSecret x = do
  putStrLn "Entrez le mot secret"
  m <- getLine
  if x == m
    then return True
    else return False

Vous pouvez aussi de la même manière utiliser case … of. Attention, l'indentation est importante : si vous oubliez d'indenter le then et le else, vous risquez d'avoir une erreur de syntaxe. Maintenant, imaginons qu'on veuille mettre un message un peu plus intéressant à l'utilisateur pour indiquer si l'opération a réussi. Vous pourriez essayer d'écrire un code comme celui-ci :

1
2
3
4
5
6
7
8
motSecret x = do
  putStrLn "Entrez le mot secret"
  m <- getLine
  if x == m
    then putStrLn "Vous avez trouvé !"
         return True
    else putStrLn "Non, ce n'est pas le mot secret"
         return False

Si vous essayez d"utiliser ce code, vous allez obtenir un message d'erreur pas très clair, et qui ne correspond pas du tout à ce que vous attendez. En effet, dans les blocs do, if n'a pas de comportement spécial, et on doit mettre des expressions après then et else. Pour faire ce que vous voulez faire, vous devez donc commencer un nouveau bloc do à l'intérieur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
motSecret x = do
  putStrLn "Entrez le mot secret"
  m <- getLine
  if x == m
    then do
      putStrLn "Vous avez trouvé !"
      return True
    else do
      putStrLn "Non, ce n'est pas le mot secret"
      return False

Maintenant, on veut faire une dernière modification : si le mot entré n'est pas le bon, on veut redemander le mot. Vous pensez peut-être tout de suite à utiliser une boucle : tant que le mot entré n'est pas le bon, on demande un mot à l'utilisateur. En Haskell, on utilise la récursivité à la place : il suffit de rappeler la fonction motSecret si le mot entré n'est pas le bon :

1
2
3
4
5
6
7
8
motSecret x = do
  putStrLn "Entrez le mot secret"
  m <- getLine
  if x == m
    then putStrLn "Vous avez trouvé !"
    else do
      putStrLn "Non, ce n'est pas le mot secret."
      motSecret x

Cette idée peut servir pour des choses plus intéressantes. Par exemple, on peut coder facilement un plus ou moins de cette façon :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
plusOuMoins x xmin xmax ncoups = do
  putStrLn $ "Entrez un nombre entre " ++ show xmin ++ " et " ++ show xmax
  y <- readLn
  case compare x y of 
    LT -> do
      putStrLn "Plus petit !"
      plusOuMoins x xmin (y-1) (ncoups + 1)
    GT -> do
      putStrLn "Plus grand !"
      plusOuMoins x (y+1) xmax (ncoups + 1)
    EQ -> do
      putStrLn $ "Bravo, vous avez trouvé le nombre en " ++ show ncoups ++ " essais"

En plus de simplement demander un nombre, on garde à chaque étape l'intervalle dans lequel se trouve le nombre, et le nombre de coups qui ont été joués. On utilise aussi la fonction compare, qui compare deux nombres et renvoie LT (plus petit), GT (plus grand) ou EQ (égal). La fonction readLn combine les fonctions de getLine et read : elle lit une ligne de l'entrée standard, et applique la fonction read à la chaîne obtenue pour convertir ça dans le type qui nous intéresse (ici, en un entier).

Vous pouvez aussi utiliser cette technique pour redemander une information à l'utilisateur si il a entré quelque chose d'invalide la première fois. Par exemple, la fonction lireValide prend en argument une fonction de lecture, et redemande l'information tant que la chaîne donnée n'est pas valide (la fonction de validation doit renvoyer Nothing pour indiquer que la lecture a échoué).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
ouiNon :: String -> Maybe Bool
ouiNon s = if s' `elem` oui 
                then Just True 
           else if s' `elem` non 
                then Just False 
                else Nothing
    where oui = ["y","yes","oui","o"]
          non = ["n","no","non"]
          s' = map toLower s

lireValide lire = do
  s <- getLine
  case lire s of
    Nothing -> do
                putStrLn "Réponse invalide"
                lireValide lire
    Just r -> return r

lireOuiNon = lireValide ouiNon

Vous pouvez voir que la notation do ne permet pas de faire sortir une valeur d'une action IO : il n'est pas possible de créer une fonction de type IO a -> a. C'est une bonne nouvelle : cela signifie que les effets de bords des actions n'impacteront pas sur la partie pure du programme, puisqu'à la fin, on doit forcément renvoyer une action IO.

Plus de fonctions, compilation

Améliorons le plus ou moins

Pour commencer, nous allons améliorer le plus ou moins. Ce sera l'occasion de voir comment générer des nombres aléatoires, manipuler les fichiers, et compiler un programme.

Générer un nombre

Il n'y a pas de fonction pure pour générer un nombre aléatoire en Haskell : par principe, une telle fonction devrait renvoyer une même valeur pour les mêmes arguments, et ce n'est pas ce que l'on veut. Cependant, il est tout de même possible de générer des nombres aléatoires, en utilisant les fonctions du module System.Random. La première chose à avoir est une source de nombre aléatoires. En réalité, on ne peut pas générer de nombres vraiment aléatoires avec un ordinateur : le résultat des opérations est parfaitement déterminé. Mais il existe des techniques pour générer, à partir d'une valeur de départ, des suites de nombres qui ont l'air aléatoires. Il y a deux possibilités : vous pouvez décider de passer explicitement l'état du générateur à chaque fonction, et compter sur ces fonctions pour retourner l'état suivant du générateur. Par exemple, pour tirer deux nombres aléatoires à la suite, le code ressemblerait à ceci :

1
2
3
4
random2 :: (RandomGen g) => g -> (Int,Int,g)
random2 gen = let (a,gen2) = random gen in
              let (b,gen3) = random gen2 in
              (a,b,gen3)

Cette fonction prend donc en argument un générateur aléatoire, et doit renvoyer un générateur aléatoire pour être utilisé par la fonction suivante. Le code n'est pas très beau, et en plus vous risquez de vous tromper et d'utiliser deux fois random avec le même état, ce qui fait que vous obtiendrez deux fois le même nombre (puisque la valeur de retour ne dépend que des arguments).

Soit vous décidez d'utiliser la solution simple, et vous utilisez les fonctions de génération des nombres aléatoires retournant une valeur dans la monade IO. Ces fonctions stockent en réalité un état global du générateur de nombres aléatoires, mais il est caché et vous n'avez pas à vous en soucier. Pour générer un nombre aléatoire, utilisez simplement la fonction randomRIO en lui indiquant l'intervalle qui vous intéresse. Vous pouvez aussi utiliser la fonction randomIO, mais elle ne prend pas d'intervalle en argument et risque de vous donner des nombres beaucoup trop grands pour ce que vous voulez en faire. Vous devrez parfois aussi indiquer le type de retour que vous souhaitez (ce n'est pas nécessaire ici, car le type des arguments de randomRIO permet de déterminer son type de retour).

1
2
3
jouer xmin xmax = do
  x <- randomRIO (xmin,xmax)
  plusOuMoins x xmin xmax 0

Voilà, votre plus ou moins est capable de générer des nombres aléatoires.

Compiler votre programme

Maintenant que vos programmes sont capables de faire quelque chose en dehors de retourner une valeur, il est temps d'apprendre à les compiler. Il n'y a pas grand-chose de compliqué. D'abord, vous devez mettre une fonction main quelque part (par exemple, notre fichier PlusOuMoins.hs) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import System.Random

plusOuMoins x xmin xmax ncoups = do
  putStrLn $ "Entrez un nombre entre " ++ show xmin ++ " et " ++ show xmax
  y <- readLn
  case compare x y of 
    LT -> do
      putStrLn "Plus petit !"
      plusOuMoins x xmin (y-1) (ncoups + 1)
    GT -> do
      putStrLn "Plus grand !"
      plusOuMoins x (y+1) xmax (ncoups + 1)
    EQ -> do
      putStrLn $ "Bravo, vous avez trouvé le nombre en " ++ show (ncoups + 1) ++ " essais"
      return (ncoups + 1)

jouer :: Int -> Int -> IO Int
jouer xmin xmax = do
  x <- randomRIO (xmin,xmax)
  plusOuMoins x xmin xmax 0

main = jouer 1 100

Ensuite, pour compiler ce programme, il suffit de lancer la commande ghc --make PlusOuMoins.hs. Si vous n'obtenez pas d'erreurs, vous devriez trouver dans le dossier un exécutable appelé PlusOuMoins. Vous n'avez plus qu'à le lancer pour jouer !

Plus de fonctions d'entrées-sorties

Combiner des actions IO

Imaginons que vous voulez coder un programme qui prend du texte en entrée, et à chaque ligne de texte, renvoie cette ligne en majuscule. Il y a dans le module Data.Char une fonction toUpper qui met un caractère en majuscule. Le premier coder qui pourrait vous venir à l'esprit est celui-ci :

1
2
3
4
5
6
import Data.Char

main = do
  l <- getLine
  putStrLn $ map toUpper l
  main

Vous remarquez qu'on utilise un appel récursif à la fin de la fonction, pour que l'action se répète. Il y a une fonction pour ça, dans le module Control.Monad : c'est la fonction forever. Vous pouvez donc réécrire votre programme comme ceci :

1
2
3
4
5
6
import Data.Char
import Control.Monad

main = forever $ do
         l <- getLine
         putStrLn $ map toUpper l

Son type est forever :: IO a -> IO b (en effet, cette fonction ne devrait jamais se terminer, donc la valeur de retour n'est pas utilisée). Son type réel est un peu plus général, mais ce type devrait vous suffire pour recoder forever vous-même :

1
2
3
forever a = do
  a
  forever a

Si vous voulez utiliser votre programme sans le compiler d'abord, vous pouvez utiliser le programme runhaskell : par exemple, si vous avez enregistré votre programme dans le fichier up.hs, utilisez simplement la commande runhaskell up.hs

Comme les fonctions, les actions IO ne subissent pas de traitement particulier : il est possible de les prendre comme arguments, et de les combiner pour donner d'autres actions. D'ailleurs, Control.Monad comprend plein d'autres fonctions pour combiner des actions. Par exemple, les fonctions when et unless permettent d'exécuter une action IO conditionnellement : when condition action exécute l'action si la condition est vraie, et return () sinon, et unless fait l'inverse. Par exemple, si vous voulez créer un programme qui n'affiche que les lignes qui ne commencent pas par un espace :

1
2
3
4
5
6
import Data.List
import Control.Monad

main = forever $ do
         l <- getLine
         unless (" " `isPrefixOf` l) $ putStrLn l

La fonction sequence est aussi plutôt utile : son type est sequence :: [IO a] -> IO [a]. Elle exécute donc toutes les actions d'une liste à la suite, et donne le résultat. On peut donc l'utiliser avec toutes les fonctions qui donnent des listes, comme map. Par exemple, vous pouvez faire un programme qui compte jusqu'à 10 en utilisant map :

1
compter n = sequence $ map (putStrLn . show) [1..n]

Mais pour ce genre d'utilisation, vous pouvez utiliser directement la fonction mapM :

1
compter n = mapM (putStrLn . show) [1..n]

La fonction mapM_ peut aussi servir si vous n'avez pas besoin du résultat des fonctions (ce qui est le cas dans notre exemple) : elle retourne une valeur de type IO () (donc ignore les résultats) :

1
compter n = mapM_ (putStrLn . show) [1...n]

Vous pourrez aussi parfois voir les fonctions forM et forM_ : il s'agit de mapM et mapM_ avec leurs arguments inversés.

Entrées et sorties standard

Il y a quelques fonctions utiles pour interagir avec l'utilisateur que vous n'avez pas encore vues.

La fonction putStr fait la même chose que putStrLn mais n'insère pas de retour à la ligne automatiquement. Cela peut être utile pour faire un prompt pour demander des informations :

1
2
3
4
5
allo = do
  putStr "Dites quelque chose: "
  l <- getLine
  putStr "Vous avez dit : "
  putStrLn l

Cependant, ce code ne marche pas comme vous l'attendez : dans ghci, vous verrez bien "Dites quelque chose:", mais si vous lancez ce script avec runhaskell, il attendra une entrée, puis il affichera le message demandant l'entrée. Cela ne vient pas encore d'un problème avec l'ordre d'exécution, mais du fait que par défaut, la sortie n'est affichée qu'à chaque caractère de retour à la ligne. Si cela pose un problème, vous pouvez désactiver ce comportement de plusieurs façons. La première, c'est d'utiliser la fonction hFlush du module System.IO : quand vous voulez que la sortie soit affichée immédiatement, ajoutez simplement hFlush stdout. Sinon, vous pouvez désactiver complètement la mise en cache en exécutant l'action hSetBuffering stdout NoBuffering.

Vous pouvez rencontrer le même problème avec la fonction getChar : cette fonction attend un caractère de l'utilisateur. L'entrée standard aussi n'est lue que lorsque l'utilisateur appuie sur entrée. Pour régler ce problème, vous pouvez si besoin utiliser hSetBuffering stdin NoBuffering. Ensuite, vous pouvez créer un programme qui réagit dès que l'utilisateur appuie sur une touche. Par exemple, ce bout de programme permet de répondre par o ou n à une question sans avoir à appuyer sur entrée après :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import System.IO


main = do
  hSetBuffering stdout NoBuffering
  hSetBuffering stdin NoBuffering
  r <- ouiNon
  putStrLn $ show r

ouiNon = do
  putStr "Oui ou non? "
  c <- getChar
  putChar '\n'
  case c of
    'y' -> return True
    'n' -> return False
    _ ->  ouiNon

La fonction putChar utilisée ici permet d'afficher un caractère. Une autre fonction très pratique est la fonction print : vous écrivez souvent putStrLn (show a) ? Remplacez ce code tout simplement par print a.

Si votre programme lit toute l'entrée d'un coup, la fonction getContents peut vous être utile : elle lit toute l'entrée d'un seul coup. Par exemple, on peut facilement créer un programme qui compte les lignes d'un fichier avec getContents :

1
2
3
main = do 
  l <- getContents
  print $ length (lines l)

Ce genre de programme peut être utile si on lui passe la sortie d'un autre programme : par exemple, au lieu de compter les lignes d'un fichier avec la commande wc, vous pouvez faire cat fichier | runhaskell wc.hs (en tout cas, ça marche sous un environement type Unix). Faisons un autre test : on va créer un programme qui affiche le nombre de caractères de chaque ligne.

1
2
3
main = do 
  l <- getContents
  mapM_ (print . length) (lines l)

On teste ce programme avec un exemple (les chiffres sont ce qui est renvoyé par le programme) :

1
2
3
4
5
6
Hello, world!
13
ABC
3
haskell c'est bien
18

Surprise : au lieu d'attendre la fin de l'entrée pour donner le résultat, notre programme affiche le nombre de caractères après chaque ligne. En fait, la fonction getContents est paresseuse : au lieu de lire tout le contenu, puis de le renvoyer, elle crée une liste, et à chaque fois qu'un caractère de cette liste est demandé, elle le lit sur l'entrée standard. C'est très pratique pour un certain nombre de programmes, où on aimerait bien afficher le résultat dès que possible. Par exemple, quand on utilise des pipes pour connecter les entrées et les sorties de plusieurs programmes, on aime bien que chaque programme affiche le résultat en fonction de ce qu'il a déjà reçu, au lieu d'attendre la fin de l'entrée pour tout afficher. Dans le même esprit, il y a la fonction interact : elle prend une fonction de type String -> String, et renvoie une action IO (). On peut donc interagir en même temps avec l'entrée et la sortie, et coder notre programme de cette façon :

1
main = interact (unlines . map (show . length) . lines)

C'est plutôt court ! En fait, ce qu'on fait, c'est qu'on prend ce qui arrive en entrée, on le découpe en lignes, on compte le nombre de caractères de chaque ligne, qu'on transforme immédiatement en chaîne de caractères, et on regroupe le tout avec unlines. Avec interact, lines et unlines, il est possible de coder très rapidement des programmes qui traitent l'entrée ligne par ligne. Si vous avez du mal avec les compositions de fonctions en chaîne, vous pouvez découper un peu plus, en donnant un nom à notre fonction de traitement d'une ligne.

Par contre, il est moins facile d'utiliser ces fonctions pour des programmes qui doivent interagir plus directement avec l'utilisateur : on sait que les informations seront demandées dans l'ordre, affichées dans l'ordre, mais il est difficile de déterminer dans quel ordre exact seront faites les entrées par rapport aux sorties.

Fichiers, dossiers et ligne de commande

Manipuler des fichiers

Il est aussi possible de manipuler les fichiers. Toutes les fonctions permettant de manipuler les fichiers se trouvent dans le module System.IO. Pour ouvrir un fichier, on utilise la fonction openFile :: FilePath -> IOMode -> IO Handle. Le type FilePath est juste un autre nom pour le type String : c'est donc le nom du fichier. Le type IOMode sert à indiquer ce qu'on souhaite faire avec le fichier. Il est défini par data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode. Il définit la façon dont on veut interagir avec le fichier : le lire uniquement (ReadMode), écrire dedans (WriteMode), écrire à la fin du fichier (AppendMode), ou lire et écrire (ReadWriteMode). Ensuite, on obtient un Handle, qui représente le fichier ouvert.

Ensuite, on peut lire et modifier le fichier avec des opérations comme hGetContents, hPutChar, hPutStr, hGetLine, ou hGetChar. Ces fonctions marchent presque comme leurs versions sans h devant, sauf qu'elles prennent un paramètre supplémentaire : un Handle qui correspond au fichier que l'on veut modifier. Enfin, après avoir terminé avec un fichier, n'oubliez pas de le fermer avec la fonction hClose. Par exemple, ce programme lit un fichier, rajoute un numéro de ligne devant chaque ligne et écrit les lignes obtenues dans un deuxième fichier :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import System.IO

numeroter inp outp n = do
  t <- hIsEOF inp
  if t 
    then return () 
    else do  
      x <- hGetLine inp
      hPutStrLn outp $ show n ++ ": " ++ x
      numeroter inp outp (n+1)

main = do
  inp <- openFile "test" ReadMode
  outp <- openFile "test.num" WriteMode
  numeroter inp outp 1
  hClose inp
  hClose outp

En plus des fonctions mentionnées plus haut, on a utilisé la fonction hIsEOF : elle permet de tester si on est arrivé à la fin du fichier ou s'il reste du contenu à lire. Au lieu d'utiliser openFile et hClose, vous pouvez utiliser la fonction withFile. L'avantage de cette fonction est que le fichier est automatiquement fermé, quoi qu'il arrive. Son type est withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r : elle prend une fonction qui utilise le fichier ouvert, et lui passe le descripteur de fichier, puis ferme le fichier avant de retourner le résultat. Par exemple, on peut réécrire notre fonction main de cette façon :

1
2
3
4
main = do
  withFile "test" ReadMode 
       (\inp -> withFile "test.num" WriteMode 
        (\outp -> numeroter inp outp 1))

Enfin, vous aimerez probablement utiliser les fonctions readFile, writeFile et appendFile : la fonction readFile retourne le contenu d'un fichier, la fonction writeFile écrit le contenu qu'on lui donne dans un fichier (et écrase le contenu si le fichier existait déjà), et la fonction appendFile ajoute quelque chose à la fin d'un fichier, en le créant si nécessaire. Par exemple, on peut numéroter les lignes comme ceci :

1
2
3
main = do
  x <- readFile "test"
  writeFile "test.num" $ unlines . zipWith (\n l -> show n ++ ": " ++ l) [1..] . lines $ x

Comme avec getContents, le fichier est lu de façon paresseuse. Ce code fonctionne un peu comme les programmes qui utilisent interact : on coupe l'entrée (ici, le contenu d'un fichier) en lignes, puis on traite le contenu comme une liste de lignes, et enfin on le regroupe avant de l'afficher.

Si votre programme a besoin d'afficher des résultats à l'écran ou de les écrire dans un fichier suivant le choix de l'utilisateur, pas besoin de faire une fonction pour chaque cas : l'entrée et la sortie standard peuvent aussi être traitées comme un fichier. Utilisez simplement stdin et stdout comme Handle (respectivement pour l'entrée et la sortie)

Arguments de la ligne de commande

Si vous développez des programmes qui s'utilisent en ligne de commande, vous aurez besoin de gérer les arguments donnés à votre programme. Pour cela, il y a deux actions IO intéressantes dans le module System.Environment. La première est getProgName :: IO String, elle renvoie le nom du programme (argv[0] en C). La deuxième est getArgs :: IO [String], qui renvoie la liste des arguments. Testons ces deux fonctions :

1
2
3
4
5
6
7
8
import System.Environment
import Control.Monad

main = do
  p <- getProgName
  putStrLn $ "Nom du programme: " ++ p
  a <- getArgs
  mapM_ putStrLn a

Vous pouvez tester ce programme après l'avoir compilé :

1
2
3
4
5
$ ./args arg1 arg2 arg3
Nom du programme: args
arg1
arg2
arg3

Vous pouvez aussi passer des arguments au programme avec runhaskell : par exemple, dans notre cas, on utiliserait la commande runhaskell args.hs arg1 arg2 arg3, et le nom du programme serait args.hs.


Voilà, maintenant vous savez faire un programme complet en Haskell. Vous n'avez pas tout vu, n'hésitez pas à lire la documentation des modules qui vous semblent intéressants. Pour manipuler les chemins des fichiers, le module System.FilePath fournit quelques fonctions utiles. Enfin, si vous avez besoin de lister le contenu d'un dossier, ou de déplacer, copier, supprimer des dossiers, vous trouverez sans doute ce que vous cherchez dans le module System.Directory.