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.

2 Likes

I was wondering why is it IO a instead of IO e a where e would be an Exception constraint? Some thoughts:

  • I can’t conjure up any exception free side effects.
  • Exceptions are not encoded anywhere, but are everywhere.
  • It’s usually difficult to find in the docs what exact exceptions are thrown.

Bonus: This could be used to “segment” effectful code. I can imagine some shared exceptions like FileNotFoundException and WrongPermissionException, but also some artificial exceptions like ReadFileException and WriteFileException. Those plus some ExplicitExceptions ghc plugin could help segment effects. For example:

readFile :: (ReadFileException e) => FilePath -> IO e a
writeFile :: (WriteFileException e) => FilePath -> a -> IO e a

loadStuff :: IO e a
loadStuff = ... >> readFile "x" -- Should fail 

loadStuff :: (ReadFileException e) => IO e a
loadStuff = ... >> readFile "x" -- Should compile

loadStuff :: (ReadFileException e) => IO e a
loadStuff = ... >> readFile "x" >>= writeFile "y" -- Should fail

This could be used for quick effect traceability and restricting, without having to go for a full monad.

For one because asynchronous exceptions exist and they can come up from anywhere at any time.

What implementation exactly? Have you looked at effectful? It’s a modern implementation that’s flexible, fast and successfully used in production.

4 Likes

I guess I’m not smart enough to understand why that’d be a problem? If anything, I’d imagine functions like forkIO having an AsyncException constraint that must be directly addressed instead of silently sneaking into the program and leaving orphaned threads.

Maybe I was ambiguous to my detriment, I never meant I didn’t like the library implementation, I’ve actually read a bunch of them and think they are very smart, and more than that, the accumulation of a lot of experience. What I meant is that I don’t like how they force me to structure my code, which is totally subjective, but philosophically, I’m not comfortable saying that my program is a Reader+State+Logger, when it simply uses those abstractions.

I’m perfectly happy using monads like STM where and when I need them though.

1 Like

Then why not introduce an abstraction for what your program is, which simply defers to those lower level components in a handler?

1 Like