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:
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)]
IO
to pick a Question
from the [(Question, Rational)]
distribution produced by distribQ
:
pickQ :: MonadIO m => [(Question, Rational)] -> m Question
IO
¹
shuffleQ :: MonadIO m => Question -> m Question
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
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:
askQ
?(¹) The Question
data type also encodes which answers are correct, and which ones are checked, so
type Answer = Question
simple.
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
.