VII. Entrées/Sorties▲
Le système d'entrées/sorties (input/output ou I/O en anglais) dans Haskell est purement fonctionnel, mais garde tout de la puissante expressivité que l'on trouve dans les langages de programmation conventionnels. Dans les langages impératifs, les programmes procèdent par « actions » qui examinent et modifient l'état courant. Des actions typiques incluent la lecture et l'écriture de variables globales, l'écriture de fichiers, la lecture des entrées, l'ouverture de fenêtres. De telles actions sont également intégrées dans Haskell, mais sont proprement séparées du cœur purement fonctionnel du langage.
Le système d'entrées/sorties de Haskell est construit sur des fondations mathématiques quelque peu intimidantes : les « monades ». Cependant, il n'est pas nécessaire de comprendre la théorie des monades pour utiliser le système d'E/S qui repose sur cette théorie. Les monades sont une structure conceptuelle qui cadre bien avec le système d'E/S. Il n'est pas plus nécessaire de comprendre la théorie des monades pour réaliser des E/S Haskell que de comprendre la théorie des groupes pour réaliser de simples opérations arithmétiques. Une explication détaillée des monades se trouve en section 9.
Les opérateurs monadiques sur lesquels sont construits le système d'E/S ont également d'autres usages. Pour le moment, nous allons éviter le terme monade et nous concentrer sur l'utilisation du système d'E/S. Il est préférable de se représenter les monades d'E/S simplement comme un type de donnée abstrait.
Les actions sont définies, plutôt qu'invoquées, dans le langage d'expression de Haskell. L'évaluation de la définition d'une action ne déclenche pas l'action. En fait, l'invocation des actions se déroule hors de l'évaluation de l'expression que nous avons considérée jusqu'ici.
Les actions sont soit atomiques, telles que définies dans les primitives du système, soit une composition séquentielle d'autres actions. Les monades d'E/S contiennent des primitives qui construisent des actions composées, un procédé similaire à celui utilisé avec « ; » pour définir une séquence ordonnée d'instructions dans d'autres langages. Par conséquent, les monades servent de colle qui crée un lien entre les actions dans un programme.
VII-A. Opérations d’E/S de base▲
Toute action d'E/S retourne une valeur. Dans le système de typage, la valeur retournée est « typée » E/S, ce qui distingue les actions des autres valeurs. Par exemple, le type de la fonction getChar est :
getChar ::
IO
Char
Le IO Char indique que getChar, lorsqu'il est invoqué, exécute des actions qui retournent un caractère. Les actions qui ne retournent pas de valeurs dignes d'intérêt utilisent le type unitaire (). Par exemple, la fonction putChar :
putChar ::
Char
->
IO
()
prend un caractère en tant qu'argument, mais ne retourne rien d'utile. Le type unitaire est similaire à void dans d'autres langages.
Les actions sont mises en séquence en utilisant un opérateur dont le nom est quelque peu cryptique : »= (ou « lie »). Plutôt que d'utiliser cet opérateur directement, nous choisirons une friandise syntaxique, la notation do, pour cacher ces opérateurs de séquence sous une syntaxe qui ressemble plus aux langages conventionnels. La notation do peut facilement être étendue à »=, tel que décrit dans §3.14.
Le mot-clé do initie une séquence d'instructions qui sont exécutées dans l'ordre. Une instruction peut être une action, un motif lié au résultat d'une action en utilisant <-, ou un ensemble de définitions locales utilisant let ou where afin d'omettre les crochets cursifs et le point-virgule avec une indentation correcte. Voici un programme simple qui lit puis affiche un caractère :
main ::
IO
()
main =
do
c <-
getChar
putChar c
L'utilisation du nom main est importante : main est défini pour être le point d'entrée d'un programme Haskell (similaire à la fonction main en C), et doit être de type IO, en général IO (). (Le nom main n'est spécial que dans le module Main. Nous en dirons plus sur les modules ultérieurement). Ce programme exécute deux actions à la suite : premièrement il lit un caractère qu'il lie à la variable c et ensuite il affiche ce caractère. Contrairement à une expression let ou les variables sont disponibles pour toutes les définitions cadrées par let, les variables définies par <- ne sont disponibles que dans les instructions suivantes.
Il y a encore une pièce manquante. Nous pouvons invoquer des actions et examiner leurs résultats en utilisant do, mais comment faire pour retourner une valeur à partir d'une séquence d'actions? Par exemple, considérons la fonction ready qui lit un caractère et retourne True si le caractère est un « y » :
ready ::
IO
Bool
ready =
do
c <-
getChar
c ==
'y'
` Bad!!!
Ceci ne fonctionne pas, parce que la deuxième instruction dans le cadre du do n'est qu'une valeur booléenne, et non pas une action. Nous devons prendre ce booléen et créer une action qui ne fait rien, mais retourne le booléen en tant que résultat. C'est précisément ce que fait la fonction return :
return ::
a ->
IO
a
La fonction return complète la suite ordonnée de primitives. La dernière ligne de ready devrait donc être return (c == 'y'). Nous sommes maintenant prêts à aborder des fonctions d'E/S plus compliquées. Commençons par la fonction getLine :
getLine ::
IO
String
getLine =
do
c <-
getChar
if
c ==
'\n'
then
return ""
else
do
l <-
getLine
return (c :l)
Notez le deuxième do dans la clause else. Chaque do initie une chaîne simple d'instructions. Toute construction intercalée, telle que le if, doit utiliser un nouveau do pour initialiser de nouvelles séquences d'actions.
La fonction return admet une valeur ordinaire telle qu'une booléenne dans le royaume des actions d'E/S. Et dans l'autre sens, qu'en est-il? Peut-on invoquer des actions d'E/S dans une expression ordinaire? Par exemple, comment pouvons nous exprimer x + print y dans une expression de façon à ce que y soit affiché quand l'expression est évaluée? La réponse est : c'est impossible! Il n'est pas possible de pénétrer discrètement dans le monde impératif alors que l'on est en plein milieu d'un code purement fonctionnel. Toute valeur « infectée » par l'univers impératif doit être signalée comme telle. Une fonction telle que
f ::
Int
->
Int
->
Int
ne peut absolument pas réaliser d'E/S puisque IO n'apparaît pas dans le type de la valeur retournée. Cette contrainte est assez insupportable pour les programmeurs habitués à placer des instructions d'affichage librement dans leur code lors du débogage. En fait, il existe quelques fonctions non sûres qui permettent de contourner ce problème, mais il est préférable de laisser cela aux programmeurs expérimentés. Les paquets de débogage (comme Trace) font souvent une utilisation libérale de ces « fonctions interdites » d'une manière sécurisée.
VII-B. Programmer avec des actions▲
Les actions d'E/S sont des valeurs Haskell ordinaires : Elles peuvent être passées à des fonctions, placées dans des structures et utilisées comme n'importe quelle autre valeur Haskell. Considérons cette liste d'actions :
todoList ::
[IO
()]
todoList =
[putChar 'a'
,
do
putChar 'b'
putChar 'c'
,
do
c <-
getChar
putChar c]
Cette liste n'invoque, en fait, aucune action – elle se contente de les contenir. Pour réunir ces actions en une unique action, une fonction telle que sequence est requise : sequence_ est nécessaire :
sequence_ ::
[IO
()] ->
IO
()
sequence_ [] =
return ()
sequence_ (a:as) =
do
a
sequence as
Ce que l'on peut simplifier si l'on retient que do x;y est interprétée comme x » y (voir section 9.1). La fonction foldr reproduit ce motif de récursion (voir le Prélude Standard pour une définition de foldr) ; une meilleure définition de sequence_ est :
sequence_ ::
[IO
()] ->
IO
()
sequence_ =
foldr (») (return ())
La notation do est un outil utile, mais dans ce cas, l'opérateur monadique sous-tendu » est plus approprié. Une compréhension des opérateurs sur lesquels do est construit est assez utile au programmeur Haskell.
La fonction sequence_ peut être utilisée pour construire putStr à partir de putChar :
putStr ::
String
->
IO
()
putStr s =
sequence_ (map putChar s)
Une des différences entre Haskell et la programmation impérative conventionnelle est visible dans putStr. Dans un langage impératif, mapper une version impérative de putChar sur la chaîne de caractères serait suffisant pour l'afficher. Dans Haskell, cependant, la fonction map n'exécute aucune action, mais crée une liste d'actions, une par caractère dans la chaîne de caractères. L'opération foldr dans sequence_ utilise la fonction » pour combiner toutes les actions individuelles en une seule action. Le return () utilisé ici est tout à fait nécessaire – foldr a besoin d'une action nulle à la fin de la chaîne d'actions qu'elle crée (en particulier s'il n'y a pas de caractères dans la chaîne de caractères !).
Le Prélude Standard et les bibliothèques contiennent beaucoup de fonctions qui sont utiles pour mettre des actions d'E/S en séquence. Celles-ci sont généralement généralisées en monades arbitraires ; toute fonction ayant un contexte incluant Monad m => fonctionne avec le type IO.
VII-C. Gestion des exceptions▲
À ce stade, nous avons évité le sujet des exceptions durant les opérations d'E/S. Qu'arriverait-il si getChar était confronté à une fin de fichier ? Nous utilisons le terme « erreur » pour « _|_ » : une condition de laquelle on ne peut récupérer telle qu'une non-terminaison, ou une correspondance de motif négative. Les exceptions, au contraire, peuvent être appréhendées et traitées dans une monade d'E/S. Pour traiter les conditions exceptionnelles telles qu'un « fichier non trouvé » dans une monade d'E/S, un mécanisme de gestion est utilisé, dont les fonctionnalités sont similaires à celles du ML standard. Il n'y a pas de syntaxe ou de sémantique particulière ; la gestion des exceptions fait partie de la définition des opérations d'E/S mises en séquence.
Les erreurs sont encodées en utilisant un type de donnée spécial, IOError. Ce type représente toutes les exceptions possibles qui peuvent se produire dans une monade d'E/S. C'est un type abstrait : il n'y a pas de constructeur disponible pour l'utilisateur dans IOError. Les prédicats permettent de requérir les valeurs d'IOError. Par exemple, la fonction
isEOFError ::
IOError
->
Bool
détermine si une erreur a été provoquée par une fin de fichier. En faisant de IOError un type abstrait, de nouveaux types d'erreurs peuvent être ajoutés au système sans changements notables du type de donnée. La fonction isEOFError est définie dans une librairie séparée, IO, et doit être explicitement importée dans le programme.
Un gestionnaire d'exception est de type IOError -> IO a. La fonction catch associe un gestionnaire d'exception à une action ou à un ensemble d'actions :
catch ::
IO
a ->
(IOError
->
IO
a) ->
IO
a
Les arguments de catch sont une action et un gestionnaire. Si l'action se déroule avec succès, son résultat est retourné sans invoquer le gestionnaire. Si une erreur se produit, elle est passée au gestionnaire en tant que valeur de type IOError et l'action associée au gestionnaire est à son tour invoquée. Par exemple, cette version de getChar retourne une nouvelle ligne quand une erreur est rencontrée :
getChar'
::
IO
Char
getChar'
=
getChar `catch ` (\e ->
return '\n'
)
C'est plutôt abrupt puisque toutes les erreurs sont traitées de la même manière. Si c'est uniquement une fin de fichier qui doit être reconnue, la valeur de l'erreur doit être requise :
getChar'
::
IO
Char
getChar'
=
getChar `catch ` eofHandler where
eofHandler e =
if
isEofError e then
return '\n'
else
ioError e
La fonction ioError utilisée ici lance une exception au prochain gestionnaire d'exception. Le type de ioError est
ioError ::
IOError
->
IO
a
Ce qui est similaire à return sauf que cela transfert le contrôle au gestionnaire d'exception plutôt que de continuer avec l'action d'E/S suivante. Il est permis de faire des appels emboîtés à catch, ce qui produit des gestionnaires d'exception emboîtés. En utilisant getChar', nous pouvons redéfinir getLine pour démontrer l'utilisation de gestionnaires emboîtés :
getLine'
::
IO
String
getLine'
=
catch getLine''
(\err ->
return ("Error : "
++
show err))
where
getLine''
=
do
c <-
getChar'
if
c ==
'\n'
then
return ""
else
do
l <-
getLine'
return (c :l)
Les gestionnaires d'erreur emboîtés permettent à getChar' d'appréhender une fin de fichier alors que toute autre erreur aboutit à une chaîne de caractères commençant par « Error : » en provenance de getLine'. Pour faciliter le travail, Haskell fournit un gestionnaire d'exception par défaut, au niveau le plus élevé d'un programme, qui affiche l'exception et termine le programme.
VII-D. Fichiers, canaux et gestionnaires▲
À côté des monades d'E/S et du mécanisme de gestion des exceptions qu'il fournit, les utilitaires du système d'E/S dans Haskell sont, pour la plupart, assez proches de ceux des autres langages. Beaucoup de ces fonctions se trouvent dans la librairie IO plutôt que dans le Prélude. Cette librairie doit donc être importée explicitement pour être dans l'étendue (les modules et l'importation sont abordés dans la section 11). De même, la plupart de ces fonctions se trouvent dans le Library Report plutôt que dans le Haskell Report principal.
L'ouverture d'un fichier crée un « gestionnaire » (de type Handle) à utiliser dans les transactions d'E/S. La fermeture du gestionnaire ferme le fichier associé :
type
FilePath
=
String
– path names in
the file system
openFile ::
FilePath
->
IOMode ->
IO
Handle
hClose ::
Handle ->
IO
()
data
IOMode =
ReadMode |
WriteMode |
AppendMode |
ReadWriteMode
Les gestionnaires peuvent aussi être associés à des « canaux » : des ports de communication qui ne sont pas directement attachés à des fichiers. Quelques gestionnaires de canaux sont prédéfinis, y compris stdin (l'entrée standard), stdout (la sortie standard), et stderr (l'erreur standard). Les opérations d'E/S au niveau du caractère comprennent hGetChar et hPutChar, qui prennent un gestionnaire en tant qu'argument. La fonction getChar utilisée précédemment peut être redéfinie par :
getChar =
hGetChar stdin
Haskell permet également de retourner comme une chaîne de caractères unique le contenu intégral d'un fichier ou d'un canal :
getContents ::
Handle ->
IO
String
De manière pragmatique, il pourrait sembler que getContents doit lire immédiatement l'intégralité d'un fichier ou d'un canal, ce qui aurait un impact négatif sur la performance en terme de temps et de taille dans certaines circonstances. Cependant, ce n'est pas le cas. Le point clef est que getContents retourne une liste « paresseuse » (c.-à-d. non stricte) de caractères (rappelez-vous que les chaînes de caractères ne sont que des listes de caractères dans Haskell), dont les éléments sont lus « à la demande » comme pour toute autre liste. On peut attendre d'une implémentation de Haskell qu'elle implémente ce comportement « à la demande » en lisant le fichier un caractère à la fois lorsqu'ils sont requis dans le calcul informatisé.
Dans cet exemple, un programme Haskell copie un fichier sur un autre :
main =
do
fromHandle <-
getAndOpenFile "Copy from : "
ReadMode
toHandle <-
getAndOpenFile "Copy to : "
WriteMode
contents <-
hGetContents fromHandle
hPutStr toHandle contents
hClose toHandle
putStr "Done."
getAndOpenFile ::
String
->
IOMode ->
IO
Handle
getAndOpenFile prompt mode =
do
putStr prompt
name <-
getLine
catch (openFile name mode)
(\_
->
do
putStrLn ("Cannot open "
++
name ++
"\n"
)
getAndOpenFile prompt mode)
En utilisant la fonction paresseuse getContents, le transfert à la mémoire de l'intégralité du contenu du fichier n'est pas requis en une seule fois. Si hPutStr choisit de mettre en tampon la sortie, en écrivant par blocs de taille fixe la chaîne de caractères, il n'y aura en mémoire qu'un seul bloc à la fois. Le fichier en entrée est fermé implicitement quand le dernier caractère a été lu.
VII-E. Haskell et la programmation impérative▲
Un dernier mot, la programmation d'E/S soulève une question importante : il y a suspicion de ressemblance avec la programmation impérative ordinaire. Par exemple, la fonction getLine :
getLine =
do
c <-
getChar
if
c ==
'\n'
then
return ""
else
do
l <-
getLine
return (c :l)
exhibe une saisissante ressemblance avec le code impératif :
function getLine() {
c :
=
getChar();
if
c ==
'\n'
then
return ""
else
{l :
=
getLine();
return c :l}}
Alors, finalement, est-ce que Haskell a simplement réinventé la roue impérative?
Oui, en quelque sorte. Les monades d'E/S constituent un petit sous-langage impératif à l'intérieur de Haskell, et par conséquent, les composants d'E/S d'un programme peuvent sembler similaires à du code impératif ordinaire. Mais il y a une différence importante : il n'y a pas de sémantique particulière avec laquelle l'utilisateur devrait jongler. En particulier, le raisonnement par équations dans Haskell n'est pas compromis. L'impression d'impératif que laisse le code monadique dans un programme n'amoindrit pas les aspects fonctionnels de Haskell. Un programmeur fonctionnel expérimenté devrait être capable de minimiser les composants impératifs d'un programme, en limitant les monades d'E/S à une quantité minime de mises en séquence à un haut niveau. Les monades séparent proprement les composants fonctionnels et impératifs d'un programme ; contrairement aux langages impératifs fournissant des sous-ensembles fonctionnels, mais qui n'ont pas de barrière bien définie entre l'univers purement fonctionnel et impératif.