Skip to content

Latest commit

 

History

History
212 lines (177 loc) · 9.75 KB

2013-12-04-24-days-of-hackage-extensible-effects.md

File metadata and controls

212 lines (177 loc) · 9.75 KB
title
24 Days of Hackage: extensible-effects

To a lot of programmers, the idea of programming with monads is almost synonymous with the idea of programming in Haskell. Of course, to those us who write a lot of Haskell we only consider monads a small part of our work - but it's true, monads do have a fairly important role in how we structure our computations. Despite the benefits monads give us, they are not without their pain points. For a long time, we've known how to combine monads (see for example, last year's post on transformers) - but we've also known that monads are not closed under composition. This means that if you take any two monads, it's not always true that they can be composed to form a new monad. There's some great discussion of this on Stack Overflow, which I highly recommend reading.

So even though it's not always possible to compose monads, we do at least have the transformers and mtl libraries to allow us to layer up monads to make more powerful monads. This is definitely a very powerful way of programming, but even then having an enforced ordering of the layers can make interaction between the layers a difficult task. Section 5 of the paper "Extensible Effects" by Kiselyov, Sabry and Swords discusses just this, and they show some computations that simply can't be expressed by independent layering of monads. I won't go into the details now, but this is also interesting reading!

If we can't always compose monads, and the layering can be cumbersome, is it time to abandon ship and go back to writing Java? Absolutely not! Monad transformers are actually only one of the available tools we have available, and since the above paper was published we have another new tool - extensible-effects. extensible-effects is a direct implemantion of the concepts explored in the paper. Today, we'll have a look at how we can use this library to build effectful computations.

The problem that we're looking to solve is being able to add in logging support for applications. However, we might change our minds on how we want to perform this logging - for example in production we want to log to syslog, in development we want to log to stderr, and in tests we want to be able to collect the logs into some value that we can inspect - after all, logging is a feature and should be tested as such. Let's have a look at how we can do this using the effect framework given by extensible-effects.

The main twist with extensible effects is that it operates under a client/server type abstraction. You, the client, program under the Eff monad, and send requests to a "server". This server then interprets the request, performing whatever effects it needs to, and then responds to the client. This describes the "effects" part - the "extensible" part comes out of the fact that the set of possible requests and servers to handle them is open - users are able to define their own, outside the extensible-effects library.

We can see this by looking closer at the type of the Eff monad. Eff takes a type parameter r which describes the set of "requests" that a client can send, which in turn corresponds to the set of possible effects a program can have. This is an open set, so you can add in new effects in your own libraries. In our case, we're going to build a logging effect. Starting from the top-level, we want to be able to write something like this:

verboseAddition :: Member Log r => Eff r Int
verboseAddition = do
  log "I'm starting with 1..."
  x <- return 1

  log "and I'm adding 2..."
  y <- return 2

  let r = x + y

  log $ "Looks like the result is " ++ show r
  return r

So we know that we have a client/server model, and that this is all managed for us by the Eff monad. But how do we actually submit a request? This is done using the send primitive. The type of send is a bit scary...

send :: (forall w. (a -> VE w r) -> Union r (VE w r)) -> Eff r a

...but it's actually really quite simple. To send a request, we have to provide a function that will produce the description of a step in our final computation. We can also provide a result to the next action, though we won't need that functionality here. I'll jump right in to the code, and we'll reflect on how this works after:

data Log v = Log String v deriving (Functor, Typeable)

log :: Member Log r => String -> Eff r ()
log txt = send $ \next -> inj (Log txt (next ()))

To log some text, we introduce a Log action and keep hold of the text we should be logging. Logging doesn't produce a result, so we simply pass on () to the next action. This lets us construct a Log value, which we inject into the open set of requests using inj.

Now that we have a way to introduce the effect, we also need a way to run it. The concept of running events mean that if we are given an Eff with some effect in its set of effects, we can produce a new Eff which doesn't have that effect - because we've now performed it. So we gradually run the effects we need to, until we have ran all effects.

There are two functions that we need to run effects. The first is admin, which is used to begin the evaluation process. admin turns our Eff r a into a VE a r - and this type indicates whether we are producing a value (possibly using other effects), or performing an effect and another computation. Let's start by dealing with the simple case - Val:

runLogger :: Eff (Log :> r) a -> Eff r (a, [String])
runLogger logAction = go (admin logAction)
 where
  go (Val v) = return (v, [])

If our logAction is constructed with Val then it produced no log lines, so we return an empty log, and the value itself.

So far so good. Next, we need to do the interesting part - actually dealing with logging effect. This is done by using the handleRelay function. handleRelay takes a request, and works out whether or not it is an effect that we should deal with, or if it is a different type of effect. Thus we need to know how to relay requests on, and how to deal with an actual logging request. Relaying on requests is as simple as just calling go. If we have a request to log something, then we perform that logging, and then perform the rest of the computation. Therefore, we end up with:

runLogger :: Eff (Log :> r) a -> Eff r (a, [String])
runLogger logAction = go (admin logAction)
 where
  go (Val v) = return (v, [])
  go (E request) =
    let prefixLogWith txt (v, l) = (v, txt:l)
        performLog (Log txt next) =
          fmap (prefixLogWith txt) (go next)
    in handleRelay request go performLog

We use handeRelay on the request, and use go to relay on requests that aren't interesting to us. If it's a logging request, then we have access to the Log constructor we introduced earlier. We pattern match on this to discover the text that we have to log, and the computation to run after performing the logging. The later computation will return a value and a log, so we just prefix the log of the further computation with this log entry. And we're done!

We can now run our verbose addition:

> :t run (runLogger verboseAddition) 
run (runLogger verboseAddition) :: (Int, [String])

> run (runLogger verboseAddition)
(3,["I'm starting with 1...","and I'm adding 2...","Looks like the result is 3"])

We can also run our logger action by logging to stderr, and here things become a bit more interesting. If you have a computation that requires logging, and you want to run it by logging to stderr then you actually have a computation that requires logging and IO. Here we see our first interaction of effects. Thankfully, it's really quite straight forward. Here's the code:

runIOLogger :: SetMember Lift (Lift IO) r => Eff (Log :> r) a -> Eff r a
runIOLogger logAction = go (admin logAction)
 where
  go (Val v) = return v
  go (E request) =
    let performLog (Log txt next) =
          lift (putStrLn txt) >> go next
    in handleRelay request go performLog

Now our performLog function has changed. We pattern match on the Log request as we did before, but the Eff we now return is to first perform some IO, and then to perform the rest of the computation. It's also worth noticing how the top-level type signature has changed. We can see that runIOLogger takes an Eff and removes the logging effect (by running it), which gives us a subset of the original effects. However, that subset also has to contain the ability to perform IO - and that's what SetMember Lift (Lift IO) r is all about.

This time, we have to run the action in a slightly different form:

> :t runLift $ runIOLogger verboseAddition
runLift $ runIOLogger verboseAddition :: IO Int

> runLift $ runIOLogger verboseAddition   
I'm starting with 1...
and I'm adding 2...
Looks like the result is 3
3

I really like the extensible-effects work, and find it a very refreshing way of exploring ideas. While I haven't built anything hugely complex with it, I certainly hope I will, as I think it's a really powerful way of programming. Oleg mentioned in the Haskell cafe that

I must stress that thinking of extensible-effects effects as just another implementation of MTL is not productive. Not all effects can be decomposed into State, Reader, etc. layers. Manly [sic], effects should not be decomposed into layers.

It's entirely possible my post has missed the point, so I look forward to being told just how wrong I am! The code for today's post is on Github.