Unliftio
The MonadUnliftIO typeclass for unlifting monads to IO
Install / Use
/learn @fpco/UnliftioREADME
unliftio
Provides the core MonadUnliftIO typeclass, a number of common
instances, and a collection of common functions working with it. Not
sure what the MonadUnliftIO typeclass is all about? Read on!
NOTE The UnliftIO.Exception module in this library changes the semantics of asynchronous exceptions to be in the style of the safe-exceptions package, which is orthogonal to the "unlifting" concept. While this change is an improvment in most cases, it means that UnliftIO.Exception is not always a drop-in replacement for Control.Exception in advanced exception handling code. See Async exception safety for details.
Quickstart
- Replace imports like
Control.ExceptionwithUnliftIO.Exception. Yay, yourcatchandfinallyare more powerful and safer (see Async exception safety)! - Similar with
Control.Concurrent.AsyncwithUnliftIO.Async - Or go all in and import
UnliftIO - Naming conflicts: let
unliftiowin - Drop the deps on
monad-control,lifted-base, andexceptions - Compilation failures? You may have just avoided subtle runtime bugs
Sounds like magic? It's not. Keep reading!
Unlifting in 2 minutes
Let's say I have a function:
readFile :: FilePath -> IO ByteString
But I'm writing code inside a function that uses ReaderT Env IO, not
just plain IO. How can I call my readFile function in that
context? One way is to manually unwrap the ReaderT data constructor:
myReadFile :: FilePath -> ReaderT Env IO ByteString
myReadFile fp = ReaderT $ \_env -> readFile fp
But having to do this regularly is tedious, and ties our code to a
specific monad transformer stack. Instead, many of us would use
MonadIO:
myReadFile :: MonadIO m => FilePath -> m ByteString
myReadFile = liftIO . readFile
But now let's play with a different function:
withBinaryFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a
We want a function with signature:
myWithBinaryFile
:: FilePath
-> IOMode
-> (Handle -> ReaderT Env IO a)
-> ReaderT Env IO a
If I squint hard enough, I can accomplish this directly with the
ReaderT constructor via:
myWithBinaryFile fp mode inner =
ReaderT $ \env -> withBinaryFile
fp
mode
(\h -> runReaderT (inner h) env)
I dare you to try and accomplish this with MonadIO and
liftIO. It simply can't be done. (If you're looking for the
technical reason, it's because IO appears in
negative/argument position
in withBinaryFile.)
However, with MonadUnliftIO, this is possible:
import Control.Monad.IO.Unlift
myWithBinaryFile
:: MonadUnliftIO m
=> FilePath
-> IOMode
-> (Handle -> m a)
-> m a
myWithBinaryFile fp mode inner =
withRunInIO $ \runInIO ->
withBinaryFile
fp
mode
(\h -> runInIO (inner h))
That's it, you now know the entire basis of this library.
How common is this problem?
This pops up in a number of places. Some examples:
- Proper exception handling, with functions like
bracket,catch, andfinally - Working with
MVars viamodifyMVarand similar - Using the
timeoutfunction - Installing callback handlers (e.g., do you want to do logging in a signal handler?).
This also pops up when working with libraries which are monomorphic on
IO, even if they could be written more extensibly.
Examples
Reading through the codebase here is likely the best example to see
how to use MonadUnliftIO in practice. And for many cases, you can
simply add the MonadUnliftIO constraint and then use the
pre-unlifted versions of functions (like
UnliftIO.Exception.catch). But ultimately, you'll probably want to
use the typeclass directly. The type class has only one method --
withRunInIO:
class MonadIO m => MonadUnliftIO m where
withRunInIO :: ((forall a. m a -> IO a) -> IO b) -> m b
withRunInIO provides a function to run arbitrary computations in m
in IO. Thus the "unlift": it's like liftIO, but the other way around.
Here are some sample typeclass instances:
instance MonadUnliftIO IO where
withRunInIO inner = inner id
instance MonadUnliftIO m => MonadUnliftIO (ReaderT r m) where
withRunInIO inner =
ReaderT $ \r ->
withRunInIO $ \run ->
inner (run . flip runReaderT r)
instance MonadUnliftIO m => MonadUnliftIO (IdentityT m) where
withRunInIO inner =
IdentityT $
withRunInIO $ \run ->
inner (run . runIdentityT)
Note that:
- The
IOinstance does not actually do any lifting or unlifting, and therefore it can useid IdentityTis essentially just wrapping/unwrapping its data constructor, and then recursively callingwithRunInIOon the underlying monad.ReaderTis just likeIdentityT, but it captures the reader environment when starting.
We can use withRunInIO to unlift a function:
timeout :: MonadUnliftIO m => Int -> m a -> m (Maybe a)
timeout x y = withRunInIO $ \run -> System.Timeout.timeout x $ run y
This is a common pattern: use withRunInIO to capture a run function,
and then call the original function with the user-supplied arguments,
applying run as necessary. withRunInIO takes care of invoking
unliftIO for us.
We can also use the run function with different types due to
withRunInIO being higher-rank polymorphic:
race :: MonadUnliftIO m => m a -> m b -> m (Either a b)
race a b = withRunInIO $ \run -> A.race (run a) (run b)
And finally, a more complex usage, when unlifting the mask
function. This function needs to unlift values to be passed into the
restore function, and then liftIO the result of the restore
function.
mask :: MonadUnliftIO m => ((forall a. m a -> m a) -> m b) -> m b
mask f = withRunInIO $ \run -> Control.Exception.mask $ \restore ->
run $ f $ liftIO . restore . run
Limitations
Not all monads which can be an instance of MonadIO can be instances
of MonadUnliftIO, due to the MonadUnliftIO laws (described in the
Haddocks for the typeclass). This prevents instances for a number of
classes of transformers:
- Transformers using continuations (e.g.,
ContT,ConduitM,Pipe) - Transformers with some monadic state (e.g.,
StateT,WriterT) - Transformers with multiple exit points (e.g.,
ExceptTand its ilk)
In fact, there are two specific classes of transformers that this approach does work for:
- Transformers with no context at all (e.g.,
IdentityT,NoLoggingT) - Transformers with a context but no state (e.g.,
ReaderT,LoggingT)
This may sound restrictive, but this restriction is fully intentional. Trying to unlift actions in stateful monads leads to unpredictable behavior. For a long and exhaustive example of this, see A Tale of Two Brackets, which was a large motivation for writing this library.
Comparison to other approaches
You may be thinking "Haven't I seen a way to do catch in StateT?"
You almost certainly have. Let's compare this approach with
alternatives. (For an older but more thorough rundown of the options,
see
Exceptions and monad transformers.)
There are really two approaches to this problem:
- Use a set of typeclasses for the specific functionality we care
about. This is the approach taken by the
exceptionspackage withMonadThrow,MonadCatch, andMonadMask. (Earlier approaches includeMonadCatchIO-mtlandMonadCatchIO-transformers.) - Define a generic typeclass that allows any control structure to be
unlifted. This is the approach taken by the
monad-controlpackage. (Earlier approaches includemonad-peelandneither.)
The first style gives extra functionality in allowing instances that
have nothing to do with runtime exceptions (e.g., a MonadCatch
instance for Either). This is arguably a good thing. The second
style gives extra functionality in allowing more operations to be
unlifted (like threading primitives, not supported by the exceptions
package).
Another distinction within the generic typeclass family is whether we
unlift to just IO, or to arbitrary base monads. For those familiar,
this is the distinction between the MonadIO and MonadBase
typeclasses.
This package's main objection to all of the above approaches is that
they work for too many monads, and provide difficult-to-predict
behavior for a number of them (arguably: plain wrong behavior). For
example, in lifted-base (built on top of monad-control), the
finally operation will discard mutated state coming from the cleanup
action, which is usually not what people expect. exceptions has
different behavior here, which is arguably better. But we're arguing
here that we should disallow all such ambiguity at the type level.
So comparing to other approaches:
monad-unlift
Throwing this one out there now: the monad-unlift library is built
on top of monad-control, and uses fairly sophisticated type level
features to restrict it to only the safe subset of monads. The same
approach is taken by Control.Concurrent.Async.Lifted.Safe in the
lifted-async package. Two problems with this:
- The complicated type level functionality can confuse GHC in some cases, making it difficult to get code to compile.
- We don't have an ecosystem of functions like
lifted-basebuilt on top of it, making it likely people will revert to the less safe cousin functions.
monad-control
The main contention until now is that unlifting in a transformer like
StateT is unsafe. This is not universally true: if only one action
is being unlifted, no ambiguity exists. So
