Variables mutables, ST, STM (Haskell)
Voir aussi : video youtube - code source
Un langage purement fonctionnel interdit les effets de bord (entrées/sorties, mutations, etc). Cependant, les effets de bord sont généralement indispensables pour implémenter des programmes “utiles”. Pour cela, Haskell propose les monades, c’est-à-dire des contextes dans lesquels on peut réaliser des fonctionnalités particulières, par exemple des effets de bord particuliers. Dans cet article, on va voir comment manipuler des variables mutables, dans différentes monades (IO, ST et STM).
IO
La monade IO permet de faire des entrées/sorties, et donc à peu près tout ce qu’on veut avec la machine qui exécute le programme. Il s’agit du contexte d’exécution du programme principal et de nombreuses fonctions à effets de bord.
La bibliothèque de base propose plusieurs types pour implémenter des variables mutables dans IO. IORef permet de créer et d’utiliser des variables mutables basiques. MVar apporte des fonctionnalités supplémentaires, notamment pour de la programmation concurrente. MVar et IORef sont assez similaires dans leur fonctionnement de base; l’exemple ci-dessous utilise MVar.
On veut implémenter un programme qui permet d’incrémenter un compteur, en distinguant les unités (_counter1
) des dizaines (_counter2
).
data Model = Model
_counter1 :: Int
{ _counter2 :: Int
,deriving (Show)
}
incrementCounter1 :: Model -> Model
Model c1 c2) = Model (1+c1) c2
incrementCounter1 (
updateCounters :: Model -> Model
Model c1 c2) = Model (c1 `mod` 10) (c1 `div` 10 + c2) updateCounters (
Il s’agit de code Haskell “classique”, avec des fonctions pures et sans effet de bord.
Si on veut utiliser notre compteur dans un programme avec des accès concurrents, on ne peut plus se contenter de prendre un Model et de retourner le Model modifié. Il faut désormais utiliser une variable mutable contenant un Model et la mettre à jour de façon atomique, par exemple avec modifyMVar_
.
incrementCounter1Var :: MVar Model -> IO ()
=
incrementCounter1Var modelVar return . incrementCounter1)
modifyMVar_ modelVar (
updateCountersVar :: MVar Model -> IO ()
=
updateCountersVar modelVar return . updateCounters) modifyMVar_ modelVar (
On notera que les deux fonctions précédentes ne retournent pas de valeur mais doivent être appelées dans IO, car elles réalisent bien un effet de bord.
On notera également que ces fonctions ne se composent pas. Elle doivent être appelées successivement, sans garantie d’atomicité : un autre accès concurrent peut être réalisé entre deux appels. Si on veut garantir l’atomicité de deux appels, il faut l’implémenter également :
incrementAndUpdateVar :: MVar Model -> IO ()
=
incrementAndUpdateVar modelVar return . updateCounters . incrementCounter1) modifyMVar_ modelVar (
On peut alors créer et utiliser une variable mutable avec ces fonctions, par exemple :
test :: IO ()
= do
test
-- créer une variable mutable
<- newMVar (Model 0 0)
modelVar
-- incrémente puis met à jour la variable
incrementCounter1Var modelVar
updateCountersVar modelVar
-- idem mais de façon atomique
incrementAndUpdateVar modelVar
-- répète 2 fois une action sur la variable
2 $ incrementAndUpdateVar modelVar
replicateM_
-- lit et affiche le contenu de la variable
<- readMVar modelVar
model2 print model2
Évidemment, les variables mutables sont plus utiles dans un contexte avec des accès concurrents. Par exemple, pour un serveur web où la route “/” lit la variable et la route “/inc” la modifie :
main :: IO ()
= do
main
<- newMVar (Model 0 0)
modelVar
3000 $ do
scotty
"/" $ do
get Model c1 c2 <- liftIO $ readMVar modelVar
$ renderText $ do
html $ toHtml $ show c1 <> " " <> show c2
div_ $ form_ [action_ "/inc", method_ "post"] $
div_ "submit", value_ "increment"]
input_ [type_
"/inc" $ do
post $ incrementAndUpdateVar modelVar
liftIO "/" redirect
Ce code fonctionne à peu près correctement mais a deux inconvénients : les fonctions manipulant la variable mutable ne se composent pas; la monade IO est très générale, ce qui signifie que nos fonctions peuvent faire à peu près n’importe quoi, au lieu de seulement manipuler une variable mutable.
ST
Avant la STM, considérons la monade ST (déjà présentée dans le tuto 69). Celle-ci permet de réaliser un calcul dont l’exécution est réalisée dans un seul thread, en évitant les problèmes d’accès concurrent. STRef permet de manipuler des variables mutables dans le contexte de ST.
On peut donc réécrire nos fonctions pour manipuler une variable mutable. Au lieu de prendre un MVar
et de fonctionner dans IO
, elles prendront un STRef s
et fonctionneront dans ST s
(s
correspond au thread d’exécution).
incrementCounter1 :: STRef s Model -> ST s ()
=
incrementCounter1 modelRef $ \(Model c1 c2) -> Model (1+c1) c2
modifySTRef' modelRef
updateCounters :: STRef s Model -> ST s ()
=
updateCounters modelRef $
modifySTRef' modelRef Model c1 c2) -> Model (c1 `mod` 10) (c1 `div` 10 + c2) \(
Ainsi, ces fonctions sont limitées aux fonctionnalités de ST (par exemple modifier une variable mutable) et ne peuvent pas réaliser toutes les fonctionnalités (potentiellement dangeureuses) de IO.
On peut également composer directement ces fonctions (dans le sens de ST).
incrementAndUpdate :: STRef s Model -> ST s ()
= do
incrementAndUpdate modelRef
incrementCounter1 modelRef updateCounters modelRef
Pour tester, on peut convertir une séquence d’actions ST dans IO. On peut également calculer cette séquence et récupérer son résultat, “dans du code pur” :
test :: IO ()
= do
test let res = runST $ do
<- newSTRef (Model 0 0)
modelRef
incrementCounter1 modelRef
updateCounters modelRef
incrementAndUpdate modelRef
2 $ incrementAndUpdate modelRef
replicateM_
readSTRef modelRef
print res
STM
La mémoire transactionnelle logicielle est une technique de programmation concurrente, qui permet notamment d’écrire du code modulaire et composable. En Haskell, elle est implémentée dans la bibliothèque STM. Celle-ci propose la monade STM (contexte d’exécution avec concurrence) ainsi que différents types et fonctions implémentant diverses abstractions de communication concurrente.
Ainsi, on peut réécrire les fonctions de notre programme d’exemple, en prenant en paramètre une variable mutable TVar
et en fonctionnant dans STM
:
incrementCounter1 :: TVar Model -> STM ()
=
incrementCounter1 modelVar $ \(Model c1 c2) -> Model (1+c1) c2
modifyTVar modelVar
updateCounters :: TVar Model -> STM ()
= do
updateCounters modelVar Model c1 c2) <- readTVar modelVar
($ Model (c1 `mod` 10) (c1 `div` 10 + c2) writeTVar modelVar
Comme pour la monade ST, la monade STM permet de séquencer ou de composer les actions : si on appelle la fonction incrementCounter1
puis la fonction updateCounters
dans le même contexte STM, on pourra exécuter l’ensemble de façon atomique.
incrementAndUpdate :: TVar Model -> STM ()
= do
incrementAndUpdate modelVar
incrementCounter1 modelVar updateCounters modelVar
On peut alors exécuter une séquence d’actions STM, de façon atomique, avec la fonction atomically
:
test :: IO ()
= do
test <- newTVarIO (Model 0 0)
modelVar
<- atomically $ do
model2
incrementCounter1 modelVar
updateCounters modelVar
incrementAndUpdate modelVar
2 $ incrementAndUpdate modelVar
replicateM_
readTVar modelVar
print model2
La STM apporte également de nombreuses fonctionnalités : gestion de l’exécution (check
, retry
…), abstractions de communication concurrente (variable mutable, queue, channel..).
Conclusion
Haskell propose différentes implémentations de variables mutables, utilisables dans le cadre de la programmation fonctionnelle.
Les types IORef et MVar apportent les fonctionnalités de bases pour gérer des variables mutables à accès concurrents. Ils apparaissent explicitement dans les signatures de fonctions et sont vérifiés par le compilateur. Cependant ils utilisent la monade IO, qui est très (trop) générale, et ne permettent pas de composer des accès concurrents.
La mémoire transactionnelle logicielle (STM) apporte plus de flexibilité. Elle permet notamment de composer des accès concurrents et de les exécuter dans une monade dédiée (un peu comme la monade ST pour le contexte du multi-threading), de manière atomique.
Ces différentes implémentations sont assez largement utilisées en Haskell, par exemple pour du code asynchrone, pour le design pattern ReaderT, etc.