A fast, flexible, fused effect system for Haskell

Build Status

Overview

fused-effects is an effect system for Haskell emphasizing expressivity and efficiency. The former is achieved by encoding algebraic, higher-order effects, while the latter is the result of fusing effect handlers all the way through computations.

Readers already familiar with effect systems may wish to start with the usage instead.

Algebraic effects

In fused-effects and other systems with algebraic (or, sometimes, extensible) effects, effectful programs are split into two parts: the specification (or syntax) of the actions to be performed, and the interpretation (or semantics) given to them. Thus, a program written using the syntax of an effect can be given different meanings by using different effect handlers.

These roles are performed by the effect and carrier types, respectively. Effects are datatypes with one constructor for each action. Carriers are generally newtypes, with a Carrier instance specifying how an effect’s constructors should be interpreted. Each carrier handles one effect, but multiple carriers can be defined for the same effect, corresponding to different interpreters for the effect’s syntax.

Higher-order effects

Unlike most other effect systems, fused-effects offers higher-order (or scoped) effects in addition to first-order algebraic effects. In a strictly first-order algebraic effect system, operations (like local or catchError) which specify some action limited to a given scope must be implemented as interpreters, hard-coding their meaning in precisely the manner algebraic effects were designed to avoid. By specifying effects as higher-order functors, these operations are likewise able to be given a variety of interpretations. This means, for example, that you can introspect and redefine both the local and ask operations provided by the Reader effect, rather than solely ask (as is the case with certain formulations of algebraic effects).

As Nicolas Wu et al showed in Effect Handlers in Scope, this has implications for the expressiveness of effect systems. It also has the benefit of making effect handling more consistent, since scoped operations are just syntax which can be interpreted like any other, and are thus simpler to reason about.

Fusion

In order to maximize efficiency, fused-effects applies fusion laws, avoiding the construction of intermediate representations of effectful computations between effect handlers. In fact, this is applied as far as the initial construction as well: there is no representation of the computation as a free monad parameterized by some syntax type. As such, fused-effects avoids the overhead associated with constructing and evaluating any underlying free or freer monad.

Instead, computations are performed in a carrier type for the syntax, typically a monad wrapping further monads, via an instance of the Carrier class. This carrier is specific to the effect handler selected, but since it isn’t described until the handler is applied, the separation between specification and interpretation is maintained. Computations are written against an abstract effectful signature, and only specialized to some concrete carrier when their effects are interpreted.

Since the interpretation of effects is written as a typeclass instance which ghc is eager to inline, performance is excellent: approximately on par with mtl.

Finally, since the fusion of carrier algebras occurs as a result of the selection of the carriers, it doesn’t depend on complex RULES pragmas, making it very easy to reason about and tune.

Usage

Using built-in effects

Like other effect systems, effects are performed in a Monad extended with operations relating to the effect. In fused-effects, this is done by means of a Member constraint to require the effect’s presence in a signature, and a Carrier constraint to relate the signature to the Monad. For example, to use a State effect managing a String, one would write:

action :: (Member (State String) sig, Carrier sig m) => m ()

(Additional constraints may be necessary depending on the precise operations required, e.g. to make the Monad methods available.)

Multiple effects can be required simply by adding their corresponding Member constraints to the context. For example, to add a Reader effect managing an Int, we would write:

action :: (Member (State String) sig, Member (Reader Int) sig, Carrier sig m) => m ()

Different effects make different operations available; see the documentation for individual effects for more information about their operations. Note that we generally don’t program against an explicit list of effect components: we take the typeclass-oriented approach, adding new constraints to sig as new capabilities become necessary. If you want to name and share some predefined list of effects, it’s best to use the -XConstraintKinds extension to GHC, capturing the elements of sig as a type synonym of kind Constraint:

type Shared sig = ( Member (State String) sig
                  , Member (Reader Int)   sig
                  , Member (Writer Graph) sig
                  )

myFunction :: (Shared sig, Carrier sig m) => Int -> m ()

Running effects

Effects are run with effect handlers, specified as functions (generally starting with run…) invoking some specific Carrier instance. For example, we can run a State computation using runState:

example1 :: (Carrier sig m, Effect sig) => [a] -> m (Int, ())
example1 list = runState 0 $ do
  i <- get
  put (i + length list)

runState returns a tuple of both the computed value (the ()) and the final state (the Int), visible in the result of the returned computation.

Since this function returns a value in some carrier m, effect handlers can be chained to run multiple effects. Here, we get the list to compute the length of from a Reader effect:

example2 :: (Carrier sig m, Effect sig, Monad m) => m (Int, ())
example2 = runReader "hello" . runState 0 $ do
  list <- ask
  put (length (list :: String))

(Note that the type annotation on list is necessary to disambiguate the requested value, since otherwise all the typechecker knows is that it’s an arbitrary Foldable. For more information, see the comparison to mtl.)

When all effects have been handled, a computation’s final value can be extracted with run:

example3 :: (Int, ())
example3 = run . runReader "hello" . runState 0 $ do
  list <- ask
  put (length (list :: String))

run is itself actually an effect handler for the Pure effect, which has no operations and thus can only represent a final result value.

Alternatively, arbitrary Monads can be embedded into effectful computations using the Lift effect. In this case, the underlying Monadic computation can be extracted using runM. Here, we use the MonadIO instance for the LiftC carrier to lift putStrLn into the middle of our computation:

example4 :: IO (Int, ())
example4 = runM . runReader "hello" . runState 0 $ do
  list <- ask
  liftIO (putStrLn list)
  put (length list)

(Note that we no longer need to give a type annotation for list, since putStrLn constrains the type for us.)

Required compiler extensions

To use effects, you’ll need a relatively-uncontroversial set of extensions: -XFlexibleContexts, -XFlexibleInstances, and -XMultiParamTypeClasses.

When defining your own effects, you’ll need -XTypeOperators to declare a Carrier instance over (:+:), and -XUndecidableInstances to satisfy the coverage condition for this instance. You may need -XKindSignatures if GHC cannot correctly infer the type of your handler; see the documentation on common errors for more information about this case.

The following invocation, taken from the teletype example, should suffice for any use or construction of effects:

{-# LANGUAGE DeriveFunctor, FlexibleContexts, FlexibleInstances, GeneralizedNewtypeDeriving,
    KindSignatures, MultiParamTypeClasses, TypeOperators, UndecidableInstances #-}

Defining new effects

The process of defining new effects is outlined in docs/defining_effects.md, using the classic Teletype effect as an example.

Project overview

This project builds a Haskell package named fused-effects. The library’s sources are in src, with doctests (property tests written in documentation comments) attached to most functions. Unit tests are in test, and library usage examples are in examples. Further documentation can be found in docs.

This project adheres to the Contributor Covenant code of conduct. By participating, you are expected to uphold this code.

Finally, this project is licensed under the BSD 3-clause license.

Development

Development of fused-effects is typically done using cabal new-build:

cabal new-build # build the library
cabal new-test  # build and run the examples, unit tests, and doctests

The package is available on hackage, and can be used by adding it to a component’s build-depends field in your .cabal file.

Versioning

Though fused-effects is suitable for production work, it is currently in a pre-release state. Though we will attempt to comply with the Haskell Package Versioning Policy standard, we make no concrete guarantees of API stability between versions < 1.0.0.0. Once v1.0.0.0 lands, all changes will abide by the PVP MAJOR.MAJOR.MINOR.PATCH standard.

Benchmarks

To run the provided benchmark suite, use cabal new-bench. You may wish to provide the -O2 compiler option to view performance under aggressive optimizations. fused-effects has been benchmarked against a number of other effect systems. See also @patrickt’s benchmarks.

Related work

fused-effects is an encoding of higher-order algebraic effects following the recipes in Effect Handlers in Scope (Nicolas Wu, Tom Schrijvers, Ralf Hinze), Monad Transformers and Modular Algebraic Effects: What Binds Them Together (Tom Schrijvers, Maciej Piróg, Nicolas Wu, Mauro Jaskelioff), and Fusion for Free—Efficient Algebraic Effect Handlers (Nicolas Wu, Tom Schrijvers).

Contributed packages

Though we aim to keep the fused-effects core minimal, we encourage the development of external fused-effects-compatible libraries. If you’ve written one that you’d like to be mentioned here, get in touch!

Comparison to mtl

Like mtl, fused-effects provides a library of monadic effects which can be given different interpretations. In mtl this is done by defining new instances of the typeclasses encoding the actions of the effect, e.g. MonadState. In fused-effects, this is done by defining new instances of the Carrier typeclass for the effect.

Also like mtl, fused-effects allows scoped operations like local and catchError to be given different interpretations. As with first-order operations, mtl achieves this with a final tagless encoding via methods, whereas fused-effects achieves this with an initial algebra encoding via Carrier instances.

Unlike mtl, effects are automatically available regardless of where they occur in the signature; in mtl this requires instances for all valid orderings of the transformers (O(n²) of them, in general).

Also unlike mtl, there can be more than one State or Reader effect in a signature. This is a tradeoff: mtl is able to provide excellent type inference for effectful operations like get, since the functional dependencies can resolve the state type from the monad type. On the other hand, this behaviour can be recovered in fused-effects using newtype wrappers with phantom type parameters and helper functions, e.g.:

newtype Wrapper s m a = Wrapper { runWrapper :: m a }
  deriving (Applicative, Functor, Monad)

instance Carrier sig m => Carrier sig (Wrapper s m) where …

getState :: (Carrier sig m, Member (State s) m) => Wrapper m s
getState = get

Indeed, Wrapper can now be made an instance of MonadState:

instance (Carrier sig m, Member (State s) sig, Monad m) => MTL.MonadState s (Wrapper s m) where
  get = Control.Effect.State.get
  put = Control.Effect.State.put

Thus, the approaches aren’t mutually exclusive; consumers are free to decide which approach makes the most sense for their situation.

Unlike fused-effects, mtl provides a ContT monad transformer; however, it’s worth noting that many behaviours possible with delimited continuations (e.g. resumable exceptions) are directly encodable as effects. Further, fused-effects provides a relatively large palette of these, including resumable exceptions, tracing, resource management, and others, as well as tools to define your own.

Finally, thanks to the fusion and inlining of carriers, fused-effects is approximately as fast as mtl (see benchmarks).

Comparison to freer-simple

Like freer-simple, fused-effects uses an initial encoding of library- and user-defined effects as syntax which can then be given different interpretations. In freer-simple, this is done with a family of interpreter functions (which cover a variety of needs, and which can be extended for more bespoke needs), whereas in fused-effects this is done with Carrier instances for newtypes.

(Tho note that as of fused-effects 0.3.1, it is possible to define handlers using runInterpret in a manner analogous to freer-simple’s interpret, with the caveat that its use of higher-order functions defeats the fusion and inlining of Carrier instances which makes fused-effects so efficient.)

Unlike fused-effects, in freer-simple, scoped operations like catchError and local are implemented as interpreters, and can therefore not be given new interpretations.

Unlike freer-simple, fused-effects has relatively little attention paid to compiler error messaging, which can make common (compile-time) errors somewhat more confusing to diagnose. Similarly, freer-simple’s family of interpreter functions can make the job of defining new effect handlers somewhat easier than in fused-effects. Further, freer-simple provides many of the same effects as fused-effects, plus a coroutine effect, but minus resource management and random generation.

Finally, fused-effects has been benchmarked as faster than freer-simple.

Changes

0.3.1.0

  • Improved speed of Reader, State, Writer, and Pure effects by defining and inlining auxiliary Applicative methods.
  • Adds runInterpret & runInterpretState handlers in Control.Effect.Interpret as a convenient way to experiment with effect handlers without defining a new carrier type and Carrier instance. Such handlers are somewhat less efficient than custom Carriers, but allow for a smooth upgrade path when more efficiency is required.
  • Added unliftio-core as a dependency so as to provide a blessed API for unlift-style effects and a solution to the cubic-caller problem.

0.3.0.0

Backwards-incompatible changes

  • Adds Monad as a superclass of Carrier, obviating the need for a lot of constraints, and Monad instances for all carrier types. This is a backwards-incompatible change, as any carriers users have defined now require Monad instances. Note that in many cases carriers can be composed out of existing carriers and monad transformers, and thus these instances can often be derived using -XGeneralizedNewtypeDeriving. We also recommend compiling with -Wredundant-constraints as many of these can now be removed.
  • Replaces AltC with a new carrier, NonDetC, based on Ralf Hinze’s work in Deriving Backtracking Monad Transformers. This is a backwards-incompatible change. AltC was equivalent to the ListT monad transformer, and had the same well-known limitation to commutative monads. Therefore, the elimination of Eff required a more durable approach.
  • Removes Branch. This is a backwards-incompatible change, but was necessitated by the difficulty of implementing correct Applicative & Monad instances for carriers which used it. Carriers which were employing Branch internally should be reimplemented using NonDetC or a similar approach; see CutC and CullC for examples.
  • Renames Control.Effect.Void, Void, and VoidC to Control.Effect.Pure, Pure, and PureC respectively. This is a backwards-incompatible change for code mentioning VoidC; it should be updated to reference PureC instead.

Deprecations

  • Eff and interpret, in favour of computing directly in the carriers. This enables the compiler to perform significant optimizations; see the benchmarks for details. Handlers can simply remove the Eff wrapping the carrier type & any use of interpret. As above, we also recommend compiling with -Wredundant-constraints as many of these can now be removed.
  • ret, in favor of pure or return.
  • handleEither, handleReader, handleState, handleSum, and handleTraversable in favour of composing carrier types directly. Carriers can be composed from other carriers and eff defined with handleCoercible; and other definitions can use handlePure & handle directly.

All deprecated APIs will be removed in the next release.

Other changes

  • Adds a lazy State carrier in Control.Effect.State.Lazy
  • Rewrites CutC using an approach related to NonDetC, with the addition of a continuation to distinguish empty from cutfail.
  • Rewrites CullC using ListC and ReaderC.
  • Moves OnceC from Control.Effect.NonDet to Control.Effect.Cull to avoid cyclic dependencies.
  • Adds a runCutAll handler for Cut effects, returning a collection of all results.

0.2.0.2

  • Loosens the bounds on QuickCheck to accommodate 2.x.

0.2.0.1

  • Fixes the benchmarks, and builds them in CI to avoid regressing them again.

0.2.0.0

  • Adds listen, listens, and censor operations to Writer.
  • Provides explicit type parameters to run-style functions in State, Reader, Writer, and Error. This is a backwards-incompatible change for clients using these functions in combination with visible type applications.
  • Adds benchmarks of WriterC/VoidC wrapped with Eff against their unwrapped counterparts.
  • Adds Functor, Applicative, and Monad instances for WriterC.
  • Adds Functor, Applicative, and Monad instances for VoidC.
  • Fixes a space leak with WriterC.
  • Removes the Functor constraint on asks and gets.
  • Adds bracketOnError, finally, and onException to Resource.
  • Adds sendM to Lift.

0.1.2.1

  • Loosens the bounds on QuickCheck to accommodate 0.12.

0.1.2.0

  • Adds support for ghc 8.6.2, courtesy of @jkachmar.
  • Adds a Cut effect which adds committed choice to nondeterminism.
  • Adds a Cull effect which adds pruning to nondeterminism.
  • Adds an example of using NonDet, Cut, and a character parser effect to define parsers.
  • Fixes the table of contents links in the README.

0.1.1.0

  • Adds a runNonDetOnce handler which terminates immediately upon finding a solution.

0.1.0.0

Initial release.

comments powered byDisqus