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
MonadStateclass itself. Those areget,put, andstate.msupports these functions because the instance definition ofMonadState s mmust provide definitions of these functions. -
Functions that can be implemented using only the methods of the
MonadStateclass as building blocks.modifyis one such function. As this exercise demonstrates, we can implement it in terms of onlygetandput, 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
putStrhad the more general typeputStr :: MonadIO m => String -> m ()any call of
putStrfrom within theIOmonad would be treated as ifputStrhad 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 theIOmonad, 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. ↩