MonadIO
Given that ask
works for any monad m
that is an instance of MonadReader
,
tell
works for any monad m
that is an instance of MonadWriter
, and get
and put
work for any monad m
that is an instance of MonadState
, it would
be really nice if we could define a class MonadIO
such that any monad m
that
is an instance of MonadIO
supports all the same functions that the IO
monad
supports. For example,
putStr :: MonadIO m => String -> m ()
If we query the type of putStr
in GHCi, you get the disappointing result that
>>> :t putStr
putStr :: String -> IO ()
Let's figure out why we can't (easily) have the more general type
putStr :: MonadIO m => String -> m ()
Consider our MonadState
class one more time. Any monad m
that is an instance
of this class supports get
, put
, state
, modify
, and a whole bunch of
other functions. These functions can be divided into two categories:
-
Methods of the
MonadState
class itself. Those areget
,put
, andstate
.m
supports these functions because the instance definition ofMonadState s m
must provide definitions of these functions. -
Functions that can be implemented using only the methods of the
MonadState
class as building blocks.modify
is one such function. As this exercise demonstrates, we can implement it in terms of onlyget
andput
, so we havemodify :: MonadState s m => (s -> s) -> m () modify f = get >>= put . f
Those are the only functions supported by every monad m
that is an instance
of MonadState
. Some monads may support other functions, but if all we know is
that m
is an instance of MonadState
, then the only primitives that are
guaranteed to exist are the ones in the MonadState
class, and all other
functions must be implemented in terms of these primitives.
Now what should the primitives for a MonadIO
class look like? Doing I/O can
mean lots of things. We want to write files, talk to a graphics card,
communicate over network sockets, control a printer, etc, etc, etc. Given that
the state we're interacting with is the real world, the possibilities are
endless. The primitives that every instance of the MonadIO
class should
support must allow us to implement all other I/O operations in terms of these
primitives. There is no relationship between printing stuff on screen and
communicating over a network socket or talking to a printer. Of course, all of
this can be implemented using low-level assembly code, so we could provide a
primitive that allows us to run any assembly code we want. However, this would
be horribly inconvenient, and any safety guarantees would disappear.
Given that the list of IO
operations we may want to support is endless and
that there is no small set of (reasonable) primitives in terms of which they can
all be implemented, the closest we can come to supporting all IO
operation
in any I/O monad is to offer a unified interface to lift any IO
operation into
such a monad:
class Monad m => MonadIO m where
liftIO :: IO a -> m a
We can easily implement instances of MonadIO
for our various monad
transformers:
instance MonadIO m => MonadIO (ReaderT r m) where
liftIO = lift . liftIO
instance (Monoid w, MonadIO m) => MonadIO (WriterT w m) where
liftIO = lift . liftIO
instance MonadIO m => MonadIO (StateT s m) where
liftIO = lift . liftIO
instance MonadIO m => MonadIO (MaybeT m) where
liftIO = lift . liftIO
How is liftIO
better than lift
? For a monad like MaybeT IO
, lift
and
liftIO
do the same thing. However, if we have a monad like
ReaderT r (StateT s (WriterT w IO))
, then accessing IO
operations via lift
requires us to peel away the monad transformers one at a time, as in
lift $ lift $ lift $ putStrLn "Hello"
. Given that IO
is an instance of
MonadIO
, the above instance definitions for ReaderT
, WriterT
, and StateT
imply that ReaderT r (StateT s (WriterT w IO))
is also an instance of
MonadIO
, so we can simply write liftIO $ putStrLn "Hello"
. We still need to
lift the IO
operation, but one liftIO
is enough.
As a final note, it would be possible to have all our standard IO
functions
have types such as
putStr :: MonadIO m => String -> m ()
All it would take is to split the implementation of any such function into two parts:
ioPutStr :: String -> IO ()
putStr :: MonadIO m => String -> m ()
putStr = liftIO . ioPutStr
Maybe the standard library will adopt this approach at some point in the future. It is also possible that this never happens because polymorphic functions are more costly to call unless the compiler can deduce from their use in our code how to specialize them to a particular type.1
-
Haskell programmers refer to this automatic specialization to a particular type as "monomorphization". For example, if
putStr
had the more general typeputStr :: MonadIO m => String -> m ()
any call of
putStr
from within theIO
monad would be treated as ifputStr
had the more specific typeputStr :: String -> IO ()
This eliminates the overhead of calling the polymorphic version of
putStr
. Where the compiler has a harder time to eliminate the overhead of polymorphic functions is if we build polymorphic functions from other polymorphic functions. We may call the outermost function from theIO
monad, and the compiler may be able to monomorphize this particular function, but I don't know how good the compiler is at propagating this information down recursive calls. ↩