June 11, 2014
This article starts a series in which I am going to publish my thoughts on and experience with different «extensible effects» approaches. This one in particular will explain the problem with the classic mtl approach that motivates us to explore extensible effects in the first place.
Often we start with a single monad — perhaps Reader
or State
. Then we realize it would be nice to add more to it — other ReaderT
s or StateT
s, probably an EitherT
etc.
At that point writing the whole stack in a type signature becomes rather onerous. So we create a type alias for it, or even a newtype, to improve type error messages. At first it looks like a good idea — we have «the monad» for our application. It removes a lot of the cognitive overhead — all our internal APIs are structured around this monad. The more time we spend working on our application, the more useful functions we invent that are automatically compatible and composable; the more joy it becomes to write code.
At least this is how I used to structure my code. I learned this approach from xmonad, the first «serious» Haskell project I studied and took part in. It has the X
monad, and all the functions work in and/or with this monad.
This approach breaks, however, once we want to have multiple applications based on the same code. At work, for instance, I’d like to reuse a significant part of code between the real application, the simulator (kind of a REPL for our messaging campaigns) and tests. But those necessarily use different monad stacks! The simulator doesn’t deal with MySQL and RabbitMQ connections; the server doesn’t need to be able to travel back and forth in time, like our simulator does; and tests for a piece of functionality should ideally use the smallest stack that’s necessary for that functionality.
So we should abstract in some way from the monad stack.
One such abstraction comes directly from mtl
, the monad transformers library.
If we simply write
{-# LANGUAGE NoMonomorphismRestriction #-}
import Control.Monad.State
import Control.Monad.Reader
foo = do
x <- ask
put $ fromEnum $ not x
without supplying any type signature, then the inferred type will be
foo :: (MonadReader Bool m, MonadState Int m) => m ()
This type signature essentially says that foo
is a monadic computation which has two effects: reading a boolean value and reading/writing an integral value. These effects are handled by the familiar «handlers» runState
and runReader
.
We can combine any such computations together, and the type system will automaticaly figure out the total set of effects, in the form of class constraints. E.g. if we also have
bar :: (MonadState Int m, MonadWriter All m) => m ()
then
(do foo; bar) :: (MonadReader Bool m, MonadState Int m, MonadWriter All m) => m ()
So it looks like mtl
can provide us with everything that the «extensible effects» approach promises. Or does it?
Unfortunately, if we write something a little bit different, namely
{-# LANGUAGE NoMonomorphismRestriction #-}
import Control.Monad.State
import Control.Monad.Reader
foo = do
x <- get
put $ fromEnum $ not x
where we’ve changed ask
to get
, the compiler gets confused:
test.hs:6:3:
No instance for (Monad m) arising from a do statement
Possible fix:
add (Monad m) to the context of the inferred type of foo :: m ()
In a stmt of a 'do' block: x <- get
In the expression:
do { x <- get;
put $ fromEnum $ not x }
In an equation for ‘foo’:
foo
= do { x <- get;
put $ fromEnum $ not x }
test.hs:6:8:
No instance for (MonadState Bool m) arising from a use of ‘get’
In a stmt of a 'do' block: x <- get
In the expression:
do { x <- get;
put $ fromEnum $ not x }
In an equation for ‘foo’:
foo
= do { x <- get;
put $ fromEnum $ not x }
test.hs:7:3:
No instance for (MonadState Int m) arising from a use of ‘put’
In the expression: put
In a stmt of a 'do' block: put $ fromEnum $ not x
In the expression:
do { x <- get;
put $ fromEnum $ not x }
This is because mtl asserts, via a mechanism called functional dependency, that a monadic stack can have only once instance of MonadState
. Because get
and put
in the above example operate with different types of state, that code is invalid.
Since we can’t have multiple different MonadState
constraints for our reusable monadic computation, we need to merge all StateT
layers in order to be able to access them through the MonadState
class:
data MyState = MyState
{ _sInt :: Int
, _sBool :: Bool
}
Then we could generate lenses and put them in classes to achieve modularity:
class HasInt t where
lInt :: Lens t Int
class HasBool t where
lBool :: Lens t Bool
foo :: (MonadState s m, HasInt s, HasBool s) => m ()
The drawbacks of this approach are:
StateT
layers together. For instance, there’s no way to achieve the semantics of StateT s1 (MaybeT (StateT s2 Identity))
using only one layer of StateT
.mtl’s classes almost provide a valid «extensible effects» implementation, if not for the functional dependency that lets us have only single MonadState
instance per stack.
In the subsequent articles we’ll explore ways to address this limitation.