Le design pattern ReaderT, en Haskell

Voir aussi : video youtube - video peertube - code source

Le design pattern ReaderT est une architecture de code classique en Haskell. Elle permet de séparer la couche métier de l’environnement d’exécution, tout en explicitant les fonctionnalités nécessaires.

Architecture de base, avec ReaderT

Comme son nom l’indique, le design pattern ReaderT est basé sur le transformateur de monade ReaderT. Ce transformateur permet de définir :

Pour cet article, on veut manipuler une liste d’utilisateurs prédéfinie et écrire des messages de log. La couche métier se résume donc à définir un type représentant un utilisateur, avec son nom et son age.

data User = User
    { userName :: String
    , userAge :: Int
    } deriving (Show)

On définit également un type pour l’environnement d’exécution, avec la fonction de log et la liste d’utilisateurs.

data Env1 = Env1
    { env1Log :: String -> IO ()
    , env1Users :: [User]
    }

On peut ensuite définir quelques fonctions de base, accédant à notre environnement.

readUsers1 :: (MonadReader Env1 m) => m [User]
readUsers1 = asks env1Users

logMsg1 :: (MonadReader Env1 m, MonadIO m) => String -> m ()
logMsg1 msg = do
    env <- ask
    liftIO $ env1Log env msg

Ici, on utilise le “style MTL” (voir le tutoriel 60). Par exemple, readUsers1 est une action dans une pile de monades m. Cette action récupère (avec asks) la liste des utilisateurs (avec env1Users). Elle peut donc fonctionner dans n’importe quelle pile de monades m respectant la contrainte de types MonadReader Env1 m. De même pour logMsg1, où l’on a également besoin de la contrainte MonadIO, pour appeler env1Log (une action de IO).

On peut ensuite définir une application et un environnement d’exécution.

app1 :: (MonadReader Env1 m, MonadIO m) => m ()
app1 = do
    logMsg1 "begin app1"
    users <- readUsers1
    logMsg1 $ show users
    logMsg1 "end app1"

env1 :: Env1
env1 = Env1
    { env1Log = putStrLn
    , env1Users = [User "Pedro" 13, User "John" 42]
    }

Et enfin lancer l’exécution, avec la fonction runReaderT.

$ ghci main1.hs 

ghci> runReaderT app1 env1
begin app1
[User {userName = "Pedro", userAge = 13},User {userName = "John", userAge = 42}]
end app1

Cette architecture permet déjà de structurer le code et de le faire évoluer un peu. Par exemple, on peut définir un autre environnement d’exécution, avec une fonction de log prédéfinie et sans gestion d’utilisateur, ainsi qu’une application correspondante.

data Env2 = Env2

logMsg2 :: (MonadReader Env2 m, MonadIO m) => String -> m ()
logMsg2 msg =  liftIO $ putStrLn $ "-> " <> msg

app2 :: (MonadReader Env2 m, MonadIO m) => m ()
app2 = do
    logMsg2 "begin app2"
    logMsg2 "end app2"

Ici, app2 peut être exécutée sur un environnement Env2 mais pas sur un environnement Env1.

ghci> runReaderT app2 Env2
-> begin app2
-> end app2

ghci> runReaderT app2 env1
<interactive>:11:12: error:
Couldn't match typeEnv1’ with ‘Env2
...

En effet, les applications sont définies explicitement pour leur environnement respectif. Cependant, comme app2 ne fait que des logs et que Env1 fournit également cette fonctionnalité, on aimerait pouvoir exécuter app2 également sur un Env1.

Les classes de types Has*

Principe

Une approche classique consiste à définir des classes de types pour les différentes fonctionnalités, plutôt que d’accéder directement à un type bien particulier (un peu comme les interfaces en POO). Par exemple, la classe HasUsers suivante permet de définir qu’un type propose une fonction getUsers retournant une liste d’utilisateurs. On peut alors instancier cette classe pour Env1 et écrire la fonction de base readUsers de façon plus générale.

class HasUsers a where
    getUsers :: a -> [User]
instance HasUsers Env1 where
    getUsers = env1Users

readUsers :: (HasUsers e, MonadReader e m) => m [User]
readUsers = asks getUsers

En effet, readUsers n’est pas limité à des m de classe MonadReader Env1 mais accepte n’importe quel MonadReader ee est de classe HasUsers.

De même, on écrit une classe HasLog pour implémenter la fonctionnalité “récupérer une fonction de log”, on l’instancie pour Env1 et on l’utilise pour écrire la fonction de base logMsg.

class HasLog a where
    getLog :: a -> (String -> IO ())
instance HasLog Env1 where
    getLog = env1Log

logMsg :: (HasLog e, MonadReader e m, MonadIO m) => String -> m ()
logMsg msg =  do
    env <- ask
    liftIO $ getLog env msg

Enfin, on change les fonctions de base dans app1, qu’on peut exécuter sur env1 comme précédemment.

app1 :: (HasLog e, HasUsers e, MonadReader e m, MonadIO m) => m ()
app1 = do
    logMsg "begin app1"
    users <- readUsers
    logMsg $ show users
    logMsg "end app1"

env1 :: Env1
env1 = ...

Mais désormais, on peut également utiliser HasLog pour Env2, avec une simple instance.

instance HasLog Env2 where
    getLog _ msg = putStrLn $ "-> " <> msg

app2 :: (HasLog e, MonadReader e m, MonadIO m) => m ()
app2 = do
    logMsg "begin app2"
    logMsg "end app2"

Ainsi, app2 fonctionne pour un MonadReader e me n’est plus un type particulier mais n’importe quel type de classe HasLog, donc ici Env1 et Env2.

$ ghci main2.hs

ghci> runReaderT app2 Env2
-> begin app2
-> end app2

ghci> runReaderT app2 env1
begin app2
end app2

On dit parfois que cette approche est évolutive selon deux dimensions car on peut ajouter des environnements sans changer les fonctions de base existantes, et on peut ajouter des fonctionnalités sans changer les environnements existants.

La bibliothèque RIO

RIO est une bibliothèque standard alternative, pour écrire des applications. Elle se base sur le design pattern ReaderT avec l’approche des “classes de types Has”. Par exemple, on peut réécrire le code précédent de la façon suivante.

{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE FlexibleInstances #-}
import RIO
import System.IO (putStrLn)

data User = User
    { userName :: String
    , userAge :: Int
    } deriving (Show)

data Env1 = Env1
    { env1Log :: String -> IO ()
    , env1Users :: [User]
    }

class HasUsers a where
    getUsers :: a -> [User]
instance HasUsers Env1 where
    getUsers = env1Users

readUsers :: (HasUsers e) => RIO e [User]
readUsers = asks getUsers

class HasLog a where
    getLog :: a -> (String -> IO ())
instance HasLog Env1 where
    getLog = env1Log

logMsg :: (HasLog e) => String -> RIO e ()
logMsg msg = do
    env <- ask
    liftIO $ getLog env msg

app1 :: (HasLog e, HasUsers e) => RIO e ()
app1 = do
    logMsg "begin app1"
    users <- readUsers
    logMsg $ show users
    logMsg "end app1"

env1 :: Env1
env1 = Env1
    { env1Log = putStrLn
    , env1Users = [User "Pedro" 13, User "John" 42]
    }

Exemple d’exécution :

$ ghci main2rio.hs 

ghci> runRIO env1 app1
begin app1
[User {userName = "Pedro", userAge = 13},User {userName = "John", userAge = 42}]
end app1

Réduire les dépendances via des monades

L’approche des “classes de types Has” peut introduire des dépendances injustifiées. Par exemple, la classe HasLog introduit une dépendance à IO, qu’on indique par la contrainte MonadIO m.

class HasLog a where
    getLog :: a -> (String -> IO ())

app2 :: (HasLog e, MonadReader e m, MonadIO m) => m ()
app2 = do
    logMsg "begin app2"
    logMsg "end app2"

Cependant, cette contrainte n’est pas toujours justifiée. Par exemple, on pourrait vouloir tester le code en environnement contrôlé (mocking), via un State sans IO. Une solution classique consiste à définir et à utiliser une monade sans cette dépendance, ici MonadLog.

class Monad m => MonadLog m where
    logMsg :: String -> m ()

app2 :: (MonadLog m) => m ()
app2 = ...

Pour notre application à base de ReaderT, on peut instancier MonadLog, avec HasLog et donc MonadIO, comme avant.

instance (HasLog e, MonadIO m) => MonadLog (ReaderT e m) where
    logMsg msg = do
        env <- ask
        liftIO $ getLog env msg

Mais désormais, on peut aussi définir un type d’environnement pour faire du mocking. Ce type peut alors instancier MonadLog sans nécessiter MonadIO.

type EnvMock = State ([String], [User])

instance MonadLog EnvMock where
    logMsg msg = do
        (msgs, users) <- get
        put (msg:msgs, users)

Par exemple, pour tester app2 et récupérer les messages de log dans une liste :

$ ghci main4.hs

ghci> execState app2 ([]::[String], [User "John" 42])
(["end app2","begin app2"],[User {userName = "John", userAge = 42}])

Conclusion

Tout comme la programmation orientée objet, la programmation fonctionnelle a également ses design-patterns. En Haskell, le design pattern ReaderT est une architecture de code classique qui permet de séparer la couche métier de l’environnement d’exécution, d’expliciter les fonctionnalités avec des classes de types Has* et de simplifier les dépendances via des monades spécifiques.

Le design pattern ReaderT est utilisé dans des bibliothèques comme RIO ou dans des architectures plus évoluées comme l’architecture Haskell trois couches.