You might have heard of the ReaderT design pattern in Haskell.
It is a good way to provide global capabilities, such as logging
and database access to your application. In short, the code of your application lives inside
type App = ReaderT Env IO
monad where Env
is a record containing
some global functions and variables (IORef, MVar…).
Yes, global mutable states! How terrifying in the nice and pure Haskell world! Luckily, you can create typeclasses for each capabilities you encapsulated and program in mtl-style to recover some level of purity.
But why does it use IORef when we have StateT? The article argues that
- You lose your state when encountering runtime exception
- Having the entire app living inside StateT is no better than explicitly using mutables
- StateT does not play well with concurrency
Let me show you another reason we prefer ReaderT with IORef over StateT.
Thread your monad through an IO hole
When dealing with some low-level bindings to event-driven runtimes, you will inevitably
encounter some functions that have type signatures like (X -> IO a) -> IO b
.
For example, eventListener :: (Event -> IO a) -> IO EventListener
that creates an event
listener from a event handler function.
Notice the explicit use of IO
in the first argument. There is no way to “lower” a function of
MonadIO m => Event -> m a
to Event -> IO a
, which implies that you cannot use App
monad
in the event handler, right? Well, not exactly. If you have the full knowledge of the monad being
a ReaderT Env IO
(not using mtl-style), runReaderT
can be used.
program :: App ()
= do
program ...
<- ask
env <- liftIO $ eventListener $ runReaderT handler env
el ...
where
handler :: Event -> App Bool
= ... handler ev
(There is a library called unliftio
that do this for you in a more generic way)
Simply passing env
to runReaderT
is enough. Remember that Env
itself is immutable (it’s ReaderT after all),
using it in any context is safe. Access to variables inside the event handler is proxied through IORef
so that
you correctly get the latest value. The whole process is the same as passing pointers to event handlers in C language.
Of course if there is concurrency involved, you have to deal with them yourself
(such as to use MVar
or TVar
instead of IORef
).
You cannot do this if it was StateT
. If you call evalStateT
and pass in the current state, in the handler you got
only the copy of the state at the time you register that event listener. In fact, event-driven model and pure states
don’t play well together at all!
Don’t pretend to be writing pure code
The takeaway here is, write in imperative style if you are doing imperative things. If you want to write pure, functional code, use a library with a higher-level abstraction instead. If you have to deal with lower-level bindings, write in imperative style is usually easier than handcrafting an ad-hoc functional layer on top of it. After all, Haskell is the best imperative language.