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:
I have two issues with this: (1) compositionality, and (2) resource efficiency.
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"
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.
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?
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