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
mul2 x = x*2

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
mul2debug x = do
    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 ()
handleRoute msg = do
    liftIO $ L.putStrLn $ "sending: " <> msg
    text msg

main :: IO ()
main = 
    scotty 3000 $ do
        get "/" $ handleRoute "this is /"
        get "/info" $ handleRoute "this is /info"

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)
readName = do
    putStrLn "name?"
    Just <$> getLine

readAge :: IO (Maybe Int)
readAge = do
    putStrLn "age?"
    readMaybe <$> getLine

readHaskeller :: IO (Maybe Bool)
readHaskeller = do
    putStrLn "haskeller?"
    readMaybe <$> getLine

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 ()
main = do
    maybeName <- readName
    case maybeName of
        Nothing -> putStrLn "error"
        Just name -> do
            maybeAge <- readAge
            case maybeAge of
                Nothing -> putStrLn "error"
                Just age -> do
                    maybeHaskeller <- readHaskeller
                    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
readName = Just

readAge :: String -> Maybe Int
readAge = readMaybe 

readHaskeller :: String -> Maybe Bool
readHaskeller = readMaybe 

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 ()
main = do
    putStrLn "name?"
    strName <- getLine
    putStrLn "age?"
    strAge <- getLine
    putStrLn "haskeller?"
    strHaskeller <- getLine

    let maybeNameAgeHaskeller = do
            name <- readName strName
            age <- readAge strAge
            haskeller <- readHaskeller strHaskeller
            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
readName = MaybeT $ do
    putStrLn "name?"
    Just <$> getLine

readAge :: MaybeT IO Int
readAge = MaybeT $ do
    putStrLn "age?"
    readMaybe <$> getLine

readHaskeller :: MaybeT IO Bool
readHaskeller = MaybeT $ do
    putStrLn "haskeller?"
    readMaybe <$> getLine

On peut alors exécuter notre pile “Maybe / IO”, avec runMaybeT.

main :: IO ()
main = do
    maybeNameAgeHaskeller <- runMaybeT $ do
        name <- readName 
        age <- readAge 
        haskeller <- readHaskeller 
        lift $ putStrLn "ok"
        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?
douze
error

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
myState0 = MyState [42, 13, 42, 37]

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)
        (n:ns) -> 
            let r1 = if n==42 then 1 else 0
                s1 = MyState ns
            in (r1, s1)

app :: MyState -> ((Int, Int), MyState)
app s0 = 
    let (c1, s1) = count42 s0
        (c2, s2) = count42 s1
    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 ()
main = do
    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
count42 = do
    s0 <- get
    case _numbers s0 of
        [] -> return 0
        (n:ns) -> do
            let r1 = if n==42 then 1 else 0
                s1 = MyState ns
            put s1
            return r1

app :: State MyState (Int, Int)
app = do
    c1 <- count42
    c2 <- count42
    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 ()
main = do
    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)
app s0 = do
    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 ()
main = do
    (r, s) <- app myState0
    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)
app = do
    c1 <- count42
    lift $ putStrLn $ "count42: " <> show c1
    c2 <- count42
    lift $ putStrLn $ "count42: " <> show c2
    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
count42 = do
    s0 <- get
    case _numbers s0 of
        [] -> return 0
        (n:ns) -> do
            let r1 = if n==42 then 1 else 0
                s1 = MyState ns
            put s1
            return r1

Enfin, comme app empile un StateT sur IO, on peut l’exécuter dans la fonction main, avec runStateT.

main :: IO ()
main = do
    (r, s) <- runStateT app myState0
    putStrLn $ "result: " <> show r
    putStrLn $ "state: " <> show s

A l’exécution :

$ runghc MainCount3.hs 
count42: 1
count42: 0
result: (1,0)
state: MyState {_numbers = [42,37]}

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)
runParser = runStateT 

itemP :: Parser Char
itemP = do
    c:cs <- get
    put cs
    return 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)
app = do
    c1 <- lift count42
    lift $ lift $ putStrLn $ "count42: " <> show c1
    c2 <- lift count42
    guard (c2 /= 0)
    lift $ lift $ putStrLn $ "count42: " <> show c2
    return (c1, c2)

main :: IO ()
main = do
    (r, s) <- runStateT (runMaybeT app) myState0
    putStrLn $ "result: " <> show r
    putStrLn $ "state: " <> show s

A l’exécution :

$ runghc MainStack.hs 
count42: 1
result: Nothing
state: MyState {_numbers = [42,37]}

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.