simple-effects-0.13.0.0: A simple effect system that integrates with MTL

Safe HaskellNone
LanguageHaskell2010

Tutorial.T2_Details

Contents

Description

In this part, we'll take a more detailed look at this library. You don't need to know these things to use the effects. For the most part, you can also implement your own without reading this part. To learn about that check out the next part: Tutorial.T3_CustomEffects.

That being said, the details will help you understand potential compiler errors you might get. Also, implementing more complex effects does require a bit of an understanding of the internals.

Synopsis

    Transformers

    Monad transformers are types with the kind (* -> *) -> * -> * that take a monad as a parameter and add some extra functionality on top of it, resulting in a new monad. This isn't a tutorial on them so we'll just briefly go through the basics.

    Since a transformer takes a monad and produces a new monad, they can stack on top of each other forming 'transformer stacks'. The functions that utilize a transformer t's functionality only work directly if t is the topmost transformer on the stack. Since there can obviously only be one transformer on the top, and we want to use more than one effect, we need to lift their functions through the stack. For example: get :: Monad m => StateT s m s so if we want to use this function in a stack that has a ReaderT transformer on top we need to use lift:

    lift get :: Monad m => ReaderT r (StateT s m) s
    

    If you imagine we had 5 additional layers above the ReaderT, we'd need to call lift 5 more times. Instead we use type classes with polymorphic functions that work over any transformer stack. Their instances are arranged in a way that automatically calls lift as many times as needed. For example, the getState function in simple-effects has the type MonadEffect (State s) m => m s. Let's say our stack is ReaderT r1 (ReaderT r2 (StateT s IO)). The reason why we can use the getState function is because there are two instances for MonadEffect (State s).

    instance MonadEffect (State s) (StateT s m)
    

    and

    instance MonadEffect (State s) m => MonadEffect (State s) (ReaderT r m)
    

    Instance resolution first matches the second instance and sees that it requires a MonadEffect (State s) from the underlying monad m. Then it again encounters a ReaderT and finally it arrives at the StateT layer that has no other requirements. The actual implementation for the ReaderT instance does the lifting while the StateT implementation actually uses the structure of the StateT transformer to implement the required function.

    Note
    This is a bit of a simplification. There isn't really a MonadEffect (State s) (ReaderT r m) instance, but instead a more general MonadEffect e (t m) one that works for any transformer and any effect. It's an OVERLAPPABLE instance so more specific ones can be chosen if they exist (as is the case with, for example, the StateT instance for the State effect)

    Now let's see how a function like implementStateViaStateT works. It's type is implementStateViaStateT @Int :: Monad m => Int -> StateT Int m a -> m a. Say we also have a computation with the type MonadEffect (State Int) n => n () that we want to run. If we give that computation as the second parameter of the implementStateViaStateT function then n becomes StateT Int m a, but since there was also a constraint on the n type, instance resolution must check if that type satisfies it. This then finds the instance MonadEffect (State s) (StateT s m) instance and everything works. Then finally the actual definition of implementStateViaStateT kicks in and it runs the StateT transformer, giving it an initial state value and resulting in a monadic action that's free of the state constraint.

    If we had additional effect constraints on our initial computation, then the instance resolution would find the matching instances for the StateT transformer that would push the constraint onto the underlying monad. This means that our final result wouldn't just have a Monad constraint, but also those other ones that remain to be handled.

    What simple-effects does is provide a structured way to define new effects so that they can automatically be lifted through monad stacks. Also, for the more complex effects where just lift isn't enough, it enables the writer of the effect to specify how exactly to lift their effect. This is done through the Effect class.

    The Effect class

    The core of every effect is it's Effect instance. Here's how the class is defined:

    class Effect (e :: (* -> *) -> *) where
        type CanLift e (t :: (* -> *) -> * -> *) :: Constraint
        type CanLift e t = MonadTrans t
    
        type ExtraConstraint e (m :: * -> *) :: Constraint
        type ExtraConstraint e m = ()
    
        liftThrough ::
            (CanLift e t, Monad m, Monad (t m))
            => e m -> e (t m)
        mergeContext :: Monad m => m (e m) -> e m
    

    The type e is the name of your effect and it's where you specify the functionality that your effect provides. This will be a record of monadic functions. For example, there's how it's instantiated for the state effect:

    data State s m = StateMethods
        { _getState :: m s
        , _setState :: s -> m () }
    

    Then there's the CanLift associated type. It specifies the constraint that a transformer needs to satisfy to be able to lift the effect through it. Usually, MonadTrans is all you need, so that's the default instantiation. Some more complicated effects have tighter demands.

    The ExtraConstraint associated type lets you put additional requirements on monads that support your effect. The motivation for this extra constraint is that it allows to have parameters on your effect (like the s parameter in State s) but still allow only one instantiation of it. For example, you can't have a function with multiple different states. This restriction makes type inferrence much better in some cases and doesn't seem to get in the way too much in practice. If you do end up needing multiple states, check out the Control.Effects.Newtype module.

    The liftThrough function gets an implementation of the effect e in the monad m and is required to provide an implementation in the monad t m. Here's how it might look for the State effect.

    liftThrough (StateMethods g s) = StateMethods (lift g) (lift . s)
    

    As you can see, MonadTrans is enough in this case since the lift function is all we need.

    Finally, the mergeContext method. Given a record of methods in a monadic context, give me a new record of methods that somehow merges that context into it. The way it's implemented is simpler than it seems. For example, let's see how we'd implement a function with the following signature: f :: m (a -> m b) -> a -> m b

    Since our result is monadic we're free to just bind that inner function and give it the extra parameter like this:

    f mamb a = do
        amb <- mamb
        amb a
    

    If we had more than one function inside of the context, we'd just need to select the correct one after binding it. Here's how mergeContext is implemented for the State effect.

    mergeContext m = StateMethods
        { _getState = do
            sm <- m
            _getState sm
        , _setState s = do
            sm <- m
            _setState sm s }
    

    If these implementations seem pretty mechanical, it's because they are. So mechanical, in fact, that in most of the cases you don't even need to write them. Just derive the Generic class for your effect and you get those definitions for free. Here's the definition of the State s effect:

    data EffMethods (State s) m = StateMethods
        { _getState :: m s
        , _setState :: s -> m () }
        deriving (Generic)
    instance Effect (State s)
    

    There are a couple of conditions though. The automatic method deriving only works for what is called, in the terminology of this library, a simple effect. The methods of a simple effect are functions that take arguments that don't depend on the monad m and also have a monadic result in that monad. For example, setState :: s -> m () is a method of a simple effect while g :: m a -> m a isn't one because the first parameter depends on m.

    One extra condition is that there can't be any universally quantified variables in the methods, so for example if your effect has a method h :: forall a. a -> m (), you can't derive liftThrough and mergeContext for it automatically. This isn't because it's impossible (or even difficult), but because you can't derive Generic for those types. In those cases you need to write the implementations yourself.

    Finally, we have the MonadEffect class.

    The MonadEffect class

    It's defined like this

    class (Effect e, Monad m) => MonadEffect e m where
        effect :: e m
    

    So it just says that monads that implement the effect e need to provide an implementation of the effect's methods that works in that monad.

    As discussed in the transformers section, there's an overlappable instance given for the MonadEffect class. Here it is:

    instance {-# OVERLAPPABLE #-}
        (MonadEffect e m, Monad (t m), CanLift e t) => MonadEffect e (t m) where
        effect = liftThrough effect
    

    It means that any transformer t can implement the effect e as long as the underlying monad can implement it and the transformer satisfies the conditions that the effect imposes on it (in most cases, the MonadTrans constraint).

    Transformers that finally implement the effect have specific MonadEffect instances. For example:

    instance Monad m => MonadEffect (State s) (StateT s m) where
        effect = StateMethods get put
    
    Note
    The get and put functions come from the Control.Monad.Trans.State module.

    Runtime implementation

    Defining effects in a structured way like this has an additional benefit. It lets us use implementations that we construct at runtime! This is achieved through the RuntimeImplemented type. RuntimeImplemented is a special transformer defined like this:

    newtype RuntimeImplemented e m a = RuntimeImplemented
        { getRuntimeImplemented :: ReaderT (e m) m a }
    

    It's a wrapper around a ReaderT that carries around an e m implementation. RuntimeImplemented e has a MonadEffect e instance defined like this

    instance (Effect e, Monad m, CanLift e (RuntimeImplemented e))
        => MonadEffect e (RuntimeImplemented e m) where
        effect = mergeContext $ RuntimeImplemented (liftThrough $ ask)
    

    Essentially, ask gives us the record, but it's inside of a monadic context so we need mergeContext to get it out. This lets us implement any effect at runtime using the implement function:

    implement :: forall e m a. e m -> RuntimeImplemented e m a -> m a
    implement em (RuntimeImplemented r) = runReaderT r em
    

    As with implementStateViaStateT, when we give our polymorphic monadic action as a second parameter, instance resolution checks if RuntimeImplemented e m a has instances for all the effects we need. Most of them just propagate through to the inner m monad, but the effect e that we're implementing finds the instance above and uses that reader's environment to get the effect's methods. All that's left is to actually run the ReaderT by giving it the record which we're free to construct any way we want.

    Note
    One more thing. You might notice that the definition of the state effect doesn't mention the getState and setState functions. The methods in the record are called _getState and _setState, with an underscore. If you think about it you'll see that to actually use one of them, we first need to get them out of the record. Since we can access the record of methods for the current monad with the effect function, getting the current state would look like _getState effect so to reduce duplication it's convenient to define helpers like getState = _getState effect and setState = _setState effect. Even simpler, though arguably more magical, is the following top level definition
    StateMethods getState setState = effect
    

    When we write that at the top level, we bind the first field in the effect record to the getState name and the second field to the setState name. It's pretty weird because we're not in any specific monad, yet we're still somehow unpacking the record. Still, getState, for example, happily accepts the MonadEffect (State s) m => m s type signature and it just works.

    That's pretty much it. In the next part we'll take a look at implementing our own effects.