haskellmonadsmonad-transformers

Composing monad transformer stacks


Suppose I have two Haskell libraries, each with one type for computations, LibA and LibB. They are both monads stacked on top of the IO monad, implemented with the ReaderT monad transformer:

import Control.Monad.IO.Class (MonadIO)
import qualified Control.Monad.Reader as MR
import Control.Monad.Trans.Reader (runReaderT)

newtype LibA a = LibA (MR.ReaderT String IO a)
  deriving (Functor, Applicative, Monad, MonadIO)

newtype LibB a = LibB (MR.ReaderT String IO a)
  deriving (Functor, Applicative, Monad, MonadIO)

Programs expressed with these libraries are executed with runReaderT:

runA :: String -> LibA a -> IO a
runA s (LibA x) =
  runReaderT x s

runB :: String -> LibB b -> IO b
runB s (LibB x) =
  runReaderT x s

Now suppose I want to write an application that uses both libraries.

When used independently, these work:

runA "conf" (return (2 :: Int)) :: IO Int
runB "conf" (return "foo") :: IO String

However, using one within the other does not:

runA "conf" $ do
  -- Expected: LibA String, Actual: IO String
  runB "conf" (return "foo")

This is because runB returns in the IO monad.

So instead I can lift this into LibA with liftIO:

runA "conf" $ do
  liftIO (runB "conf" (return "foo"))

Here's how it fits together:

enter image description here

I have two issues with this: (1) compositionality, and (2) resource efficiency.

Compositionality

Suppose an application needs to use computations from monads LibA and LibB in an interleaved fashion. The code becomes unwieldy:

runA "conf" $ do
  liftIO $
    runB "conf" $ do
      liftIO $
        runA "conf" $ do
          liftIO $
            runB "conf" $ do
              return "foo"

Resource efficiency

For real world libraries, runA and runB might initialise resources before running application code. E.g. establishing then releasing connections to web servers, file IO, etc. Deeply nested runA and runB calls will create lots of unnecessary resource handling.

For example, suppose runA creates a HTTP connection Manager to maintain one connection to a web server:

runA :: String -> LibA a -> IO a
runA s (LibA x) =
  let settings = mkManagerSettings (TLSSettingsSimple True False False) Nothing
  manager <- liftIO $ newManager settings
  runReaderT (MySession x manager) s

where actions in x can obtain the manager from the ReaderT context with ask, to reuse the one manager.

The documentation for newManager states:

Creating a new Manager is a relatively expensive operation, you are advised to share a single Manager between requests instead.

So when interleaving runA and runB executions, I don't want repeated calls to newManager, I want a manager to be created only once for all LibA computations, even those lifted from within nested runB calls.

Solutions?

What I'm after is a composable way to interleave LibA and LibB computations without nested liftIO calls, and without creating/releasing IO resources multiple times. They are both stacked directly on top of the IO monad, so this isn't beyond the realms of imagination. I have no way of modifying the library implementations.

E.g. something like:

runAB :: (String, LibA a) -> (String, LibB b) -> IO (a, b)

which would create the relevant IO resources for LibA and LibB actions only once.

Alternatively if that resource efficiency isn't possible, then for readability simply an allowance to write code more directly as if they were both stacked within one monad stack on top of the IO monad:

runA "conf" (runB "conf" (return "foo"))

What are the options?


Solution

  • In general you use polymorphic code over the monad stack. Instead of using transformers directly, you use mtl-style. It depends on how you structure your code (below i'm using the RIO pattern), but the general idea is:

    
    -- Fake types for Environments
    type Manager = String
    type FileHandler = String
    
    -- Fake for the sake of example
    type Settings = String
    type Path = String
    
    -- For the sake of example let say LibA has a Manager and LibB a FileHandler 
    newtype LibA a = LibA (ReaderT Manager IO a)
      deriving (Functor, Applicative, Monad, MonadIO)
    
    newtype LibB a = LibB (ReaderT FileHandler IO a)
      deriving (Functor, Applicative, Monad, MonadIO)
      
    -- You'd like to have these two
    runA :: String -> LibA a -> IO a
    runA s (LibA x) =
      let settings = mkManagerSettings (TLSSettingsSimple True False False) Nothing
      manager <- liftIO $ newManager settings
      runReaderT x manager -- run x using the manager as a global environment
    
    runB :: String -> LibB a -> IO a
    runB s (LibB y) =
      fhandler <- liftIO $ getFileHandler -- imagine this exists.
      runReaderT y fhandler -- run x using the file handler
    
    

    But as you can see, it is very clumsy when you pour LibB into x or LibA into y. So you better be polimorphic in the environment and the monad you use. Do not write any of your functions using directly LibA or LibB This is very similar to OOP design patterns: "rely on abstractions not on concrete implementations"

    The way to proceed (again, it depends on how do you structure your app) is to change all functions relying on LibX to rely on its abstract effects. Let me show

    Imagine you have two functions. one for LibA and one for LibB and you want to chain them together. Somethin like below

    -- Let say you have a function for managing session_ids using LibA
    -- which has the resource
    computeA :: Int -> LibA String -> IO String
    computeA session_id manage_session_id = do
      runA ...
    
    -- And a function for LibB which logs a message
    computeB :: String -> LibB String -> IO ()
    computeB log_message message_logger = do
      runB ...
    
    -- This doesn't work
    computeA 42 >>= computeB
    

    As you already know, you can't chain them. The point is that if you define computeA and computeB in terms of "what they can do" then, they compose.

    --           |- A monad with a global env
    --           |                  |- env has a Manager
    --           |                  |               |- the monad can do IO
    computeA :: (MonadReader env m, HasManager env, MonadIO m) 
             => Int -> m String
    computeA session_id = ...
    
    -- change that function to
    --           |- A monad with a global env
    --           |                  |- env has a FileHandler
    --           |                  |                   |- the monad can do IO
    computeB :: (MonadReader env m, HasFileHandler env, MonadIO m) 
             => String -> m ()
    computeB session_id = ...
    
    -- This does work!!!
    computeA 42 >>= computeB
    

    Finally in the main function you initialize everything

    -- Extra boilerplate not here. Look at the full example below
    data Env = Env {fileHandler :: FileHandler, manager :: Manager}
    newtype Lib a = Lib (ReaderT Env IO a)
      deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env)
    
    run :: Env -> Lib a -> IO a
    run env (Lib x) = runReaderT x env
    
    main :: IO ()
      m <- newManager
      f <- newFileHandler
      let env = Env f m
      run env (computeA 42 >>= compute B)
    
    

    You notice that you need some boilerplate. For example, classes HasManager and HasFileHandler. This is done, because generally you want to be polymorphic on the enviroment type.

    Here you have fully working example in the playground: https://play.haskell.org/saved/gCFK2jFj