Skip to content

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

GHCi
>>> :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 are get, put, and state. m supports these functions because the instance definition of MonadState 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 only get and put, so we have

    modify :: 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


  1. Haskell programmers refer to this automatic specialization to a particular type as "monomorphization". For example, if putStr had the more general type

    putStr :: MonadIO m => String -> m ()
    

    any call of putStr from within the IO monad would be treated as if putStr had the more specific type

    putStr :: 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 the IO 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.