Entrée/Sortie
Le système d'entrée/sortie de Haskell est purement fonctionnel, mais possède toute la puissance expressive des langages de programmation conventionnels. Dans les langages impératifs, les programmes procèdent par des actions examinant et modifiant l'état actuel du monde. Les actions typiques incluent la lecture et la définition de variables globales, l'écriture de fichiers, la lecture d'entrées et l'ouverture de fenêtres. De telles actions font également partie de Haskell mais sont clairement séparées du coeur purement fonctionnel du langage.
Le système d'entrée/sortie de Haskell est construit autour d'une base mathématique quelque peu intimidante : la monade. Cependant, la compréhension de la théorie des monades sous-jacente n'est pas nécessaire pour programmer en utilisant le système d'entrée/sortie. Au contraire, les monades sont une structure conceptuelle dans laquelle les entrées/sorties s'insèrent. Il n'est pas plus nécessaire de comprendre la théorie des monades pour effectuer des entrées/sorties Haskell que de comprendre la théorie des groupes pour effectuer une arithmétique simple.
Les opérateurs monadiques sur lesquels le système d'entrée/sortie est construit sont également utilisés à d'autres fins; nous examinerons plus en détail les monades plus tard. Pour l'instant, nous éviterons le terme monade et nous concentrerons sur l'utilisation du système d'entrée/sortie. Il est préférable de considérer la monade d'entrée/sortie comme un simple type de données abstrait.
Les actions sont définies plutôt qu'appelées dans le langage d'expression de Haskell. L'évaluation de la définition d'une action ne provoque pas réellement l'exécution de l'action. Au contraire, l'invocation des actions a lieu en dehors de l'évaluation de l'expression que nous avons considérée jusqu'à présent.
Les actions sont soit atomiques, comme définies dans les primitives système, soit une composition séquentielle d'autres actions. La monade d'entrée/sortie contient des primitives qui construisent des actions composites, un processus similaire à la jonction d'instructions dans l'ordre séquentiel à l'aide de «;» dans d'autres langages. Ainsi, la monade sert de colle liant ensemble les actions d'un programme.
Opérations d'entrée/sortie de base
Chaque action d'entrée/sortie renvoie une valeur. Dans le système de types, la valeur de retour est « étiquetée » avec le type d'entrée/sortie, ce qui distingue les actions des autres valeurs. Par exemple, le type de la fonction getChar est :
- getChar :: IO Char
La fonction IO Char indique que getChar, lorsqu'elle est appelée, exécute une action renvoyant un caractère. Les actions ne renvoyant aucune valeur intéressante utilisent le type d'unité (). Par exemple, la fonction putChar :
- putChar :: Char -> IO ()
prend un caractère comme paramètre mais ne renvoie rien d'utile. Le type d'unité est similaire à void dans d'autres langages.
Les actions sont séquencées à l'aide d'un opérateur au nom plutôt cryptique : >>= (ou `bind'). Au lieu d'utiliser directement cet opérateur, nous choisissons un peu de sucre syntaxique, la notation do, pour cacher ces opérateurs de séquencement sous une syntaxe ressemblant à des langages plus conventionnels. La notation do peut être trivialement étendue à >>=.
Le mot-clef do introduit une séquence d'instructions étant exécutées dans l'ordre. Une instruction est soit une action, un motif lié au résultat d'une action en utilisant <-, soit un ensemble de définitions locales introduites en utilisant let. La notation do utilise la mise en page de la même manière que let ou where, nous pouvons donc omettre les accolades et les points-virgules avec une indentation appropriée. Voici un programme simple pour lire puis imprimer un caractère :
- main :: IO ()
- main = do c <- getChar
- putChar c
L'utilisation du nom main est importante : main est défini comme étant le point d'entrée d'un programme Haskell (similaire à la fonction main en C), et doit avoir un type IO, généralement IO(). (Le nom main n'est spécial que dans le module Main; nous reviendrons sur les modules plus tard.) Ce programme effectue deux actions en séquence : il lit d'abord un caractère, lie le résultat à la variable c, puis affiche le caractère. Contrairement à une expression let où les variables sont étendues sur toutes les définitions, les variables définies par <- ne sont étendues que dans les instructions suivantes.
Il manque encore un élément. Nous pouvons invoquer des actions et examiner leurs résultats en utilisant do, mais comment renvoyer une valeur à partir d'une séquence d'actions ? Par exemple, considérons la fonction ready lisant un caractère et renvoie True si le caractère était un «y» :
- ready :: IO Bool
- ready = do c <- getChar
- c == 'y' -- Mauvais!!!
Cela ne fonctionne pas car la deuxième instruction de «do» n'est qu'une valeur booléenne, pas une action. Nous devons prendre ce booléen et créer une action qui ne fait rien d'autre que renvoyer le booléen comme résultat. La fonction return fait exactement cela :
- return :: a -> IO a
La fonction return complète l'ensemble des primitives de séquençage. La dernière ligne de ready doit être return (c == 'y').
Nous sommes maintenant prêts à examiner des fonctions d'entrée/sortie plus complexes. Tout d'abord, 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 introduit une chaîne de caractères unique d'instructions. Toute construction intermédiaire, telle que if, doit utiliser un nouveau do pour initier d'autres séquences d'actions.
La fonction return admet une valeur ordinaire telle qu'un booléen dans le domaine des actions d'entrées/sorties. Qu'en est-il de l'autre sens ? Pouvons-nous appeler des actions d'entrée/sortie dans une expression ordinaire ? Par exemple, comment pouvons-nous dire x + print y dans une expression de sorte que y soit affiché au fur et à mesure de l'évaluation de l'expression ? La réponse est que nous ne pouvons pas ! Il n'est pas possible de se faufiler dans l'univers impératif au milieu d'un code purement fonctionnel. Toute valeur «infectée» par le monde impératif doit être étiquetée comme telle. Une fonction telle que :
- f :: Int -> Int -> Int
ne peut absolument pas faire d'entrée/sortie puisque IO n'apparaît pas dans le type renvoyé. Ce fait est souvent assez pénible pour les programmeurs habitués à placer des instructions print généreusement dans leur code pendant le débogage. Il existe en fait des fonctions non sécurisées disponibles pour contourner ce problème, mais il vaut mieux les laisser aux programmeurs avancés. Les paquets de débogage (comme Trace) font souvent un usage généreux de ces «fonctions interdites» de manière entièrement sûre.
Programmation avec des actions
Les actions d'entrées/sorties 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érez cette liste d'actions :
- todoList :: [IO ()]
-
- todoList = [putChar 'a',
- do putChar 'b'
- putChar 'c',
- do c <- getChar
- putChar c]
Cette liste n'appelle pas réellement d'actions, elle les contient simplement. Pour joindre ces actions en une seule action, une fonction telle que sequence_ est nécessaire :
- sequence_ :: [IO ()] -> IO ()
- sequence_ [] = return ()
- sequence_ (a:as) = do a
- sequence as
Ceci peut être simplifié en notant que do x;y est étendu à x >> y. Ce modèle de récursivité est capturé par la fonction foldr (voir le prélude 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-jacent, >>, est plus approprié. Une compréhension des opérateurs sur lesquels do est construit est très 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)
L'une des différences entre Haskell et la programmation impérative conventionnelle peut être observée dans putStr. Dans un langage impératif, cartographier une version impérative de putChar sur la chaîne de caractères serait suffisant pour l'afficher. En Haskell, cependant, la fonction map n'effectue aucune action. Au lieu de cela, elle crée une liste d'actions, une pour chaque caractère de la chaîne de caractères. L'opération de pliage 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'il crée (surtout s'il n'y a aucun caractère dans la chaîne de caractères !).
Le Prelude et les bibliothèques contiennent de nombreuses fonctions utiles pour séquencer les actions d'entrée/sortie. Celles-ci sont généralement généralisées à des monades arbitraires ; toute fonction avec un contexte incluant Monad m => fonctionne avec le type IO.
Gestion des exceptions
Jusqu'à présent, nous avons évité le problème des exceptions pendant les opérations d'entrée/sortie. Que se passerait-il si getChar rencontre une fin de fichier ? (Nous utilisons le terme erreur pour _|_ : une condition ne pouvant pas être récupérée, comme la non-terminaison ou l'échec de correspondance de modèle. Les exceptions, en revanche, peuvent être interceptées et traitées dans la monade d'entrée/sortie.) Pour gérer les conditions exceptionnelles telles que «fichier non trouvé» dans la monade d'entrée/sortie, un mécanisme de gestion est utilisé, similaire en termes de fonctionnalités à celui de ML standard. Aucune syntaxe ou sémantique spéciale n'est utilisée; la gestion des exceptions fait partie de la définition des opérations de séquençage d'entrée/sortie.
Les erreurs sont codées à l'aide d'un type de données spécial, IOError. Ce type représente toutes les exceptions possibles pouvant se produire dans la monade d'entrée/sortie. Il s'agit d'un type abstrait : aucun constructeur pour IOError n'est disponible pour l'utilisateur. Les prédicats permettent d'interroger les valeurs IOError. Par exemple, la fonction :
- isEOFError :: IOError -> Bool
détermine si une erreur a été provoquée par une condition de fin de fichier. En rendant IOError abstrait, de nouveaux types d'erreurs peuvent être ajoutés au système sans modification notable du type de données. La fonction isEOFError est définie dans une bibliothèque séparée, IO, et doit être importée explicitement dans un programme.
Un gestionnaire d'exceptions a le type IOError -> IO a. La fonction catch associe un gestionnaire d'exceptions à une action ou à un ensemble d'actions :
- catch :: IO a -> (IOError -> IO a) -> IO a
Les paramètres de catch sont une action et un gestionnaire. Si l'action réussit, son résultat est renvoyé sans appeler le gestionnaire. Si une erreur se produit, elle est transmise au gestionnaire sous la forme d'une valeur de type IOError et l'action associée au gestionnaire est alors appelée. Par exemple, cette version de getChar renvoie une nouvelle ligne lorsqu'une erreur est rencontrée :
- getChar' :: IO Char
- getChar' = getChar `catch` (\e -> return '\n')
C'est assez grossier car toutes les erreurs sont traitées de la même manière. Si seule la fin du fichier doit être reconnue, la valeur d'erreur doit être interrogée :
- 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 génère une exception sur le gestionnaire d'exceptions suivant. Le type d'ioError est :
- ioError :: IOError -> IO a
Il est similaire à return, sauf qu'il transfère le contrôle au gestionnaire d'exceptions au lieu de passer à l'action d'entrée/sortie suivante. Les appels imbriqués à catch sont autorisés et produisent des gestionnaires d'exceptions imbriqués.
En utilisant getChar', nous pouvons redéfinir getLine pour démontrer l'utilisation de gestionnaires imbriqués :
- getLine' :: IO String
- getLine' = catch getLine'' (\err -> return ("Erreur: " ++ show err))
- where
- getLine'' = do c <- getChar'
- if c == '\n' then return ""
- else do l <- getLine'
- return (c:l)
Les gestionnaires d'erreurs imbriqués permettent à getChar' d'intercepter la fin du fichier tandis que toute autre erreur génère une chaîne de caractères commençant par "Erreur: " de getLine'.
Pour plus de commodité, Haskell fournit un gestionnaire d'exceptions par défaut au niveau le plus élevé d'un programme affichant l'exception et termine le programme.
Fichiers, canaux et descripteurs
Outre la monade d'entrée/sortie et le mécanisme de gestion des exceptions qu'elle fournit, les fonctions d'entrée/sortie dans Haskell sont pour la plupart assez similaires à celles des autres langages de programmation. Beaucoup de ces fonctions se trouvent dans la bibliothèque IO au lieu du Prelude et doivent donc être explicitement importées pour être dans la portée. De plus, beaucoup de ces fonctions sont abordées dans le rapport de la bibliothèque au lieu du rapport principal.
L'ouverture d'un fichier crée un descripteur (de type Handle) à utiliser dans les transactions d'entrée/sortie. La fermeture du descripteur (handle en anglais) ferme le fichier associé :
- type FilePath = String -- noms de chemin dans le système de fichiers
- openFile :: FilePath -> IOMode -> IO Handle
- hClose :: Handle -> IO ()
- data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
Les descripteurs peuvent également être associés à des canaux : des ports de communication n'étant pas directement attachés à des fichiers. Quelques descripteurs de canal sont prédéfinis, notamment stdin (entrée standard), stdout (sortie standard) et stderr (erreur standard). Les opérations d'entrée/sortie au niveau des caractères incluent hGetChar et hPutChar, prenant un descripteur comme paramètre. La fonction getChar utilisée précédemment peut être définie comme :
- getChar = hGetChar stdin
Haskell permet également de renvoyer l'intégralité du contenu d'un fichier ou d'un canal sous la forme d'une seule chaîne de caractères :
- getContents :: Handle -> IO String
De manière pragmatique, il peut sembler que getContents doive immédiatement lire un fichier ou un canal entier, ce qui entraîne de mauvaises performances en termes d'espace et de temps dans certaines conditions. Cependant, ce n'est pas le cas. Le point clef est que getContents renvoie une liste de caractères «paresseuse» (c'est-à-dire non stricte) (rappelons que les chaînes de caractères ne sont que des listes de caractères en Haskell), dont les éléments sont lus «à la demande» comme n'importe quelle autre liste. On peut s'attendre à ce qu'une implémentation implémente ce comportement piloté par la demande en lisant un caractère à la fois dans le fichier au fur et à mesure qu'ils sont requis par le calcul.
Dans cet exemple, un programme Haskell copie un fichier dans un autre :
- main = do fromHandle <- getAndOpenFile "Copie de :" ReadMode
- toHandle <- getAndOpenFile "Copie à :" 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 ("Impossible d'ouvrir "++ name ++ "\n")
- getAndOpenFile prompt mode)
En utilisant la fonction getContents paresseuse, il n'est pas nécessaire de lire en mémoire l'intégralité du contenu du fichier en une seule fois. Si hPutStr choisit de mettre en mémoire tampon la sortie en écrivant la chaîne de caractères en blocs de caractères de taille fixe, un seul bloc du fichier d'entrée doit être en mémoire à la fois. Le fichier d'entrée est fermé implicitement lorsque le dernier caractère a été lu.
Haskell et la programmation impérative
En guise de conclusion, la programmation d'entrée/sortie soulève un problème important : ce style ressemble étrangement à 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)
présente une similitude frappante avec le code impératif (pas dans aucun langage réel) :
- function getLine() {
- c := getChar();
- if c == `\n` then return ""
- else {l := getLine();
- return c:l}}
En fin de compte, Haskell a-t-il simplement réinventé la roue impérative ?
Dans un certain sens, oui. La monade d'entrée/sortie constitue un petit sous-langage impératif dans Haskell, et donc la composante d'entrée/sortie d'un programme peut sembler similaire au code impératif ordinaire. Mais il y a une différence importante : il n'y a pas de sémantique particulière à laquelle l'utilisateur doit faire face. En particulier, le raisonnement équationnel dans Haskell n'est pas compromis. La sensation impérative du code monadique dans un programme ne porte pas atteinte à l'aspect fonctionnel de Haskell. Un programmeur fonctionnel expérimenté devrait être capable de minimiser la composante impératif du programme, en utilisant uniquement la monade d'entrée/sortie pour une quantité minimale de séquençage de haut niveau. La monade sépare clairement les composants fonctionnels et impératifs du programme. En revanche, les langages impératifs avec des sous-ensembles fonctionnels n'ont généralement pas de barrière bien définie entre les mondes purement fonctionnel et impératif.