Search code examples
haskellfunctional-programmingmonad-transformersstate-monadreader-monad

About the different requirements of different steps in a monad transformer stack pipeline


The laser-focused answer I accepted for a previous question of mine, was both mind-bending and revealing, at the point that I reopened my Real World Haskell (what a book!) and decided to go the extra mile and split my program in the tiniest bit, each running a generic m monad with just enough class contraints for its implementation to compile. Essentially I came up with these function signatures:

  • Given a Report telling the failure rate for each question and a fixed set of [Question]s, the two represent respectively the mutable state (hence the MonadState Report contraint on m) and the immutable state (hence MonadReader [Question]), based on which each Question is assigned a Rational probability to be picked:
    distribQ :: (MonadState Report m, MonadReader [Question] m) => m [(Question, Rational)]
    
  • picking a question is just a matter of IO to pick a Question from the [(Question, Rational)] distribution produced by distribQ:
    pickQ :: MonadIO m => [(Question, Rational)] -> m Question
    
  • shuffling the alternative answers of a question (so one doesn't get used to remember that question 1 has answer B, for instance), is also just a matter of IO¹
    shuffleQ :: MonadIO m => Question -> m Question
    
  • asking a question is what will call for user input (hence MonadIO), at which point the user can keep answering questions, which will result in updating the runtime Report (hence MonadState Report), or decide to quit (hence MaybeT; not sure why there's no MonadMaybe class...):
    askQ :: (MonadIO m, MonadState Report m) => Question -> MaybeT m Answer
    
  • once the user chooses an answer via l, that Answer¹ is checked, the Report updated (hence MonadState Report), and, if the answer is wrong, the correct answer is printed (hence MonadIO):
    evalAns :: (MonadIO m, MonadState Report m) => Answer -> m ()
    

You can see that the above signatures nicely pipe into each other, and indeed the quiz function is implemented like this:

quiz :: [Question] -> Report -> IO Report
quiz qs r = (runMaybeT . forever)
            (distribQ >>= pickQ >>= shuffleQ >>= askQ >>= evalAns)
            `runReaderT` qs
            `execStateT` r

which more or less maps to the English "maybe run forever the loop consisting of assigning a distribution to the questions, picking one, shuffling its alternatives, asking it, and evaluating the answer; do this with given questions as immutable state, and a report of failure rates as mutable state".

The implementation of the functions above is available in my repo.

Now, what I'm faffing about is... the fact that askQ, per sé, doesn't alter the Report, just reads it:

askQ :: (MonadIO m, MonadState Report m) => Question -> MaybeT m Answer
askQ q = do
  r <- get
  (ans, e) <- (runMaybeT . forever)
              (getAns r)
              `execStateT` (q, False)
  if e
    then mzero
    else return ans

In a way, it feels to me that MonadState Report gives askQ more power than it needs, as r <- ask would be enough instead of r <- get, which comes available with put alongside it.

However, it seems I can't really swap MonadState for MonadReader, otherwise the pipeline breaks with

• Couldn't match type ‘[Question]’ with ‘Report’
    arising from a functional dependency between:
      constraint ‘MonadReader
                    Report
                    (Control.Monad.Trans.Reader.ReaderT
                       [Question] (Control.Monad.Trans.State.Strict.StateT Report IO))’
        arising from a use of ‘askQ’
      instance ‘MonadReader r (Control.Monad.Trans.Reader.ReaderT r m)’
        at <no location info>
• In the second argument of ‘(>>=)’, namely ‘askQ’
  In the first argument of ‘(>>=)’, namely
    ‘distribQ >>= pickQ >>= shuffleQ >>= askQ’
  In the first argument of ‘runMaybeT . forever’, namely
    ‘(distribQ >>= pickQ >>= shuffleQ >>= askQ >>= evalAns)’ [-Wdeferred-type-errors]

which reveals that my attempt resulted in a clash between MonadReader [Question] and the wannabe-MonadReader Report.

So the question is double:

  • is there a way to limit the power of askQ?
  • Or is my design just broken?

(¹) The Question data type also encodes which answers are correct, and which ones are checked, so

type Answer = Question

simple.


Solution

  • You could define a little combinator to insert a Reader computation into a State computation:

    readOnly :: MonadState s m => (a -> ReaderT s m b) -> a -> m b
    readOnly f a = runReaderT (f a) =<< get
    

    This isn't exactly perfect, because the function passed to readOnly still knows about the entire m you're using, and so it could demand MonadState from its argument. But it allows you to give that function the more restrictive signature, promising that it won't actually modify the state.

    Here's an example of using it for some silly Int and String operations; I leave to you the exercise of applying the idea to your system.

    showAndInc :: MonadState Int m => String -> m String
    showAndInc old = do
      x <- get
      let x' = x + 1
      put x'
      pure $ old ++ "," ++ show x'
    
    showDoubled :: MonadReader Int m => String -> m String
    showDoubled old = do
      x <- ask
      let x' = x * 2
      pure $ old ++ "," ++ show x'
    
    operate :: Int -> String
    operate x = evalState go x
      where go = pure (show x)
                 >>= showAndInc
                 >>= readOnly showDoubled
                 >>= showAndInc
    

    If you want to exercise more control over what effects can be performed by operations lifted into readOnly, you need to hide the specific m you're using from it. You can accomplish this by making it accept a universally quantified m variable, with only the constraints you want it to know about. For example, here's a version of readOnly that allows the lifted function to perform the specified Reader effects (reading the state of the underlying State monad) as well as Writer effects, but with no access to State:

    readWrite :: (MonadState s m, MonadWriter w m) 
      => (forall n. MonadWriter w n => a -> ReaderT s n b)
      -> a
      -> m b
    readWrite f x = runReaderT (f x) =<< get
    

    You can generalize this by taking a constraint as a type parameter, instead of using specifically WriterT:

    readOnly :: forall c s m a b.
      (MonadState s m, c m) => (forall m'. (Monad m', c m') => a -> ReaderT s m' b) -> a -> m b
    readOnly f a = runReaderT (f a) =<< get
    

    In order for the caller of readOnly to specify what the constraint c should be, you need to use a type application - in the below example, that's readOnly @(MonadWriter [Int]) showAndWriteDoubled.

    showAndWriteDoubled :: (MonadReader Int m, MonadWriter [Int] m) => String -> m String
    showAndWriteDoubled old = do
      x <- ask
      let x' = x * 2
      tell [x']
      pure $ old ++ "," ++ show x'
    
    operate :: Int -> (String, [Int])
    operate x = runWriter $ evalStateT go x
      where go = pure (show x)
              >>= showAndInc
              >>= readOnly @(MonadWriter [Int]) showAndWriteDoubled
              >>= showAndInc
    

    Note the difference: in this version, adding a new constraint to the signature of showAndWriteDoubled (e.g. a MonadState constraint to peek at your state) causes a type error, because then it's not general enough to meet the constraints that operate is putting onto it via readOnly.