Playing with Effects

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 :slight_smile: 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 :smiley:

Amend: effect restricting here only happens as far of what Can be done on the state, but since I’m using IO everything is possible. I’m sure there’s a way of modeling that too with a custom monad, although that won’t be extensible probably …

You may be interested in an experimental library I wrote some time ago: GitHub - lortabac/parameters: Implicit parameters with Reader semantics

It uses records instead of classes so you also get dynamic dispatch. And it provides an IO wrapper so you can’t perform arbitrary IO unless you allow it explicitly.

In spite of the extremely short implementation, it is a fully-fledged effect system. But it comes with lots of caveats since it depends on GHC implementation details.