Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

I will preface this by saying that I am a novice Haskell programmer (tinkered with it sporadically over the years) but I have a fair few years on the counter when it comes to OOO and imperative programming. I am currently learning how to use monads & combining them via the use of monad transformers (assuming I've got the correct term).


While I am able to assemble/chain things together, I am finding it hard to build an intuition for what the best way and style is & how best to assemble/write those interactions.

Specifically, I am curious to know what the best practice (or at least your practice) is for using lift/liftIO and any flavour in between & if there is way (and benefit) to hide them as I am finding them rather 'noisy'.

Below is an example snippet which I have put together to illustrate what I mean :

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

Some of the functions it calls make use of the state 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

While most don't and just live in IO (meaning they have to be lifted):

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)

My question is : is there any way to hide the liftIO in consumeRenderStageGL' and more importantly would this be a good or a bad idea?

One way I can think of hiding/getting rid of the liftIO is to bring both my consumePrologueGL & consumeEpilogueGL into my state monad but this seems wrong in the sense that those function do not need (and should not) interact with it ; all this just to reduce code noise.

The other option I can think of is to simply create the lifted version of the functions and call them in consumeRenderStageGL' - this would reduce the code noise but be identical in execution/evaluation.

The third option, which is how my logGLErrors works is that I have used a type class which has an instance defined for both IO & my state monads.

I look forward to reading what your opinions, advices and practices are.

Thanks in advance!

like image 594
Althar93 Avatar asked Oct 25 '25 05:10

Althar93


1 Answers

There are a few solutions. A common one is to make your basic actions MonadIO m => m … instead of IO …:

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

Then you can use them in StateT RenderStageContext IO () without wrapping, due to MonadIO m => MonadIO (StateT s m), and of course MonadIO IO where liftIO is the identity function.

You can also abstract over the StateT part using MonadState from mtl, so if you add another transformer above/below it, you won’t have the same issue of lifting from/to StateT.

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

Generally, a concrete stack of transformers types is fine, it just helps to wrap all your basic operations for convenience so that all the lifts are in one place.

mtl helps remove the lift noise from your code altogether, and working in a polymorphic type m means you have to declare which effects a function actually uses, and can substitute different implementations of all the effects (except for MonadIO) for testing. Using monad transformers as an effect system like this is great if you have few types of effects; if you want something finer-grained or more flexible, you’ll start hitting the pain points that make people reach for algebraic effects instead.

It’s also worth assessing whether you need StateT over IO. Typically if you’re in IO, you don’t need the pure state offered by StateT, so instead of StateT MutableState IO you might as well use ReaderT (IORef MutableState) IO.

It’s also possible to make that (or a newtype wrapper for it) an instance of MonadState MutableState, so your code using get/put/modify wouldn’t even need to change:

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

This combo of ReaderT & IO is a pretty common design pattern.

like image 114
Jon Purdy Avatar answered Oct 26 '25 23:10

Jon Purdy



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!