组合Monad变换器堆栈

3
假设我有两个Haskell库,每个库都有一个用于计算的类型,LibALibB。它们都是在IO单子之上堆叠的单子,使用ReaderT单子变换器实现:
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)

使用这些库表达的程序通过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

现在假设我想编写一个同时使用这两个库的应用程序。

当它们独立使用时,它们可以正常工作:

runA "conf" (return (2 :: Int)) :: IO Int

runB "conf" (return "foo") :: IO String

然而,将一个放在另一个内部是不行的:

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

这是因为runB在IO monad中返回。
所以我可以用liftIO将其提升到LibA中。
runA "conf" $ do
  liftIO (runB "conf" (return "foo"))

这是它的组成部分:

enter image description here

我有两个问题:(1) 组合性,和 (2) 资源效率。
组合性
假设一个应用程序需要以交错的方式使用monads LibALibB 的计算。代码变得笨重:
runA "conf" $ do
  liftIO $
    runB "conf" $ do
      liftIO $
        runA "conf" $ do
          liftIO $
            runB "conf" $ do
              return "foo"

资源效率

对于真实世界的库,runArunB 可能会在运行应用程序代码之前初始化资源。例如建立和释放到 Web 服务器、文件输入输出等的连接。深层嵌套的 runArunB 调用将创建大量不必要的资源处理。

例如,假设 runA 创建一个 HTTP 连接管理器 Manager 来维护与 Web 服务器的一个连接:

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

x中的操作可以使用ask从ReaderT上下文中获取管理器,以重用manager

newManager文档说明:

创建新的Manager是一个相对昂贵的操作,建议您在请求之间共享单个Manager。

因此,在交错执行runArunB时,我不希望重复调用newManager,而是希望为所有LibA计算仅创建一次管理器,即使这些计算是从嵌套的runB调用中提升而来的。

解决方案?

我想要的是一种可组合的方式,可以在不嵌套liftIO调用的情况下交错执行LibALibB计算,并且不会多次创建/释放IO资源。它们都直接堆叠在IO单子之上,因此这并不超出想象的范围。我没有修改库实现的方法。
例如,像这样的东西:
runAB :: (String, LibA a) -> (String, LibB b) -> IO (a, b)

这将为LibALibB的操作创建相关的IO资源,仅执行一次。

或者,如果无法实现资源效率,则为了可读性,可以允许更直接地编写代码,就好像它们都堆叠在IO单子栈上方:

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

有哪些选项?


1
我不确定,但难道这不是单子变换器的用途吗?因此,LibA和LibB必须定义其单子的变换器版本,然后您可以创建具有LibA和LibB属性的单子。 - user253751
如果你知道它们都是ReaderT,那么你可以自己创建堆栈并编写一些丑陋的代码来获取和放置状态;如果你不知道单子是什么,那么你就无法组合它们,因为通常情况下单子是不能被组合的。 - user253751
我认为你正在尝试重新发现ReaderT模式 - lsmor
1个回答

1

通常情况下,您可以在monad堆栈中使用多态代码。而不是直接使用transformers,您可以使用mtl风格。这取决于您如何构建代码(下面我将使用RIO模式),但总体思路是:


-- 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

但是,正如您所看到的,当您将LibB倒入x或将LibA倒入y时,它非常笨拙。因此,在使用环境和单子时最好是多态的。不要直接使用LibALibB编写任何函数。这与面向对象设计模式非常相似:“依赖于抽象而不是具体实现”。

继续进行的方法(再次强调,这取决于您如何构建应用程序)是将所有依赖于LibX的函数更改为依赖于其抽象效果。让我展示一下

想象一下您有两个函数。一个是用于LibA,另一个是用于LibB,您想将它们链接在一起。类似下面这样

-- 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

正如您所知,您无法将它们链接在一起。关键在于,如果您根据“它们能做什么”定义computeAcomputeB,那么它们就可以组合在一起。

--           |- 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

最后,在main函数中初始化所有内容。

-- 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)


你会注意到需要一些样板代码。例如,类HasManagerHasFileHandler。这是因为通常你希望在环境类型上具有多态性。
以下是一个在playground中完全可用的示例:https://play.haskell.org/saved/gCFK2jFj

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接