Don't Eff It Up

Freer Monads in Action

A talk by Sandy Maguire

reasonablypolymorphic.com

A dumb example.

We will make a (very) simple banking app.

Types first!

withdraw :: ( MonadIO     m
            , MonadLogger m
            )
         => Int
         -> m (Maybe Int)
withdraw :: ( MonadIO     m
            , MonadLogger m
            )
         => Int
         -> m (Maybe Int)

withdraw desired = do
  amount <- getCurrentBalance
  if amount < desired
     then do
       log "not enough funds"
       return Nothing

     else do
       putCurrentBalance $ amount - desired
       return $ Just amount

But how can we test it?

A new datatype describing if we're running for real:

data Mode = ForReal
          | Test (IORef Int)
withdraw :: ( MonadIO     m
            , MonadLogger m
            )
         => Mode
         -> Int
         -> m (Maybe Int)

withdraw mode desired = do
  amount <- case mode of
              ForReal    -> getCurrentBalance
              Test ioref -> liftIO $ readIORef ioref
  if amount < desired
     then do
       log "not enough funds"
       return Nothing

     else do
       let putAction =
             case mode of
               ForReal    -> putCurrentBalance
               Test ioref -> liftIO . writeIORef ioref
       putAction $ amount - desired
       return $ Just amount

This sucks!

Wouldn't it be nice...

... if we could just write the program that we cared about?

Polymorphism to the rescue!

class Monad m => MonadBank m where
  getCurrentBalance :: m Int
  putCurrentBalance :: Int -> m ()

The code we want to write.

withdraw :: ( MonadBank   m
            , MonadLogger m
            )
         => Int
         -> m (Maybe Int)

withdraw desired = do
  amount <- getCurrentBalance
  if amount < desired
     then do
       log "not enough funds"
       return Nothing

     else do
       putCurrentBalance $ amount - desired
       return $ Just amount

By adding this new constraint, we can abstract over IO.

Our application and test code can swap out different monads.

All is right in the world.

Or is it?

This abstraction comes with a heavy cost.

We need a carrier...

newtype IOBankT m a = IOBankT
  { runIOBankT :: IdentityT m a
  }

one that behaves with MTL...

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

  deriving ( Functor
           , Applicative
           , Monad
           , MonadError e
           , MonadIO
           , MonadRWS r w s
           , MonadReader r
           , MonadState s
           , MonadTrans
           , MonadWriter w
           , ...
           )

which implements our monad...

instance MonadIO m => MonadBank (IOBankT m) where
  getCurrentBalance = ...
  putCurrentBalance = ...

and doesn't need to be at the top of the stack...

instance MonadBank m => MonadBank (ReaderT r m) where
  getCurrentBalance = lift getCurrentBalance
  putCurrentBalance = lift . getCurrentBalance

instance MonadBank m => MonadBank (WriterT w m) where
  getCurrentBalance = lift getCurrentBalance
  putCurrentBalance = lift . getCurrentBalance

instance MonadBank m => MonadBank (StateT s m) where
  getCurrentBalance = lift getCurrentBalance
  putCurrentBalance = lift . getCurrentBalance

-- so many more

Nobody has time for this crap.

Things that take a lot of work don't get done.

Even if they're best practices.

Boilerplate gets in the way.

Monad transformers are a hack.

Everything else we use in Haskell composes.

Why don't monads?

There's a better way.

Free monads.

Monadic programs expressed as data structures we can manipulate.

Provided by the freer-effects package.

Write it now; decide what it means later.

Eff to the Rescue!

withdraw :: ( Member Bank   r
            , Member Logger r
            )
         => Int
         -> Eff r (Maybe Int)

withdraw desired = do
  amount <- getCurrentBalance
  if amount < desired
     then do
       log "not enough funds"
       return Nothing

     else do
       putCurrentBalance $ amount - desired
       return $ Just amount

Small change. Big impact.

withdraw :: ( MonadBank   m
            , MonadLogger m
            )
         => Int
         -> m (Maybe Int)
withdraw :: ( Member Bank   r
            , Member Logger r
            )
         => Int
         -> Eff r (Maybe Int)

Listen to the types.

An unambiguous monad.

withdraw :: ( Member Bank   r
            , Member Logger r
            )
         => Int
         -> Eff r (Maybe Int)

No nominal typing.

withdraw :: ( Member Bank   r
            , Member Logger r
            )
         => Int
         -> Eff r (Maybe Int)

Effects as data.

{-# LANGUAGE GADTs #-}

data Bank a where
  GetCurrentBalance :: Bank Int
  PutCurrentBalance :: Int -> Bank ()
getCurrentBalance :: Member Bank r
                  => Eff r Int
getCurrentBalance = send GetCurrentBalance
putCurrentBalance :: Member Bank r
                  => Int
                  -> Eff r ()
putCurrentBalance amount = send $ PutCurrentBalance amount

Still too much boilerplate?

{-# LANGUAGE TemplateHaskell #-}

data Bank a where
  GetCurrentBalance :: Bank Int
  PutCurrentBalance :: Int -> Bank ()

makeFreer ''Bank

Don't forget the lumberjack.

data Logger a where
  Log :: String -> Logger ()

makeFreer ''Logger

What's left?

withdraw :: ( Member Bank   r
            , Member Logger r
            )
         => Int
         -> Eff r (Maybe Int)

The REPL can help.

> :kind Eff

Eff :: [* -> *] -> * -> *

An exact correspondence.

StateT s (ReaderT r IO) a
Eff '[State s, Reader r, IO] a

So what?

main runs in IO -- not in Eff.

We have one special function:

runM :: Monad m => Eff '[m] a -> m a

Not just for monads!

run :: Eff '[] a -> a

run and runM provide base cases.

Induction.

We want a function that looks like this:

runLogger :: Eff (Logger ': r) a
          -> Eff r a

It "peels" a Logger off of our eff stack.

What does it mean to run a Logger?

Maybe we want to log those messages to stdout.

runLogger :: Member IO r
          => Eff (Logger ': r) a
          -> Eff r a

All for naught?

No!

Even though we have IO here, it's not the program that requires it; only the intepretation.

runLogger :: Member IO r
          => Eff (Logger ': r) a
          -> Eff r a

runLogger = runNat logger2io
  where
    logger2io :: Logger x -> IO x
    logger2io (Log s) = putStrLn s

We can do the same thing for Bank.

runBank :: Member IO r
        => Eff (Bank ': r) a
        -> Eff r a

runBank = runNat bank2io
  where
    bank2io :: Bank x -> IO x
    bank2io GetCurrentBalance            =  -- use IO to return an Int
    bank2io (PutCurrentBalance newValue) =  -- perform IO and return ()

Back to the REPL.

> :t (runM . runLogger . runBank)

Eff '[Bank, Logger, IO] a -> IO a
> :t (runM . runLogger . runBank $ withdraw 50)

IO (Maybe Int)

But how can we test this?

{-# LANGUAGE ScopedTypeVariables #-}

ignoreLogger :: forall r a
              . Eff (Logger ': r) a
             -> Eff r a

ignoreLogger = handleRelay pure bind
  where
    bind :: forall x
          . Logger x
         -> (x -> Eff r a)
         -> Eff r a
    bind (Log _) cont = cont ()
testBank :: forall r a
           . Int
          -> Eff (Bank ': r) a
          -> Eff r a

testBank balance = handleRelayS balance (const pure) bind
  where
    bind :: forall x
          . Int
         -> Bank x
         -> (Int -> x -> Eff r a)
         -> Eff r a
    bind s GetCurrentBalance      cont = cont s  s
    bind _ (PutCurrentBalance s') cont = cont s' ()

Finally, pure interpretations!

> :t (run . ignoreLogger . testBank)

Eff '[Bank, Logger] a -> a
> :t (run . ignoreLogger . testBank $ withdraw 50)

Maybe Int

So far, this doesn't seem very reusable.

Instead of this...

data Logger a where
  Log :: String -> Logger ()

Why not this?

data Writer w a where
  Tell :: w -> Writer w ()

Note: there is no Monoid constraint here!

Instead of this...

data Bank a where
  GetCurrentBalance :: Bank Int
  PutCurrentBalance :: Int -> Bank ()

Why not this?

data State s a where
  Get :: State s s
  Put :: s -> State s ()

This gives us more denotational meaning.

withdraw :: ( Member (State Int)     r
            , Member (Writer String) r
            )
         => Int
         -> Eff r (Maybe Int)

withdraw desired = do
  amount :: Int <- get
  if amount < desired
     then do
       tell "not enough funds"
       return Nothing

     else do
       put $ amount - desired
       return $ Just amount

Mo' generality = fewer problems.

More general types are more likely to already have the interpretations that you want.

A drop-in for MTL?

Yes! But more than just that!

A conceptually different execution model.

In MTL:

runReaderT :: ReaderT x m a -> x -> m a

In Eff:

runReader :: Eff (Reader x ': r) a -> x -> Eff r a

Interpreting effects in terms of one another.

data Exc e a where
  ThrowError :: e -> Exc e a
makeFreer ''Exc
accumThenThrow :: ( Eq e
                  , Monoid e
                  , Member (Exc e) r
                  )
                => Eff (Writer e ': r) a
                -> Eff r a
accumThenThrow prog = do
  let (a, e) = pureWriter prog
  unless (e == mempty) $ throwError e
  return a

Non-trivial transformations.

data SetOf s a where
  SetAdd      :: s -> SetOf s ()
  SetContains :: s -> SetOf s Bool
makeFreer ''SetOf
dedupWriter :: ( Member (SetOf  w) r
               , Member (Writer w) r
               )
            => Eff r a
            -> Eff r a
dedupWriter = interpose pure bind
  where
    bind (Tell w) cont = do
      alreadyTold <- setContains w
      unless alreadyTold $ do
        setAdd w
        tell w
      cont ()

Summing up.

Thanks for listening!

Questions?

SpaceForward
Right, Down, Page DownNext slide
Left, Up, Page UpPrevious slide
POpen presenter console
HToggle this help