L'approche Tagless Final, en Haskell
Voir aussi : video youtube - video peertube - code source
L’approche Tagless Final est une façon classique d’organiser du code. Elle permet notamment d’implémenter des DSL ou de compléter le pattern ReaderT.
Grossièrement, l’approche Tagless Final en Haskell consiste à définir le langage de l’application via des classes de types et à définir des interpréteurs de ce langage via des instances. Cette approche a pour avantage d’être facile à comprendre et à faire évoluer.
Cet article développe l’approche Tagless Final à partir d’un exemple classique (définir et interpréter un langage d’expressions arithmétiques) puis présente un exemple d’application Tagless Final + pattern ReaderT.
Initial encoding
On veut implémenter un langage d’expressions arithmétiques (avec entier, réel et addition) puis interpréter des expressions (évaluation, affichage en notation préfixée…) ou faire évoluer le langage (opérateur de comparaison…).
Une première façon de faire est d’utiliser un type algébrique pour définir notre langage d’expressions arithmétiques (on appelle parfois cela un “encodage initial”).
data Expr
= IntConst Int
| RealConst Double
| Add Expr Expr
On peut ensuite écrire des expressions arithmétiques, de type Expr
.
expr1 :: Expr
= Add (IntConst 20) (IntConst 22)
expr1
expr2 :: Expr
= Add (RealConst 20) (RealConst 22) expr2
Et réaliser des opérations simples sur des Expr
, comme par exemple, les formater en notation préfixée :
view :: Expr -> String
IntConst x) = show x
view (RealConst x) = show x
view (Add x y) = "(+ " ++ view x ++ " " ++ view y ++ ")" view (
Cependant, ce type Expr
est peu pratique pour l’évaluation, car il nécessite de gérer explicitement les additions “entier-réel” ainsi que le type de retour :
data Result
= IntResult Int
| RealResult Double
deriving Show
eval :: Expr -> Maybe Result
IntConst x) = Just (IntResult x)
eval (RealConst x) = Just (RealResult x)
eval (Add x0 y0) = do
eval (<- eval x0
x1 <- eval y0
y1 case (x1, y1) of
IntResult x, IntResult y) -> Just $ IntResult (x + y)
(RealResult x, RealResult y) -> Just $ RealResult (x + y)
(-> Nothing _
Exemple d’exécution :
$ ghci expr1.hs
> eval $ Add (IntConst 20) (IntConst 22)
ghciJust (IntResult 42)
> view $ Add (IntConst 20) (IntConst 22)
ghci"(+ 20 22)"
> eval $ Add (IntConst 20) (RealConst 22)
ghciNothing
Final encoding
Pour vérifier des expressions arithmétiques plus efficacement, on définit un type polymorphe Expr a
et des fonctions “constructeurs de types”. On parle ici d’encodage final car les constructeurs ne font plus partie du type initial.
newtype Expr a = Expr { unExpr :: a }
intConst :: Int -> Expr Int
= Expr x
intConst x
realConst :: Double -> Expr Double
= Expr x
realConst x
add :: Num a => Expr a -> Expr a -> Expr a
Expr x) (Expr y) = Expr (x+y) add (
On peut ensuite écrire des expressions arithmétiques mais désormais le compilateur interdit les additions “entier-réel” et détermine automatiquement les types de retour.
$ ghci expr2.hs
> unExpr $ add (intConst 20) (intConst 22)
ghci42
> unExpr $ add (realConst 20) (realConst 22)
ghci42.0
> unExpr $ add (intConst 20) (realConst 22)
ghci<interactive>:3:29: error:
Couldn't match type ‘Double’ with ‘Int’
• ...
Cependant, cet encodage ne représente plus vraiment le langage mais directement son interprétation (évaluation de l’expression arithmétique), ce qui complique l’implémentation d’une autre interprétation (par exemple, le formatage en notation préfixée).
GADT
Une autre solution à l’encodage final précédent consiste à utiliser les types algébriques généralisés (GADT). En effet, les GADT permettent de définir des constructeurs plus précis :
data Expr a where
IntConst :: Int -> Expr Int
RealConst :: Double -> Expr Double
Add :: Num a => Expr a -> Expr a -> Expr a
Comme pour l’encodage initial, on peut définir des expressions arithmétiques :
expr1 :: Expr Int
= Add (IntConst 20) (IntConst 22)
expr1
expr2 :: Expr Double
= Add (RealConst 20) (RealConst 22) expr2
Ainsi que des interpréteurs (évaluation, formatage) :
eval :: Expr a -> a
IntConst x) = x
eval (RealConst x) = x
eval (Add x y) = eval x + eval y
eval (
view :: Expr a -> String
IntConst x) = show x
view (RealConst x) = show x
view (Add x y) = "(+ " ++ view x ++ " " ++ view y ++ ")" view (
Mais désormais, les types sont vérifiés plus précisément, notamment pour interdire l’addition “entier-réel” :
$ ghci expr-gadt1.hs
> eval $ Add (IntConst 20) (IntConst 22)
ghci42
> view $ Add (RealConst 20) (RealConst 22)
ghci"(+ 20.0 22.0)"
> eval $ Add (IntConst 20) (RealConst 22)
ghci
<interactive>:2:27: error:
Couldn't match type ‘Double’ with ‘Int’
• ...
Cette approche avec GADT est parfois appelé Tagless Initial car elle ne nomme pas explicitement les résultats (pas de IntResult
, RealResult
…) et car on encode dès le type initial (Expr
).
Cependant, il reste que cette approche n’est pas très évolutive. Par exemple, si on veut ajouter la comparaison less
dans notre langage d’expressions arithmétiques, alors il faut modifier le GADT ainsi que ses interpréteurs :
data Expr a where
...
Less :: Ord a => Expr a -> Expr a -> Expr Bool
eval :: Expr a -> a
...
Less x y) = eval x < eval y
eval (
view :: Expr a -> String
...
Less x y) = "(< " ++ view x ++ " " ++ view y ++ ")" view (
Tagless final encoding
L’approche Tagless Final consiste à implémenter notre langage d’expressions arithmétiques via une classe de types au lieu d’un type algébrique.
class Expr repr where
intConst :: Int -> repr Int
realConst :: Double -> repr Double
add :: Num a => repr a -> repr a -> repr a
On peut ensuite définir des expressions arithmétiques, paramétrée par une représentation de classe Expr
:
expr1 :: (Expr repr) => repr Int
= add (intConst 20) (intConst 22)
expr1
expr2 :: (Expr repr) => repr Double
= add (realConst 20) (realConst 22) expr2
Cette représentation permet d’implémenter des interpréteurs. Pour cela, il suffit de définir un type et de lui faire instancier Expr
de façon à produire le résultat voulu. Par exemple, pour évaluer une expression arithmétique :
newtype Eval a = Eval { runEval :: a }
instance Expr Eval where
= Eval x
intConst x = Eval x
realConst x = Eval $ runEval a + runEval b add a b
On peut alors définir et évaluer des expressions arithmétiques, vérifiées par le compilateur :
$ ghci expr3.hs
> runEval $ add (intConst 20) (intConst 22)
ghci42
> runEval $ add (realConst 20) (realConst 22)
ghci42.0
> runEval $ add (intConst 20) (realConst 22)
ghci<interactive>:3:31: error:
Couldn't match type ‘Double’ with ‘Int’
• ...
De même, on peut implémenter un interpréteur pour formater une expression arithmétique en notation préfixée :
newtype Format a = Format { runFormat :: String }
instance Expr Format where
= Format $ show x
intConst x = Format $ show x
realConst x = Format $ "(+ " ++ runFormat a ++ " " ++ runFormat b ++ ")" add a b
Enfin, on peut étendre facilement le langage d’expression arithmétique en ajoutant d’autres classes de types. Par exemple, pour la comparaison de nombres :
class Less repr where
less :: Ord a => repr a -> repr a -> repr Bool
Si on veut ajouter cette extension à nos interpréteurs précédents, il suffit d’instancier la nouvelle classe pour les types correspondants :
instance Less Eval where
= Eval $ runEval a < runEval b
less a b
instance Less Format where
= Format $ "(< " ++ runFormat a ++ " " ++ runFormat b ++ ")" less a b
Exemple d’exécution :
$ ghci expr4.hs
> runFormat $ less (intConst 20) (add (intConst 10) (intConst 12))
ghci"(< 20 (+ 10 12))"
> runFormat $ less (intConst 20) (less (intConst 10) (intConst 12))
ghci<interactive>:2:33: error:
Couldn't match type ‘Bool’ with ‘Int’
• ...
Exemple d’application
Pour finir, voici un exemple d’application utilisant le pattern ReaderT et le style Tagless Final. Il s’agit d’une application basique de gestion d’utilisateurs, stockés dans une variable mutable, et avec un système de log.
Tout d’abord, on définit les extensions, les dépendances et les types de base à manipuler dans l’application.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Concurrent.MVar
import Control.Monad.Reader
data User = User
_name :: String
{ _age :: Int
,deriving (Show) }
Puis on définit le “langage” de l’application via deux classes de types, MonadLog
et MonadUsers
, selon le style Tagless Final.
class Monad m => MonadLog m where
logMsg :: String -> m ()
class Monad m => MonadUsers m where
getUsers :: m [User]
deleteUser :: String -> m ()
On définit l’environnement d’exécution avec un type Env
ainsi que le pattern ReaderT avec un type AppM
et une fonction d’exécution runAppM
. AppM
correspond à l’interpréteur du langage de l’application.
data Env = Env
_usersVar :: MVar [User]
{ _logFunc :: String -> IO ()
,
}
newtype AppM a =
AppM { unAppM :: ReaderT Env IO a }
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env)
runAppM :: AppM a -> Env -> IO a
= runReaderT (unAppM app) env runAppM app env
Il ne reste qu’à instancier les classes MonadLog
et MonadUsers
pour AppM
.
instance MonadLog AppM where
= do
logMsg msg <- asks _logFunc
f $ f msg
liftIO
instance MonadUsers AppM where
= do
getUsers <- asks _usersVar
usersVar $ readMVar usersVar
liftIO
= do
deleteUser name <- asks _usersVar
usersVar $ modifyMVar_ usersVar (return . filter ((/=name) . _name)) liftIO
Et on peut écrire et exécuter l’application proprement dite, via le langage ainsi implémenté. On notera que l’utilisation de monades permet d’écrire l’application très facilement, via la “notation do”.
app1 :: (MonadUsers m, MonadLog m) => m [User]
= do
app1 <- getUsers
users0 $ "users0: " <> unwords (map _name users0)
logMsg "John"
deleteUser <- getUsers
users1 $ "users1: " <> unwords (map _name users1)
logMsg return users1
main :: IO ()
= do
main <- newMVar [User "Pedro" 13, User "John" 42]
myVar let myLog msg = putStrLn $ "-> " <> msg
<- runAppM app1 (Env myVar myLog)
res print res
Exemple d’exécution :
$ runghc app.hs
-> users0: Pedro John
-> users1: Pedro
User {_name = "Pedro", _age = 13}] [
On notera que cette implémentation est relativement simple à étendre. On peut par exemple ajouter d’autres applications, fonctionnalités du langage ou interpréteurs.
Conclusion
L’approche Tagless Final permet d’organiser le code d’une application ou d’un DSL. Elle consiste à définir le langage de base via des classes de types et à implémenter l’interpréteur via des types algébriques et des instances. Elle est simple à mettre en oeuvre et à faire évoluer, tant au niveau du langage qu’au niveau des interpréteurs. En Haskell, c’est une approche classique, alternative à l’approche des Free Monads.
Quelques liens :