monad 转换器的最佳实践:隐藏或不隐藏 'liftIO'

Best practices with monad transformers : to hide or not to hide 'liftIO'

我会先说我是一个新手 Haskell 程序员(这些年来偶尔修改它)但是我有相当几年的时间在柜台上OOO 和命令式编程。我目前正在学习如何使用 monad 并通过使用 monad 转换器来组合它们(假设我有正确的术语)。


虽然我能够 assemble/chain 一起处理事情,但我发现很难建立直觉来判断什么是最好的方式和风格以及如何最好地 assemble/write 这些互动。

具体来说,我很想知道使用 lift/liftIO 的最佳实践(或至少是您的实践)是什么以及介于两者之间的任何风格,以及是否有办法(和好处)隐藏它们,因为我我正在寻找它们 'noisy'.

下面是一个示例片段,我把它放在一起来说明我的意思:

consumeRenderStageGL' :: RenderStage -> StateT RenderStageContext IO ()
consumeRenderStageGL' r = do 
    pushDebugGroupGL (name r)
    liftIO $ consumePrologueGL ( prologue r )
    liftIO $ consumeEpilogueGL ( epilogue r )
    consumeStreamGL   ( stream   r )
    liftIO $ popDebugGroupGL

它调用的一些函数使用状态 monad :

pushDebugGroupGL :: String -> StateT RenderStageContext IO ()
pushDebugGroupGL tag = do
    currentDebugMessageID <- gets debugMessageID
    liftIO $ GL.pushDebugGroup GL.DebugSourceApplication (GL.DebugMessageID currentDebugMessageID) tag
    modify (\fc -> fc { debugMessageID = (currentDebugMessageID + 1) })

consumeStreamGL :: Stream -> StateT RenderStageContext IO ()
consumeStreamGL s = do 
    mapM_ consumeTokenGL s
    logGLErrors

虽然大多数人没有,只是生活在 IO 中(意味着他们必须被解除):

consumePrologueGL :: Prologue -> IO ()
consumePrologueGL p = do
    colourClearFlag     <- setupAndReturnClearFlag GL.ColorBuffer   ( clearColour  p ) (\(Colour4 r g b a) -> GL.clearColor $= (GL.Color4 r g b a))
    depthClearFlag      <- setupAndReturnClearFlag GL.DepthBuffer   ( clearDepth   p ) (\d -> GL.clearDepthf $= d)
    stencilClearFlag    <- setupAndReturnClearFlag GL.StencilBuffer ( clearStencil p ) (\s -> GL.clearStencil $= fromIntegral s)
    GL.clear $ catMaybes [colourClearFlag, depthClearFlag, stencilClearFlag]
    logGLErrors
    where
        setupAndReturnClearFlag flag mValue function = case mValue of 
            Nothing     -> return Nothing
            Just value  -> (function value) >> return (Just flag)

我的问题是:有什么方法可以在 consumeRenderStageGL' 中隐藏 liftIO 更重要的是,这会不会是好主意还是坏主意?

我能想到的 hiding/getting 摆脱 liftIO 的一种方法是将我的 consumePrologueGLconsumeEpilogueGL 都带入我的状态 monad 但这似乎是错误的,因为这些功能不需要(也不应该)与它交互;所有这一切只是为了减少代码噪音。

我能想到的另一个选择是简单地创建函数的提升版本并在 consumeRenderStageGL' 中调用它们 - 这会减少代码噪音但在 execution/evaluation.

第三个选项,也就是我的 logGLErrors 的工作方式是我使用了一个类型 class,它有一个为 IO 和我的状态 monad 定义的实例。

我期待阅读您的意见、建议和做法。

提前致谢!

有几个解决方案。一个常见的是让你的基本动作 MonadIO m => m … 而不是 IO …:

consumePrologueGL :: (MonadIO m) => Prologue -> m ()
consumePrologueGL p = liftIO $ do
  …

那么你可以在 StateT RenderStageContext IO () 中使用它们而无需换行,因为 MonadIO m => MonadIO (StateT s m),当然还有 MonadIO IO,其中 liftIO 是恒等函数。

您还可以使用 mtl 中的 MonadStateStateT 部分进行抽象,因此如果您添加另一个转换器 above/below 它,您将不会有相同的解除问题 from/to StateT.

pushDebugGroupGL
  :: (MonadIO m, MonadState RenderStageContext m)
  => String -> m ()

一般来说,transformers 类型的具体堆栈是 很好 ,它只是为了方便起见而有助于包装所有基本操作,以便所有 lift s在一处。

mtl 有助于完全消除代码中的 lift 噪声,并且在多态类型中工作 m 意味着您必须声明函数实际使用的效果,并且可以替换所有效果的不同实现(MonadIO 除外)用于测试。如果您的效果类型很少,那么使用 monad 转换器作为这样的效果系统非常好;如果您想要更细粒度或更灵活的东西,您将开始触及让人们寻求 代数效应 的痛点。

还值得评估您是否需要 StateT 而不是 IO。通常,如果您处于 IO,则不需要 StateT 提供的纯状态,因此您不妨使用 ReaderT (IORef MutableState) IO.[=42 而不是 StateT MutableState IO =]

也可以使它(或它的 newtype 包装器)成为 MonadState MutableState 的实例,因此您的代码使用 get/put/modify 甚至不需要改变:

{-# Language GeneralizedNewtypeDeriving #-}

import Data.Coerce (coerce)

newtype MutT s m a = MutT
  { getMutT :: ReaderT (IORef s) m a }
  deriving
    ( Alternative
    , Applicative
    , Functor
    , Monad
    , MonadIO
    , MonadTrans
    )

evalMutT :: MutT s m a -> IORef s -> m a
evalMutT = coerce

instance (MonadIO m) => MonadState s (MutT s m) where
  state f = MutT $ do
    r <- ask
    liftIO $ do
      -- NB: possibly lazier than you want.
      (a, s) <- f <$> readIORef r
      a <$ writeIORef r s

这种 ReaderTIO 的组合是一种非常常见的设计模式。