Functional Programming

Functional Programming

Functional Programming

Jul 17, 2017

Announcing: the new unliftio library

Announcing: the new unliftio library

Announcing: the new unliftio library

For the past few years, Francesco Mazzoli and I have discussed

issues around monad transformers—and the need to run their actions

in IO—on a fairly regular basis. I wrote the

monad-unlift library a while ago to try and address these concerns.

But recent work I did in Stack on the extensible

snapshots branch demonstrated some of the shortcomings

Francesco had mentioned to me. This is also in line with

conclusions I was reaching from code review and training I've been

doing, as I've mentioned recently.


Putting that all together: last week we finally bit the bullet and put together a new pair of libraries:

  • unliftio-core defines the MonadUnliftIO typeclass, provides instances for base and transformers, and

    provides a few helper functions, with no additional

    dependencies.

  • unliftio provides a

    "batteries included" set of unlifted functions for exceptions,

    timeouts, async, and more

This should be considered an experimental release, with some

changes already planned. Instead of repeating myself, I'm going to

copy in the README from unliftio for the remainder of

this post, which includes more details on using these libraries,

comparison with alternatives, and plans for future changes.


NOTE If you're reading this in the future, please check

out the README from the packages themselves in the links above. The

content below will not be updated with changes to the

libraries.


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 This library is young, and will likely undergo some

serious changes over time. It's also very lightly tested. That

said: the core concept of MonadUnliftIO has been

refined for years and is pretty solid, and even though the code

here is lightly tested, the vast majority of it is simply apply

withUnliftIO to existing functionality. Caveat emptor and all that.


Quickstart

  • Replace imports like Control.Exception with UnliftIO.Exception. Yay, your catch and finally are more powerful and safer!

  • Similar with Control.Concurrent.Async with UnliftIO.Async

  • Or go all in and import UnliftIO

  • Naming conflicts: let unliftio win

  • Drop the deps on monad-control, lifted-base, and exceptions

  • Compilation failures? You may have just avoided subtle runtime bugs

Sound like magic? It's not. Keep reading!

Unlifting in 2 minutes

Let's say I have a function:

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:

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:


But now let's play with a different function:

We want a function with signature:

If I squint hard enough, I can accomplish this directly with the ReaderT constructor via:

I dare you to try to 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:

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, and finally

  • Working with MVars via modifyMVar and similar

  • Using the timeout function

  • 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 -- askUnliftIO:


askUnliftIO gives us a function to run arbitrary computation in m in IO. Thus the "unlift": it's like liftIO, but the other way around.

Here are some sample typeclass instances:

Note that:

  • The IO instance does not actually do any lifting or unlifting, and therefore it can use id

  • IdentityT is essentially just wrapping/unwrapping

    its data constructor, and then recursively calling

    withUnliftIO on the underlying monad.

  • ReaderT is just like IdentityT, but it captures the reader environment when starting.

We can use askUnliftIO to unlift a function:

or more concisely using withRunIO:

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. withRunIO takes care of invoking unliftIO for us.


However, if we want to use the run function with different types, we must use askUnliftIO:

or more idiomatically withUnliftIO:

This works just like withRunIO, except we use unliftIO u instead of run, which is polymorphic. You could get away with multiple withRunInIO calls here instead, but this approach is

idiomatic and may be more performant (depending on

optimizations).


And finally, a more complex usage, when unlifting the mask function. This function needs to unlift vaues to be passed into the restore function, and then liftIO the result of the restore function.

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., ExceptT and 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 exceptions package with MonadThrow, MonadCatch, and MonadMask. (Earlier approaches include MonadCatchIO-mtl and MonadCatchIO-transformers.)

  • Define a generic typeclass that allows any control structure to

    be unlifted. This is the approach taken by the

    monad-control package. (Earlier approaches include monad-peel and neither.)

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-base built 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, for

example, try :: IO a -> IO (Either e a) can safely be unlifted in StateT, while finally :: IO a -> IO b -> IO a cannot.


monad-control allows us to unlift both styles. In theory, we could write a variant of lifted-base that never does state discards, and let try be more general than finally. In other words, this is an advantage of monad-control over MonadUnliftIO. We've

avoided providing any such extra typeclass in this package though,

for two reasons:


  • MonadUnliftIO is a simple typeclass, easy to

    explain. We don't want to complicated matters

    (MonadBaseControl is a notoriously difficult to

    understand typeclass). This simplicity is captured by the laws for

    MonadUnliftIO, which make the behavior of the run functions close to that of the already familiar lift and liftIO.

  • Having this kind of split would be confusing in user code, when suddenly finally is not available to us. We would

    rather encourage good practices from the

    beginning.

Another distinction is that monad-control uses the MonadBase style, allowing unlifting to arbitrary base

monads. In this package, we've elected to go with

MonadIO style. This limits what we can do (e.g., no unlifting to STM), but we went this way because:


  • In practice, we've found that the vast majority of cases are dealing with IO

  • The split in the ecosystem between constraints like MonadBase IO and MonadIO leads to significant confusion, and MonadIO is by far the more

    common constraints (with the typeclass existing in

    base)

exceptions

One thing we lose by leaving the exceptions

approach is the ability to model both pure and side-effecting (via

IO) monads with a single paradigm. For example, it can be pretty convenient to have MonadThrow constraints

for parsing functions, which will either return an

Either value or throw a runtime exception. That said, there are detractors of that approach:


  • You lose type information about which exception was thrown

  • There is ambiguity about how the exception was returned in a constraint like (MonadIO m, MonadThrow m)

The latter could be addressed by defining a law such as throwM = liftIO . throwIO. However, we've decided in this library to go the route of encouraging Either

return values for pure functions, and using runtime exceptions in

IO otherwise. (You're of course free to also return IO (Either e a).)


By losing MonadCatch, we lose the ability to define

a generic way to catch exceptions in continuation based monads

(such as ConduitM). Our argument here is that those

monads can freely provide their own catching functions. And in

practice, long before the MonadCatch typeclass existed, conduit provided a catchC function.


In exchange for the MonadThrow typeclass, we provide helper functions to convert Either values to runtime exceptions in this package. And the MonadMask typeclass is now replaced fully by MonadUnliftIO, which like the monad-control case limits which monads we can be working with.

Async exception safety

The safe-exceptions package builds on top of the exceptions package and provides intelligent behavior

for dealing with asynchronous exceptions, a common pitfall. This

library provides a set of exception handling functions with the

same async exception behavior as that library. You can consider

this library a drop-in replacement for

safe-exceptions. In the future, we may reimplement safe-exceptions to use MonadUnliftIO instead of MonadCatch and MonadMask.


Package split

The unliftio-core package provides just the typeclass with minimal dependencies (just base and transformers). If you're writing a library, we

recommend depending on that package to provide your instances. The

unliftio package is a "batteries loaded" library

providing a plethora of pre-unlifted helper functions. It's a good

choice for importing, or even for use in a custom prelude.


Orphans

The unliftio package currently provides orphan instances for types from the resourcet and monad-logger packages. This is not intended as a long-term solution; once unliftio is deemed more

stable, the plan is to move those instances into the respective

libraries and remove the dependency on them here.


If there are other temporary orphans that should be added,

please bring it up in the issue tracker or send a PR, but we'll

need to be selective about adding dependencies.


Future questions

  • Should we extend the set of functions exposed in UnliftIO.IO to include things like hSeek?

  • Are there other libraries that deserve to be unlifted here?