Safe Haskell | None |
---|---|
Language | Haskell2010 |
Tutorial.T3_CustomEffects
Contents
Description
Let's see how we can implement custom effects. We'll go through a couple of examples of increasing complexity. This part does not require going through part 2. That being said, understanding the details will help with understanding some of the restrictions and will help with compiler errors you might get.
Synopsis
Files
To start off, we'll define an effect for working with the filesystem. To keep it simple, we'll define two functions. One for reading the contents of a file, and one for writing them to a file.
The first step is to declare a new record data type that holds the methods of our effect.
The signatures of our functions will be
and
FilePath
-> m ByteString
respectively.FilePath
-> ByteString
-> m ()
data Files m = FilesMethods { _readFile ::FilePath
-> mByteString
, _writeFile ::FilePath
->ByteString
-> m () } deriving (Generic
)
Next, we need to provide an instance of the Effect
class for our effect.
Both of our methods are what we call, in the
jargon of this library, a simple effect. It means that they are functions that return a monadic
action, and that their arguments don't depend on that monad. As an example, m Int -> m Int
isn't a method of a simple effect because the argument depends on m
.
Because of this fact, the instance is super simple:
instance Effect
Files
The class has two functions: liftThrough
and mergeContext
. Luckily, because our effect
is simple (and mostly they will be), it's enough to just derive Generic
for our type. The
functions are then defined for us.
It's a convention to name the methods starting with an underscore so we can define the following helper functions:
readFile ::MonadEffect
Files m =>FilePath
-> mByteString
writeFile ::MonadEffect
Files m =>FilePath
->ByteString
-> m () FilesMethods readFile writeFile =effect
So how does this work, and what does it even do? Well, we defined what a record of methods looks like
for our effect, but how is that record actually constructed? If we wanted to use our methods
where would we get them from? Enter the effect
function. It's type signature is
. It means that for every monad MonadEffect
e m => e mm
which supports the
effect e
, the effect
function gives us an implementation of the effect's methods. Let's say we want
to read the contents of a file. First we'd use the effect
function to get the methods of our
Files
effect, then we'd get the _readFile
function out of it, and finally we'd use that
function.
myFunction = do let methods =effect
let rfFunction = _readFile methods rfFunction "somefile.txt" -- or equivalently _readFileeffect
"somefile.txt"
Now since writing _readFile
gets tedious, we can define new top level helper
functions:effect
readFile = _readFileeffect
writeFile = _writeFileeffect
The _readFile
and _writeFile
functions are nothing more than record selectors that get the
appropriate method from our FilesMethods
record. With this in mind we can skip them and just
directly pattern match on the FilesMethods
constructor like
FilesMethods readFile writeFile = effect
Check out the previous part if you want to learn more.
Implementations
We've only defined the syntax
of our effect. How do we actually run those functions? This is
done through the implement
function. It lets us construct the implementations at runtime.
Suppose we have a function like
myFunc :: MonadEffect
Files m => m ()
myFunc = do
file <- readFile "file.dat"
newFile <- doSomething file
writeFile "file.dat" newFile
To use it in our program we need to handle the MonadEffect
constraint. This is how we might do
it:
main :: IO () main = doimplement
(FilesMethodsreadFile
writeFile
) myFunc -- *NOTE* The readFile and writeFile functions used here are *not* the ones we defined above -- They're imported from the Data.ByteString module
Here we implemented our effect using the readFile
and writeFile
functions from the
Data.ByteString module. Of course we're free to implement them however we want. In a testing
environment we might instead just read/write from a
and simulate
a filesystem.Map
FilePath
ByteString
To make it easier to use our effect, it's a good idea to provide one or more default handlers. For example, we might define two handlers:
implementFilesViaIO ::MonadIO
m =>RuntimeImplemented
Files m a -> m a implementFilesViaMap ::Monad
m =>RuntimeImplemented
Files (StateT
(Map
FilePath
ByteString
) m) a -> m a
This way the users of our effect don't have to implement the handlers themselves, but are still free to implement more specialized ones.
For our next effect we'll do logging. Just a simple printing function that takes anything
with a Show
instance and logs it somewhere.
The signature we want is print :: (
.MonadEffect
Print m, Show
a) => a -> m ()
Now here's
the main issue. The a
variable isn't mentioned anywhere in the effect. After all, we don't
want a separate effect for each possible type. We want the Print
effect to provide printing
for all types with a Show
instance. To this end we'll use the RankNTypes
extension and define
our Effect
instance like this:
newtype Print m = PrintMethods
{ _print :: forall a. Show a => a -> m () }
instance Effect
Print where
Notice we didn't derive Generic
. This is because we can't. Despite our effect being simple
(the function's parameter doesn't depend on m
and it returns a monadic action), the forall
in there makes it impossible to derive a Generic
instance. Unfortunately, this means that we
have to implement the two functions of the Effect
class ourselves. Fortunately, it's a very
mechanical procedure (that's why they can usually be automatically derived!).
The two functions are
liftThrough
:: (MonadTrans
t,Monad
m,Monad
(t m)) => e m -> e (t m)
and
mergeContext
::Monad
m => m (e m) -> e m
- Note
- The
MonadTrans
part is a slight simplification, but it's an honest one for our current example. You can read more about the actual definitions and the purpose of these two functions in the previous part of the tutorial.
The first function, liftThrough
, takes a record of methods of the effect e
for the monad
m
, and is expected to return a new record, but this time for the monad t m
. Two puzzle
pieces make this very easy to do. The first is the fact that the only place where m
is mentioned
in our effect is in the result of the _print
function. The second piece of the puzzle is the
function of the lift
:: (MonadTrans
t, Monad
m) => m a -> (t m) aMonadTrans
class. So to
construct the new _print
method, we just call the old one and lift
the result:
liftThrough
(PrintMethods pr) = PrintMethods (\a ->lift
(pr a))
This will work for as many methods with as many parameters as you want. The implementation will always look something like
liftThough
(MyMethods m1 m2 m3 m4) = MyMethods (\a ->lift
(m1 a)) (\a b c ->lift
(m2 a b c)) (\a b ->lift
(m3 a b)) (lift
m4)
Up next: mergeContext
. It says that given the record of methods inside a monadic context,
give me just the record, somehow pushing that context inside of it. The implementation is again
very mechanical.
mergeContext
pm = PrintMethods
(\a -> do
PrintMethods p <- pm
p a)
Essentially, we just pass the parameters through to the old record, but first we must actually get the old record out of the monadic context. We can do that because each method's result is a monadic action. Here's how it would look like for a bigger effect:
mergeContext
mm = MyMethods
(\a -> do
MyMethods m _ _ _ <- mm
m a)
(\a b c -> do
MyMethods _ m _ _ <- mm
m a b c)
(\a b -> do
MyMethods _ _ m _ <- mm
m a b)
(do
MyMethods _ _ _ m <- mm
m)
- Note
- Instead of pattern matching, we can use the name of our method as a field selector:
mergeContext
pm = PrintMethods (\a -> do m <- pm _print m a)
As you can see, there isn't much to these implementations. Just boilerplate. Also note that
while our Print
effect may seem simple, it's actually more complicated than it needs to be.
Instead we could have defined the whole thing like this:
data Print m = PrintMethods { _printString ::String
-> m () } deriving (Generic
) instanceEffect
Print where print :: (MonadEffect
Print m,Show
a) => a -> m () print = _printStringeffect
.show
That way we still get a nice polymorhpic function, but the effect itself is monomorphic and
lets us get away with just deriving Generic
.
Next, we'll look at a non-simple effect. One for which the liftThrough
method can't be
derived because there isn't just a single valid implementation.
Fork
Here's the challenge. There's a forkIO
function in base with the following signature
forkIO
:: IO () -> IOThreadId
We want to generalize this function to work with monads other than IO
. Essentially, we want
fork ::MonadEffect
Fork m => m () -> m (Maybe
ThreadId
)
Notice that this isn't a simple effect as the parameter is a monadic action. Anyways, let's try defining our effect and see where we get stuck:
data Fork m = ForkMethods { _fork :: m () -> m (Maybe
ThreadId
) } instanceEffect
Fork wheremergeContext
mm = ForkMethods (\a -> do ForkMethods m <- mm m a)liftThrough
(ForkMethods f) = ForkMethods (\a ->lift
(f a))
Simple right? Well, unfortunately it doesn't typecheck. The problem is in the liftThrough
function. Here are the relevant types:
f :: m () -> m ThreadId
a :: t m ()
The result we need is of type t m
. If we could somehow get a ThreadId
m
we'd
be fine since just ThreadId
lift
ing that does the trick. The problem is, the only way to get a
m
is by calling ThreadId
f
with a m ()
, and we don't have that. What we do have is
a :: t m ()
so it seems that we need a function that's opposite of lift
. Something like
unlift :: t m a -> m a
.
Turns out, that's not so simple to do. Imagine you have a function of type a -> m b
.
In this case the a ->
part is t
. If we specialize unlift
to that we get
unlift :: (a -> m b) -> m b
. There's no way to implement that function. To get m b
we need
to have an a
, but none are given to us.
In any case, even if unlift
was possible to implement, it's not like we could use it. The only
thing we know about t
is that it has a MonadTrans
instance (that's where we get the lift
function from)... Well, not exactly. Remember that note about MonadTrans
being a
simplification? The Effect
class actually has an additional associated type. It lets us
require a custom constraint for our transformer so we can actually require t
to be an instance
of anything we like.
- Note
- This extra power doesn't come for free, though. Stricter requirements mean that your effect can't
be used in certain situations. What this means exactly is a bit out of the scope of this tutorial,
but here's a quick rundown.
Handling effects requires monad transformers. Each effect handled will result in at least one extra transformer on your transformer stack. Those transformers are the types that need to satisfy the requirements of your effect. Having
MonadTrans
as a requirement is basically free since each transformer has aMonadTrans
instance, kind of by definition. Anything extra and things get a bit more complicated. To "lift" functions likeforkIO
into other transformers, people usually use theMonadTransControl
class from the "monad-control" package. Pretty much all the standard transformers are instances of that class, with one exception:ContT
isn't an instance ofMonadTransControl
.ContT
is a pretty exotic transformer though.
What we're going to use here is the RunnableTrans
class. This is an alternative to the
MonadTransControl
class that's hopefully a bit easier to use. It lets us "run" a transformer
if we give it the right state value. It also lets us get the current state value from the context
and it can restore the context from the result of running the transformer. To cut through the
confusion (or perhaps to introduce more of it) here's the code:
data Fork m = ForkMethods { _fork :: m () -> m (Maybe
ThreadId
) } instanceEffect
Fork where typeCanLift
Fork t =RunnableTrans
tmergeContext
mm = ForkMethods (\a -> do ForkMethods m <- mm m a)liftThrough
(ForkMethods f) = ForkMethods (\a -> do st <-currentTransState
lift
(f (void
(runTransformer
a st))) )
Essentially what we do here is get the current state from the main computation (the one doing
the forking), using that state to run the forked computation, discard both its result and
it's state using the void
function, then we finally call the original function and lift
the
whole thing.
- Note
- Since we're discarding the result and the state of the forked computation, and this will
happen for each transformer/effect in the stack, it means that only effects that "survive"
are the final
IO
ones. The state of the original computation does get shared with the forked one, so that's pretty useful, but if we care about what the forked computation did with that state, we need to communicate with the original thread manually through someIO
mechanism likeMVar
s. Check out theAsync
effect that this library provides for an alternative.
What about the effect handlers? How do we write one for our Fork
effect? Here's one that
ignores completely what the intended semantics were and just runs the thing sequentially:
implementForkSequentially :: Monad m => RuntimeImplemented Fork m a -> m a implementForkSequentially =implement
(ForkMethods (c -> c >>return
Nothing
))
But to really fork the computation we have to use the original forkIO
function like this:
implementForkIO :: RuntimeImplemented Fork IO a -> IO a implementForkIO =implement
(ForkMethods (fmap
Just
.forkIO
))
Notice that this forces our monad to IO. This means no other effects can be handled after it.
This is manageable if Fork
is the only effect with that condition, but what if there are more
IO
based ones? A solution is to provide a
instance directlyMonadEffect
Fork IO
instanceMonadEffect
Fork IO where effect = ForkMethods (fmap
Just
.liftIO
)
Now we can write implementForkIO
like this
implementForkIO :: IO a -> IO a
implementForkIO = id
As you can see, the function doesn't do anything, but it does force whatever we give it to be
in the IO
monad. This again means that we must handle this effect after all others, but if
there are other effects with the same requirement, they can all be handled at the end.