I’ve never been too convinced about effect systems, although I do recognize their value. Namely:
- Effect decoupling
- Effect traceability
- Effect restricting
- Effect mocking
I love the concepts, but can’t seem to force myself into liking the implementation. That’s why I’ve been playing with effects and came up with the following. It’s not without it’s drawbacks, the main being having to specify a LOT of type annotations, although I’m sure there’s some clever trick I don’t know about to avoid this.
To spicy it a bit, I’ll be simulating to do a pixel editor program with multiple rendering backends and an undo functionality (modeled as actions). First I’d put all my effect declarations in it’s own module:
module Effects where
data RenderBack = Gloss | OpenGL
class Render (b :: RenderBack) a where
drawImage :: a -> IO ()
data PersistFormat = JSON | CSV
class Persist (r :: PersistFormat) a where
loadActions :: FilePath -> IO a
saveActions :: a -> FilePath -> IO ()
...
That’s it for those guys. So far so good. Next, I’d define the state of the app:
module State (<this_is_important>) where
type Pos = (Int, Int)
data Pixel = P Pos Word16
data Action
= Put Pixel
| Del Pixel
data (Functor r, Functor f) => State r f = S
{ _image :: r Pixel
, _size :: (Int, Int)
, _actions :: f Action
}
...
I made it purposely more difficult for myself by parametrizing the images and actions. The module exports are important, because I’ll export everything BUT the State, as I want it to be accessible only from this module. This brings me to the next bit, which is not convenient at all, but I can’t say it’s a bad design either:
newState :: (Functor r, Functor f) => r Pixel -> (Int, Int) -> f Action -> State r f
newState = S
class (Functor r, Functor f) => StateOps r f a where
getImage :: a -> r Pixel
getSize :: a -> (Int, Int)
getActions :: a -> f Action
setImage :: a -> r Pixel -> a
setActions :: a -> f Action -> a
modImage :: a -> (Pixel -> Pixel) -> a
instance StateOps [] [] (State [] []) where
getImage = _image
getSize = _size
getActions = _actions
setImage s i = s {_image = i}
setActions s a = s {_actions = a}
modImage s f = setImage @[] @[] s $ fmap f (getImage @[] @[] s)
The newState function is the only way of creating a State which inconveniently requires a type annotation. Perhaps more inconvenient is the StateOps class which declares every operation that can be done on the State. I realize this is very boilerplaity, but it could also be useful. For instance, a resizeImage function would have to do the resizing and updating the size too to maintain consistency. That can be encapsulated here. Also, here is where those type applications start showing up, but, the types being in the signature, I bet there’s a way of avoiding those though.
Lastly, the usual Effect instances:
instance Render 'Gloss (State [] []) where
drawImage (S ps (sx, sy) _) = ... -- Elsewhere
instance Persist 'JSON (State [] []) where
loadActions fp = do
as <- loadFromJSON fp -- Elsewhere
pure $ S [] (0,0) as
saveActions s fp =
let as = getActions @[] @[] s
in saveAsJSON fp as -- Elsewhere
And that’s all folks!! I don’t think it’s too bad all in all. So how would I use this in a program? Say for example sake I want to have an effect that dims and draws an image altogether (for animating a fade for instance) and save the final image to a json file.
module Main (main) where
main = do
-- Gloss setup
s <- newTVarIO (newState [P (0,0) 180, ...] (640, 480) []) -- Could be an IORef too
loop s
-- Gloss shutdown
readTVarIO s >>= saveJSON "actions.json"
where
loop ms = do
...
s <- readTVarIO ms
s' <- dimAndDraw s 0.99
atomically $ writeTVar ms s'
...
dimAndDraw :: (Render 'Gloss a, StateOps [] [] a) => a -> Float -> IO a
dimAndDraw a factor = do
let a' = modImage @[] @[] a (\(P p r) -> P p (floor $ realToFrac r * factor))
drawImage @Gloss a'
pure a'
saveJSON :: (Persist 'JSON a) => FilePath -> a -> IO ()
saveJSON fp s = saveActions @JSON s fp
Since State is private to it’s module, the only way of interacting with it is through the classes it instances, and this gives effects traceability, restrictions and decoupling. What about composability? Since everything runs inside the IO monad, it’s naturally composable. For instance dimming, drawing and saving in the same step would be:
dimDrawSave fp factor s = dimAndDraw s factor >>= saveJSON fp
This one doesn’t even require type annotations and it’d have the union of the restrictions of the two effects it uses.
All the above cover effect traceability, restricting and decoupling. But how about mocking? Say my actions are not being saved correctly and I want to make sure the saving logic is. I could do the following:
data Dummy
instance Persist 'JSON Dummy where
saveActions _ fp = saveAsJSON fp [Put (P (0,0) 10), Put (P (5,0) 60)]
testJSONSave = saveActions @JSON @Dummy undefined "test.json"
Just hardcoded the list of actions inside the instance.
I’m sure this approach has a ton of limitations compared to effects, and that maybe effects do much more than I give them credit for. In that case, I’d be very happy to be finally convinced of using them