We will make a (very) simple banking app.
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
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
... if we could just write the program that we cared about?
class Monad m => MonadBank m where getCurrentBalance :: m Int putCurrentBalance :: Int -> m ()
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.
Or is it?
This abstraction comes with a heavy cost.
newtype IOBankT m a = IOBankT { runIOBankT :: IdentityT m a }
{-# LANGUAGE GeneralizedNewtypeDeriving #-} deriving ( Functor , Applicative , Monad , MonadError e , MonadIO , MonadRWS r w s , MonadReader r , MonadState s , MonadTrans , MonadWriter w , ... )
instance MonadIO m => MonadBank (IOBankT m) where getCurrentBalance = ... putCurrentBalance = ...
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
Even if they're best practices.
Boilerplate gets in the way.
Everything else we use in Haskell composes.
Why don't monads?
Monadic programs expressed as data structures we can manipulate.
Provided by the freer-effects package.
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
withdraw :: ( MonadBank m , MonadLogger m ) => Int -> m (Maybe Int)
withdraw :: ( Member Bank r , Member Logger r ) => Int -> Eff r (Maybe Int)
withdraw :: ( Member Bank r
, Member Logger r
)
=> Int
-> Eff r (Maybe Int)
withdraw :: ( Member Bank r
, Member Logger r
)
=> Int
-> Eff r (Maybe Int)
{-# 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
{-# LANGUAGE TemplateHaskell #-} data Bank a where GetCurrentBalance :: Bank Int PutCurrentBalance :: Int -> Bank () makeFreer ''Bank
data Logger a where Log :: String -> Logger () makeFreer ''Logger
withdraw :: ( Member Bank r
, Member Logger r
)
=> Int
-> Eff r (Maybe Int)
> :kind Eff Eff :: [* -> *] -> * -> *
StateT s (ReaderT r IO) a
Eff '[State s, Reader r, IO] a
main runs in IO -- not in Eff.
We have one special function:
runM :: Monad m => Eff '[m] a -> m a
run :: Eff '[] a -> a
run and runM provide base cases.
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
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
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 ()
> :t (runM . runLogger . runBank) Eff '[Bank, Logger, IO] a -> IO a
> :t (runM . runLogger . runBank $ withdraw 50) IO (Maybe Int)
{-# 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' ()
> :t (run . ignoreLogger . testBank) Eff '[Bank, Logger] a -> a
> :t (run . ignoreLogger . testBank $ withdraw 50) Maybe Int
data Logger a where Log :: String -> Logger ()
data Writer w a where Tell :: w -> Writer w ()
Note: there is no Monoid constraint here!
data Bank a where GetCurrentBalance :: Bank Int PutCurrentBalance :: Int -> Bank ()
data State s a where Get :: State s s Put :: s -> State s ()
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
More general types are more likely to already have the interpretations that you want.
Yes! But more than just that!
In MTL:
runReaderT :: ReaderT x m a -> x -> m a
In Eff:
runReader :: Eff (Reader x ': r) a -> x -> Eff r a
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
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 ()
Space | Forward |
---|---|
Right, Down, Page Down | Next slide |
Left, Up, Page Up | Previous slide |
P | Open presenter console |
H | Toggle this help |