Free Monad (ou pas) en Haskell (1)
Voir aussi : video youtube - code source
Cet article introduit les free monads en Haskell : intuition, définition, utilisation, alternatives. Il sera complété par un second article, illustrant comment combiner des free monads.
Free Monad
Intuition
Les free monads sont des types de données particuliers qui permettent de structurer du code. Intuitivement, une free monad implémente une structure d’arbre ou de liste qu’on peut ensuite traiter ou interpréter de différentes façons. Par exemple, les free monads permettent d’implémenter des EDSL (langages dédiés) ou des systèmes d’effets.
Définition
En Haskell, on peut définir les free monads via le type suivant :
data Free f a
= Pure a
| Free (f (Free f a))
Pour l’instant, disons que le type Free
permet de combiner des foncteurs de
type f
et qui vont produire une valeur de type a
.
Le type Free
peut instancier les classes Functor
, Applicative
et Monad
:
instance Functor f => Functor (Free f) where
fmap g (Pure r) = Pure (g r)
fmap g (Free x) = Free (fmap (fmap g) x)
instance Functor f => Applicative (Free f) where
...
instance Functor f => Monad (Free f) where
...
Cela signifie qu’à partir d’un type f
de classe Functor
, on peut définir un
type Free f
qui sera automatiquement de classe Functor
, Applicative
et
Monad
. Ainsi, on dit parfois que Free
permet d’avoir une monade
gratuitement (for free), à partir d’un simple foncteur.
Intuition avec les listes
Si on revient à la définition du type Free
, on peut s’intéresser à sa nature
récursive et la comparer à une définition de liste :
data Free f a = Pure a | Free (f (Free f a))
data List a = Nil | Cons a (List a)
On peut donc voir une liste comme une valeur inserée devant une sous-liste (un
chainage de valeurs). De même, on peut voir un Free
comme un foncteur
appliqué à un autre Free
(une imbrication d’évaluations de foncteur).
Intuition avec les monades
On peut également “expliquer” les free monad en les rapprochant des monades
“classiques”. Pour rappel, avec une monad m
, on a l’opérateur >>=
(appelé
“bind” et qui permet de chainer des actions), que l’on peut définir avec les
fonctions join
et fmap
:
(>>=) :: m a -> (a -> m b) -> m b
>>=) x g = join (fmap g x)
(
join :: m (m a) -> m a
...
fmap :: (a -> b) -> m a -> m b
...
Autrement dit, avec des monades classiques, on applique l’action (avec fmap
)
et on “applatit” l’imbrication de monades (avec join
). On peut donc voir les
free monads comme des monades classiques dont on conserve la structure
d’imbrications pour pouvoir l’interpréter (l’applatir) plus tard, et
éventuellement de différentes façons.
Ainsi, on a en fait une monade libre (free monad) dans le sens où elle n’est pas directement liée à une implémentation/signification particulière. Ce lien sera fait plus tard, lors de l’interprétation de la free monad.
Quelques fonctions sur les free monads
La bibliothèque free propose une implémentation des free monads ainsi que de nombreux autres types et fonctions bien pratiques.
On peut noter la fonction liftF
, qui permet de construire une free monad à
partir d’un foncteur :
liftF :: Functor f => f a -> Free f a
= Free $ fmap return x liftF x
et la fonction foldFree
, qui permet d’appliquer un interpréteur sur une free
monad :
foldFree :: (Functor f, Monad m)
=> (forall t. f t -> m t)
-> Free f a
-> m a
Pure r) = return r
foldFree _ (Free x) = do
foldFree g (<- g x
y foldFree g y
Exemple (avec des Free Monads)
On veut implémenter un EDSL de systéme de fichiers : lister les fichiers
(filesFs
), créer un dossier (filesMkdir
) et supprimer un dossier
(filesRmdir
).
Modèle
Tout d’abord, on écrit un type FilesF
(de classe Functor
) qui modélise
l’EDSL :
data FilesF a
= FilesLs ([FilePath] -> a)
| FilesMkdir FilePath a -- équivalent à: FilesMkdir FilePath (() -> a)
| FilesRmdir FilePath a
deriving (Functor)
Les valeurs du type FilesF
peuvent prendre des paramètres mais doivent
toujours retourner une fonction (qui sera appliquée sur le reste de
l’imbrication). Par exemple, la valeur FilesLs ([FilePath] -> a)
signifie
qu’on ne prend pas de paramètre mais qu’on retourne un [FilePath]
qui sera
passé au reste de l’imbrication pour produire, à la fin, une valeur de type
a
. Autre exemple, FilesMkdir FilePath a
signifie qu’on prend un paramètre
de type FilePath
et qu’on ne retourne rien (ou plutôt qu’on retourne une
fonction () -> a
qui de prend aucun paramètre et retourne une valeur de type
a
).
À partir du foncteur FilesF
, on peut créer automatiquement la free monad
correspondante :
type Files = Free FilesF
Pour simplifier l’utilisation de notre EDSL, on peut écrire des fonctions qui
construisent les valeurs de base de notre free monad Files
:
filesLs :: Files [FilePath]
= liftF $ FilesLs id
filesLs
filesMkdir :: FilePath -> Files ()
= liftF $ FilesMkdir fp ()
filesMkdir fp
filesRmdir :: FilePath -> Files ()
= liftF $ FilesRmdir fp () filesRmdir fp
Application
On a désormais les primitives de notre EDSL, utilisables comme un Functor
,
une Applicative
ou une Monad
. Par exemple, on peut définir une application
app2
qui crée un dossier “output1”, liste les fichiers puis supprime le
dossier “output1”, en utilisant la notation-do :
app2 :: Files [FilePath]
= do
app2 "output1"
filesMkdir <- filesLs
files "output1"
filesRmdir return files
Interpréteur 1
Il ne reste plus qu’à écrire un interpréteur pour pouvoir réaliser/calculer une application écrite avec notre EDSL.
Par exemple, pour réaliser réellement les opérations sur le système de
fichiers, on peut écrire l’interpréteur runFilesFs
suivant, qui affiche des
messages d’information et appelle les fonctions nécessaires du module
System.Directory
.
runFilesFs :: FilesF a -> IO a
FilesLs next) = do
runFilesFs (putStrLn "running filesLs"
<- listDirectory "."
files return $ next files
FilesMkdir fp next) = do
runFilesFs (putStrLn "running filesMkdir"
createDirectory fpreturn next
FilesRmdir fp next) = do
runFilesFs (putStrLn "running filesRmdir"
removeDirectory fpreturn next
Exemple d’exécution :
$ ghci myfiles-free.hs
*Main> foldFree runFilesFs app2
running filesMkdir
running filesLs
running filesRmdir"Free.hs","myfiles-free.hs","myfiles-mtl.hs","shell.nix","tmp","output1"] [
Interpréteur 2
Les free monads permettent d’écrire facilement différents interpréteurs. Par
exemple, l’interpréteur runFilesMock
suivant simule les opérations sans les
réalisés réellement sur le système de fichiers.
runFilesMock :: FilesF a -> IO a
FilesLs next) = do
runFilesMock (putStrLn "mock filesLs"
return $ next ["mock1", "mock2"]
FilesMkdir fp next) = do
runFilesMock (putStrLn $ "mock filesMkdir " <> fp
return next
FilesRmdir fp next) = do
runFilesMock (putStrLn $ "mock filesRmdir " <> fp
return next
Exemple d’exécution :
$ ghci myfiles-free.hs
*Main> foldFree runFilesMock app2
mock filesMkdir output1
mock filesLs
mock filesRmdir output1"mock1","mock2"] [
Interpréteur 3
Enfin, on peut également spécifier la monade utilisée pour l’interprétation.
Les deux interpréteurs précédents utilisaient la monade IO
mais on peut
vouloir utiliser une monade plus spécifique. Par exemple, l’interpréteur
runFilesPure
suivant simule les opérations et stocke les messages
d’information, via la monade State [String]
. Ceci peut être pratique pour
exécuter l’interprétation dans du code pur ou encore tester automatiquement le
code.
runFilesPure :: FilesF a -> State [String] a
FilesLs next) = do
runFilesPure (++ ["filesLs"])
modify' (return $ next ["pure1", "pure2"]
FilesMkdir fp next) = do
runFilesPure (++ ["filesMkdir " <> fp])
modify' (return next
FilesRmdir fp next) = do
runFilesPure (++ ["filesRmdir " <> fp])
modify' (return next
Exemple d’exécution :
$ ghci myfiles-free.hs
*Main> (a, s) = runState (foldFree runFilesPure app2) []
*Main> a
"pure1","pure2"]
[
*Main> s
"filesMkdir output1","filesLs","filesRmdir output1"] [
Exemple (en style Tagless Final)
A titre de comparaison, voici comment on pourrait implémenter notre EDSL en style Tagless Final (voir le tuto 62),
Modèle
Pour modéliser notre langage, on définit une classe MonadFiles
qui indique le
type des fonctions primitives.
class Monad m => MonadFiles m where
filesLs :: m [FilePath]
filesMkdir :: FilePath -> m ()
filesRmdir :: FilePath -> m ()
Application
On notera que MonadFiles
dérive de Monad
. On peut donc, comme avec les free
monads, écrire une application dans notre EDSL en utilisant la notation-do :
app2 :: MonadFiles m => m [FilePath]
= do
app2 "output1"
filesMkdir <- filesLs
files "output1"
filesRmdir return files
Interpréteur 1
Il ne reste plus qu’à écrire les interpréteurs. Pour cela, on définit un
wrapper de type, avec newtype
, pour lequel on instancie notre monade
MonadFiles
avec le comportement voulu. Par exemple, pour réaliser les
opérations réelles sur le système de fichiers, tout en affichant des messages
d’information :
newtype FilesFs m a = FilesFs { runFilesFs :: m a }
deriving (Functor, Applicative, Monad, MonadIO)
instance MonadIO m => MonadFiles (FilesFs m) where
= do
filesLs $ putStrLn "running filesLs"
liftIO $ listDirectory "."
liftIO = do
filesMkdir fp $ putStrLn "running filesMkdir"
liftIO $ createDirectory fp
liftIO = do
filesRmdir fp $ putStrLn "running filesRmdir"
liftIO $ removeDirectory fp liftIO
Exemple d’exécution :
$ ghci myfiles-mtl.hs
*Main> runFilesFs app2
running filesMkdir
running filesLs
running filesRmdir"Free.hs","myfiles-free.hs","myfiles-mtl.hs","shell.nix","tmp","output1"] [
Interpréteur 2
Ici aussi, on peut facilement écrire d’autre interpréteur. Il suffit d’écrire
un autre wrapper de types, avec son instance à MonadFiles
. Par exemple, pour
simuler les opérations sans les réaliser réellement :
newtype FilesMock m a = FilesMock { runFilesMock :: m a }
deriving (Functor, Applicative, Monad, MonadIO)
instance MonadIO m => MonadFiles (FilesMock m) where
= do
filesLs $ putStrLn "mock filesLs"
liftIO return ["mock1", "mock2"]
=
filesMkdir fp $ putStrLn $ "mock filesMkdir " <> fp
liftIO =
filesRmdir fp $ putStrLn $ "mock filesRmdir " <> fp liftIO
Exemple d’exécution :
$ ghci myfiles-mtl.hs
*Main> runFilesMock app2
mock filesMkdir output1
mock filesLs
mock filesRmdir output1"mock1","mock2"] [
Interpréteur 3
Enfin, on peut également utiliser une monade de base plus spécifique que IO
,
comme State [String]
:
newtype FilesPure m a = FilesPure { runFilesPure :: State [String] a}
deriving (Functor, Applicative, Monad, MonadState [String])
instance MonadFiles (FilesPure m) where
= do
filesLs ++ ["filesLs"])
modify' (return ["pure1", "pure2"]
= do
filesMkdir fp ++ ["filesMkdir " <> fp])
modify' (return ()
= do
filesRmdir fp ++ ["filesRmdir " <> fp])
modify' (return ()
Exemple d’exécution :
$ ghci myfiles-mtl.hs
*Main> (a, s) = runState (runFilesPure app2) []
*Main> a
"pure1","pure2"]
[
*Main> s
"filesMkdir output1","filesLs","filesRmdir output1"] [
Conclusion
La free monad peut être vue comme une imbrication d’applications de foncteurs. Ainsi, à partir d’un simple foncteur, on obtient une monade gratuitement. Plus exactement, on a une monade libre dont on peut lier l’interprétation plus tard et selon différentes implémentations.
La free monad est une contruction assez pratique et élégante mais peut poser des problèmes de performances ou d’extensibilité. C’est pourquoi, on lui préfère parfois des variantes (Church-encoded free monad, freer monad, etc) ou le style Tagless Final.
Quelques liens intéressants :