Introduction aux transformateurs de monades, en Haskell
Voir aussi : video youtube - video peertube - code source
En Haskell, une monade permet de définir une suite d’actions dans un contexte particulier (par exemple, des entrées/sorties, des communications HTTP, des calculs pouvant échouer, etc). Les transformateurs de monades permettent d’intégrer un contexte dans un autre, et ainsi de structurer le code via une pile de monades bien définie et vérifiée par le système de types.
Cet article présente le principe des transformateurs de monades en Haskell, ainsi que quelques transformateurs classiques et des exemples d’utilisation. Cependant, il ne traite pas de leur fonctionnement interne ni de leur utilisation pour structurer une vraie base de code.
Présentation
Rappels sur IO
Haskell est un langage purement fonctionnel, ce qui signifie qu’une fonction prend une entrée et produit une sortie, sans faire d’effet de bord.
mul2 :: Int -> Int
= x*2 mul2 x
Si on veut réaliser des effets de bord, comme par exemple un affichage, on doit utiliser la monade IO
, et l’indiquer dans la définition de la fonction.
mul2debug :: Int -> IO Int
= do
mul2debug x putStrLn $ "mul2debug " <> show x
return $ x*2
Cette fonction ne peut alors être exécutée que dans un contexte compatible, ici IO
. Ceci garantit qu’on introduit pas un effet de bord d’un contexte non-mentionné.
Exemple avec Scotty
La bibliothèque Scotty permet d’écrire des serveurs web (voir le tutoriel 16). Pour cela, on implémente notamment la gestion des routes, avec la monade ActionM
.
{-# LANGUAGE OverloadedStrings #-}
import Control.Monad.IO.Class
import Data.Text.Lazy.IO as L
import Web.Scotty
handleRoute :: Text -> ActionM ()
= do
handleRoute msg $ L.putStrLn $ "sending: " <> msg
liftIO
text msg
main :: IO ()
=
main 3000 $ do
scotty "/" $ handleRoute "this is /"
get "/info" $ handleRoute "this is /info" get
On remarque ici, que la fonction handleRoute
effectue également un affichage, via la fonction putStrLn
(de la monade IO
). En effet, ActionM
est défini par :
type ActionM = ActionT Text IO
C’est-à-dire par le transformateur de monade ActionT
basé sur IO
. Ainsi, ActionM
apporte des fonctionnalités pour gérer des requêtes HTTP mais permet également d’utiliser les fonctionnalités de la monade IO
, sur laquelle elle se base, via la fonction liftIO
.
Résumé sur les transformateurs de monades
Un transformateur de monade est un type qui permet de créer une nouvelle monade “par-dessus” une ancienne monade. On peut ainsi y utiliser les fonctions de la nouvelle monade mais également celles de la monade de base, via un “lift”.
Haskell propose plusieurs bibliothèques qui implémentent des transformateurs classiques : transformers, mtl.
Le transformateur MaybeT
Cette section est vaguement inspirée de l’article Monad Transformers de Monday Morning Haskell.
Version 1 : travailler dans IO
On veut saisir des informations utilisateurs au clavier : nom, age, utilisation du langage Haskell. Ces saisies peuvent échouer : par exemple si on entre un age qui n’est pas un Int
.
Une première solution est d’écrire des fonctions de saisie, qui retournent des Maybe
et “travaillent dans IO
” :
readName :: IO (Maybe String)
= do
readName putStrLn "name?"
Just <$> getLine
readAge :: IO (Maybe Int)
= do
readAge putStrLn "age?"
<$> getLine
readMaybe
readHaskeller :: IO (Maybe Bool)
= do
readHaskeller putStrLn "haskeller?"
<$> getLine readMaybe
On peut ensuite appeler ces fonctions “dans un IO
”. Cependant, si on veut tester successivement la réussite des saisies, alors on doit imbriquer les vérifications, ce qui rend le code peu lisible.
main :: IO ()
= do
main <- readName
maybeName case maybeName of
Nothing -> putStrLn "error"
Just name -> do
<- readAge
maybeAge case maybeAge of
Nothing -> putStrLn "error"
Just age -> do
<- readHaskeller
maybeHaskeller case maybeHaskeller of
Nothing -> putStrLn "error"
Just haskeller ->
print (name, age, haskeller)
Version 2 : travailler dans un Maybe
Une autre solution est d’écrire des fonctions de saisies permettant de travailler dans un Maybe
.
readName :: String -> Maybe String
= Just
readName
readAge :: String -> Maybe Int
= readMaybe
readAge
readHaskeller :: String -> Maybe Bool
= readMaybe readHaskeller
On peut alors effectuer les IO
pour récupérer les données, puis les traiter les saisies dans un Maybe
, puis vérifier le tout.
main :: IO ()
= do
main putStrLn "name?"
<- getLine
strName putStrLn "age?"
<- getLine
strAge putStrLn "haskeller?"
<- getLine
strHaskeller
let maybeNameAgeHaskeller = do
<- readName strName
name <- readAge strAge
age <- readHaskeller strHaskeller
haskeller return (name, age, haskeller)
case maybeNameAgeHaskeller of
Nothing -> putStrLn "error"
Just info -> print info
Cependant le problème de cette solution est qu’elle effectue d’abord tous les getLine
avant de traiter les données, ce qui pourrait être évité plus tôt en cas d’erreur.
Version 3 : avec MaybeT
Enfin, une solution est d’utiliser le transformateur MaybeT
pour “travailler dans un Maybe
” mais “basé sur IO
”. Ainsi, on peut écrire des fonctions de saisie qui construisent des Maybe
tout en effectuant des IO
.
readName :: MaybeT IO String
= MaybeT $ do
readName putStrLn "name?"
Just <$> getLine
readAge :: MaybeT IO Int
= MaybeT $ do
readAge putStrLn "age?"
<$> getLine
readMaybe
readHaskeller :: MaybeT IO Bool
= MaybeT $ do
readHaskeller putStrLn "haskeller?"
<$> getLine readMaybe
On peut alors exécuter notre pile “Maybe / IO”, avec runMaybeT
.
main :: IO ()
= do
main <- runMaybeT $ do
maybeNameAgeHaskeller <- readName
name <- readAge
age <- readHaskeller
haskeller $ putStrLn "ok"
lift return (name, age, haskeller)
case maybeNameAgeHaskeller of
Nothing -> putStrLn "error"
Just info -> print info
Comme la pile de monades est une monade, une erreur dans une saisie est détectée immédiatement et interrompt les saisies suivantes.
$ runghc MainMaybe3.hs
?
name
john?
age
douzeerror
Le transformateur StateT
Simuler un état mutable avec des fonctions pures
Imaginons maintenant qu’on veuille écrire un programme comportant un état qui évolue au cours du programme, par exemple une liste de nombres que l’on consomme. Pour implémenter cela, on peut représenter l’état par un type.
newtype MyState = MyState
_numbers :: [Int]
{deriving Show }
On peut alors construire l’état initial au début du programme, ou le prédéfinir.
myState0 :: MyState
= MyState [42, 13, 42, 37] myState0
Comme Haskell est purement fonctionnel, on ne peut pas modifier un état courant. Mais on peut prendre en paramètre un état et retourner un nouvel état.
count42 :: MyState -> (Int, MyState)
=
count42 s0 case _numbers s0 of
-> (0, s0)
[] :ns) ->
(nlet r1 = if n==42 then 1 else 0
= MyState ns
s1 in (r1, s1)
app :: MyState -> ((Int, Int), MyState)
=
app s0 let (c1, s1) = count42 s0
= count42 s1
(c2, s2) in ((c1, c2), s2)
Ici, count42
consomme un nombre dans l’état “courant” et le retourne 1 si on a consommé le nombre 42 (et retourne 0 si on a consommé un autre nombre ou s’il n’y a plus de nombre à consommer). Elle prend donc en paramètre l’état et retourne un tuple avec le résultat et le nouvel état. L’application principale app
effectue les traitements voulus (ici, deux appels à count42
), en utilisant les états successifs.
La fonction principale se résume alors à appeler app
sur l’état initial.
main :: IO ()
= do
main let (r, s) = app myState0
putStrLn $ "result: " <> show r
putStrLn $ "state: " <> show s
Avec la monade State
Haskell propose la monade State
qui implémente un contexte d’état mutable. Ceci évite d’avoir à gérer explicitement les états successifs dans les fonctions. En effet, on a juste à indiquer que la fonction travaille dans un State
, et à indiquer le type de l’état et le type de la valeur de retour. Pour connaitre la valeur de l’état ou la changer, on a des fonctions comme get
et put
.
count42 :: State MyState Int
= do
count42 <- get
s0 case _numbers s0 of
-> return 0
[] :ns) -> do
(nlet r1 = if n==42 then 1 else 0
= MyState ns
s1
put s1return r1
app :: State MyState (Int, Int)
= do
app <- count42
c1 <- count42
c2 return (c1, c2)
On notera qu’on peut utliser la notation do
, puisque State
est une monade.
Enfin, la fonction runState
permet de lancer notre application sur l’état initial.
main :: IO ()
= do
main let (r, s) = runState app myState0
putStrLn $ "result: " <> show r
putStrLn $ "state: " <> show s
Ajouter des IO
Ainsi, la monade State
permet de définir et de manipuler des états mutables. Cependant, elle est difficile à étendre. Par exemple, on ne peut pas effectuer des IO dans un contexte de State
, on doit travailler dans un contexte IO
et, de nouveau, gérer les états successifs manuellement.
app :: MyState -> IO ((Int, Int), MyState)
= do
app s0 let (c1, s1) = runState count42 s0
putStrLn $ "count42: " <> show c1
let (c2, s2) = runState count42 s1
putStrLn $ "count42: " <> show c2
return ((c1, c2), s2)
main :: IO ()
= do
main <- app myState0
(r, s) putStrLn $ "result: " <> show r
putStrLn $ "state: " <> show s
Avec le transformateur StateT
Le transformateur StateT
permet d’empiler les fonctionnalités de State
sur une monade de base. En réalité, c’est même plutôt l’inverse : State
est l’empilement de StateT
sur la monade neutre Identity
.
type State s = StateT s Identity
Concrètement, on peut définir StateT de la façon suivante :
newtype StateT s m a = StateT { runStateT :: s -> m (a,s) }
On remarque que notre fonction app
précédente est tout simplement une spécialisation de runStateT
(où s = MyState
, m = IO
et a = (Int, Int)
). On peut donc écrire app
comme un StateT MyState IO (Int, Int)
.
app :: StateT MyState IO (Int, Int)
= do
app <- count42
c1 $ putStrLn $ "count42: " <> show c1
lift <- count42
c2 $ putStrLn $ "count42: " <> show c2
lift return (c1, c2)
On peut alors utiliser les fonctionnalités de StateT
ainsi que celles de IO
, récupérer depuis la monade de base avec lift
.
De même, on peut écrire la fonction count42
comme un StateT
sur n’importe quelle monade de base, car elle n’utlise aucune fonctionnalité de celle-ci.
count42 :: Monad m => StateT MyState m Int
= do
count42 <- get
s0 case _numbers s0 of
-> return 0
[] :ns) -> do
(nlet r1 = if n==42 then 1 else 0
= MyState ns
s1
put s1return r1
Enfin, comme app
empile un StateT
sur IO
, on peut l’exécuter dans la fonction main
, avec runStateT
.
main :: IO ()
= do
main <- runStateT app myState0
(r, s) putStrLn $ "result: " <> show r
putStrLn $ "state: " <> show s
A l’exécution :
$ runghc MainCount3.hs
: 1
count42: 0
count42: (1,0)
result: MyState {_numbers = [42,37]} state
Digression sur les parseurs
On peut voir un parseur comme un StateT
où l’état est le texte d’entrée (String
) et où la monade de base est un contexte de calcul pouvant échouer (Maybe
). Ainsi, le parseur présenté au tutoriel 57 peut s’écrire très simplement, avec StateT
(voir aussi la doc sur StateT).
type Parser = StateT String Maybe
runParser :: Parser a -> String -> Maybe (a, String)
= runStateT
runParser
itemP :: Parser Char
= do
itemP :cs <- get
c
put csreturn c
...
Empiler des transformateurs
Les transformateurs permettent de créer une pile de monades. Sur l’exemple avec StateT
, on avait un StateT
sur un IO
, ce qui nous permettait de compter les nombres 42 et de faire des affichages. Si on veut ajouter un contexte de calcul pouvant échouer (par exemple, on veut assurer que le deuxième comptage ne donne pas 0, en utilisant guard
), alors on peut empiler un MaybeT
.
app :: MaybeT (StateT MyState IO) (Int, Int)
= do
app <- lift count42
c1 $ lift $ putStrLn $ "count42: " <> show c1
lift <- lift count42
c2 /= 0)
guard (c2 $ lift $ putStrLn $ "count42: " <> show c2
lift return (c1, c2)
main :: IO ()
= do
main <- runStateT (runMaybeT app) myState0
(r, s) putStrLn $ "result: " <> show r
putStrLn $ "state: " <> show s
A l’exécution :
$ runghc MainStack.hs
: 1
count42: Nothing
result: MyState {_numbers = [42,37]} state
Conclusion
Un transformateur de monade permet d’intégrer une monade à une monade de base. Ceci permet notamment de définir une pile de monades, via le système de types, et ainsi de structurer le code. Les transformateurs de monades sont un outil classique en Haskell mais qui a tendance à rendre le code moins lisible au fur et à mesure des empilements de monades.