An Effect System based on Type Classes.
Please see the README on GitHub at https://github.com/typedbyte/effet#readme
effet
Overview
effet
is an effect system based on type classes, written in Haskell. A central idea of an effect system is to track effects (like performing a file system access) on the type level in order to describe the behavior of functions more precisely. Another central idea of an effect system is the well-known design principle of separating an interface (the effect) from its actual implementation (the effect handler or effect interpreter). Hence, effet
allows developers to write programs by composing functions which describe their effects on the type level, and to provide different implementation strategies for those effects when running the programs.
effet
is by far not the first library which pursues these ideas. It borrows various ideas from existing libraries, just to name a few:
- It is based on type classes, like
mtl
andfused-effects
. - It is based on plucking constraints, like
mtl
, but without the "n² instances problem". - It supports
TemplateHaskell
-based generation of the effect infrastructure, likepolysemy
. - It supports functional dependencies and tagged effects for effect disambiguation, like
ether
.
effet
has a rather down-to-earth implementation without much magic. It is like a thin wrapper around the transformers
library and its lifting friends transformers-base
and monad-control
, thus minimizing the reinvention of the wheel. The library and its documentation can be found on Hackage.
Quick-Start Guide
Examples can be found in the examples folder. The following sections give a quick overview of the most important features.
Defining Effects
When defining effects or effect handlers, the module Control.Effect.Machinery provides everything we need:
import Control.Effect.Machinery
Effects are ordinary type classes, like the following:
class Monad m => FileSystem' tag m where
readFile' :: FilePath -> m String
writeFile' :: FilePath -> String -> m ()
Note the tag
type parameter. Such a parameter is used to disambiguate effects of the same type, i.e. we can use multiple FileSystem
effects in our program simultaneously. In effet
, the naming convention is to use an apostrophe as name suffix whenever we use tags. However, effet
is not limited to that, we can easily define our effect without a tag
parameter:
class Monad m => FileSystem m where
readFile :: FilePath -> m String
writeFile :: FilePath -> String -> m ()
Let's go on with the tagged version for now and pretend that the untagged type class does not exist. The next step is to generate the effect handling, lifting and tagging infrastructure for our new effect using the TemplateHaskell
language extension:
makeTaggedEffect ''FileSystem'
This line generates the necessary infrastructure for combining our new effect with other effects. We also get the untagged version of the effect for free, i.e. the corresponding untagged definitions (FileSystem
, readFile
, writeFile
, note the missing apostrophes) are generated for us. This line also generates functions which help us to tag (tagFileSystem'
), retag (retagFileSystem'
) and untag (untagFileSystem'
) our effect. More on that later.
effet
also provides the function makeTaggedEffectWith
to define our own naming convention if we don't like the apostrophes. For untagged effects, we would have simply used the function makeEffect
instead.
Using Effects
We can now use our effect to write programs that access the file system. We will define a simple program which appends something to a file. In order to make it more interesting, we will combine it with the pre-defined Writer
effect to report the file size before and after appending, just to demonstrate the interplay with other effects:
import Control.Effect.Writer
program :: (FileSystem m, Writer [Int] m) => FilePath -> String -> m ()
program path txt = do
content <- readFile path
let size = length content
tell [size]
seq size $ writeFile path (content ++ txt)
tell [size + length txt]
In order to run this program, we need a handler (or "interpreter") for our effect. There are several ways to interpret our effect. We could, for example, really access our local file system or just provide a virtual, in-memory file system. Hence, we will define two effect handlers for demonstration purposes.
Defining Effect Handlers
First of all, we could – in theory – interpret effects without using effet
at all, which is something that is not possible in many other effect systems. Since effects are ordinary type classes, we would just instantiate the monad type m
with a type that provides instances for all (!) the effect type classes that constrain m
. This is where the so-called "n² instances problem" of mtl
comes from, since every effect handler that wants to handle a single effect (i.e., that wants to provide a type class instance for the effect it wants to handle) must also provide type class instances for all other effects – some of which might not even be known at compile-time – in order to delegate them to their corresponding effect handlers.
The advantage of effet
is the ability to interpret effects separately, one by one, by plucking constraints and not caring about all effects at the same time. In effet
, we write an effect handler by defining a type and a corresponding type class instance for the effect we are interested in and let the infrastructure of effet
handle the delegation of all other effects to their corresponding effect handlers.
An effect handler is a monad transformer which provides an instance for our effect type class. First, let's define the handler type for the local file system handler. We can derive all the necessary instances for proper effect lifting, some of them by using the DerivingVia
language extension:
newtype LocalFS m a =
LocalFS { runLocalFS :: m a }
deriving (Applicative, Functor, Monad, MonadIO)
deriving (MonadTrans, MonadTransControl) via IdentityT
deriving (MonadBase b, MonadBaseControl b)
Next, we provide the instance for our FileSystem'
effect. We need another import for that:
import qualified System.IO as IO
instance MonadIO m => FileSystem' tag (LocalFS m) where
readFile' path = liftIO $ IO.readFile path
writeFile' path txt = liftIO $ IO.writeFile path txt
Note that we do not need a separate instance for the untagged version of our effect which was generated before.
In order to make the usage of our instance more comfortable, we additionally provide two functions which instruct the type system to use our particular instance for handling the FileSystem
effect when running a particular program. We write one tagged and one untagged version:
runLocalFileSystem' :: (FileSystem' tag `Via` LocalFS) m a -> m a
runLocalFileSystem' = coerce
runLocalFileSystem :: (FileSystem `Via` LocalFS) m a -> m a
runLocalFileSystem = runLocalFileSystem' @G
We can also let effet
generate the untagged version of runLocalFileSystem
, so we don't have to write it by hand:
makeUntagged ['runLocalFileSystem']
Now let's provide similar definitions for our virtual file system. Instead of IO
, we will use the Map
effect that is shipped with effet
in order to map file paths to their corresponding contents. For simplification, we will assume that the contents of non-existing files are empty strings:
newtype VirtualFS m a =
VirtualFS { runVirtualFS :: m a }
deriving (Applicative, Functor, Monad, MonadIO)
deriving (MonadTrans, MonadTransControl) via IdentityT
deriving (MonadBase b, MonadBaseControl b)
instance Map' tag FilePath String m => FileSystem' tag (VirtualFS m) where
readFile' path = VirtualFS $ fromMaybe "" <$> lookup' @tag path
writeFile' path txt = VirtualFS $ insert' @tag path txt
runVirtualFileSystem' :: (FileSystem' tag `Via` VirtualFS) m a -> m a
runVirtualFileSystem' = coerce
makeUntagged ['runVirtualFileSystem']
Using Effect Handlers
Now we have all our puzzle pieces together in order to run our program with different effect handlers. Let's recap the type of our program:
program :: (FileSystem m, Writer [Int] m) => FilePath -> String -> m ()
Let's see what happens if we use our runVirtualFileSystem
function on that program after we feed it some file path and the content that should be appended to the file:
test :: (Map FilePath String m, Writer [Int] m) => m ()
test = runVirtualFileSystem $ program "/tmp/test.txt" "hello"
What happened? We handled the FileSystem
effect using our virtual file system handler (i.e., we "plucked the constraint"), which gives us a new program as result that still needs its Map
and Writer
effects handled. We essentially reinterpreted one effect (FileSystem
) in terms of another (Map
) without touching all the other effects (Writer
). In order to run the remaining two effects, we just need to import some of the pre-defined effect handlers for the Map
and Writer
effects:
import Control.Effect.Map.Lazy
import Control.Effect.Writer.Lazy
And here is the complete code for running our program either locally or virtually:
runLocalProgram :: MonadIO m => m ([Int], ())
runLocalProgram
= runWriter
. runLocalFileSystem
$ program "/tmp/test.txt" "hello"
runVirtualProgram :: Monad m => m ([Int], ())
runVirtualProgram
= runWriter
. runMap
. runVirtualFileSystem
$ program "/tmp/test.txt" "hello"
As we can see by the types, no IO
is involved in the virtual program, since we do not touch the actual file system.
We decided to handle our Map
and Writer
effects using their lazy implementations. We could, for example, easily switch the handlers to their strict counterparts, or even use some other Map-like implementation for our Map
effect, like Redis.
Tagging, Retagging, Untagging
If we write a program with multiple FileSystem'
effects, we can disambiguate them using the tags ...
fsProgram :: (FileSystem' "fs1" m, FileSystem' "fs2" m) => m ()
fsProgram = do
writeFile' @"fs1" "/tmp/test.txt" "first content"
writeFile' @"fs2" "/tmp/test.txt" "second content"
... and interpret them differently, one using the local and one using the virtual file system, for example:
runDifferently :: MonadIO m => m ()
runDifferently
= runMap' @"fs1"
. runVirtualFileSystem' @"fs1"
. runLocalFileSystem' @"fs2"
$ fsProgram
We could also use one tagged (FileSystem'
) and one untagged (FileSystem
) effect to disambiguate them, of course.
We can also change the tags of our effects before interpretation using the generated tagFileSystem'
, retagFileSystem'
and untagFileSystem'
functions. We could, for example, merge the two effects into a single tag and interpret them uniformly:
runUniformly :: Monad m => m ()
runUniformly
= runMap' @"fs1"
. runVirtualFileSystem' @"fs1"
. retagFileSystem' @"fs2" @"fs1"
$ fsProgram
In the example above, we merge tag fs2
into tag fs1
and interpret them together using the virtual file system. We can achieve the same result by untagging both effects and then using the untagged versions of the interpretation functions:
runUntagged :: Monad m => m ()
runUntagged
= runMap
. runVirtualFileSystem
. untagFileSystem' @"fs2"
. untagFileSystem' @"fs1"
$ fsProgram
Tagging, retagging and untagging are useful if we want to compose two functions which have the same effects, but we want to interpret them differently after composition. Note that there is a chance that two authors write two different libraries using the same effects, but do not know from each other ...
-- somewhere in library A
functionA :: FileSystem m => m ()
functionA = ...
-- somewhere in library B
functionB :: FileSystem m => m ()
functionB = ...
... and we want to compose these functions, but interpret the effects differently after composition:
-- oops, the effects were merged!
ourProgram :: FileSystem m => m ()
ourProgram = functionA >> functionB
We then need to introduce a tag for at least one of the two functions ...
-- now the effects are separated
ourProgram :: (FileSystem m, FileSystem' "b" m) => m ()
ourProgram = functionA >> tagFileSystem' @"b" functionB
... which again allows us to interpret the effects independently as described above.
Limitations and Remarks
TemplateHaskell
-based code generation can yield code that does not compile if you go crazy withm
-based parameters in higher-order effect methods (wherem
is the monad type parameter of the effect type class). In such cases, one has to write the necessary type class instances by hand. They are explained in the documentation of the moduleControl.Effect.Machinery.TH
.- The performance should be
mtl
-like, but this has not been verified yet.