Reputation: 4219
I have a MonadReader
that generates data for an application I am working on. The main monad here generates the data based on some environment variables. The monad generates the data by selecting one of several other monads to run based on the environment. My code looks somewhat like the following with mainMonad
being the main monad:
data EnvironmentData = EnvironmentA | EnvironmentB
type Environment = (EnvironmentData, Integer)
mainMonad ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
mainMonad = do
env <- ask
case env of
EnvironmentA -> monadA
EnvironmentB -> monadB
monadA ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadA = do
...
result <- helperA
result <- helper
...
monadB ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadB = do
start <- local (set _1 EnvironmentA) monadA
...
result <- helper
...
helperA ::
( MonadReader Environment m
, MonadRandom m
)
=> m String
helperA = do
...
helper ::
( MonadReader Environment m
, MonadRandom m
)
=> m String
helper = do
...
The notable things here are:
mainMonad
) that is both a MonadReader Environment
and a MonadRandom
.monadA
and monadB
of the same type.monadA
and monadB
.monadB
makes a call out to monadA
(but uses local
to change the environment)Most importantly:
monadA
or helperA
is called the EnvironmentData
is EnvironmentA
and whenever monadB
is called the EnvironmentData
is EnvironmentB
.My code base is pretty much a scaled up version of this. There are more subservient Monads (12 at the moment but this will likely increase in the future), there are more helpers, and my EnvironmentData
type is a little more complex (my Environment
though is nearly identical).
The last bullet point is important because the EnvironmentData
is used in the helpers and having the wrong Environment
will lead to subtle changes in the results of the helpers.
Now my issue is that it can be pretty easy to miss a local
in my code and just call a monad directly with the wrong environment. I also fear calling a monad without using local
because I think that it is expecting an environment it is not. These are tiny and easy to make errors (I've done it several times already) and yhe results of doing this are often rather subtle and rather varied. This ends up making the symptoms of the problem rather hard to catch with unit testing. So I would like to target the problem directly. My first instinct was to add a clause to my unit test that says something along the lines of:
Call
mainMonad
check that over the course of evaluating it we never have a monad called with the wrong environment.
That way I can catch these mistakes without having to comb through the code very carefully. Now after thinking about this for a little while I have not come up with a very neat way to do this. I've thought of a couple of ways that do work but I am not quite happy with:
I could fix this by adding a condition to the front of each monad that hard crashes if it detects it being called with the wrong environment. For example:
monadA ::
( MonadReader m
)
=> m Type
monadA = do
env <- view _1 ask
case env of
EnvironmentA -> return ()
_ -> undefined
...
The crash will be caught during unit testing and I will discover the issue. However this is not ideal since I would really prefer the customer to experience the slight issues caused by calling things with the wrong environment rather than a hard crash in the event that the test handler does not catch the issue. It sort of seems like the nuclear option. It isn't awful but is not satisfactory by my standards and the worst of the three.
I also tried changing the types of monadA
and monadB
so that monadA
could not be called directly from monadB
or vice versa. This is very nice in that it catches the problems at compile time. This has the issue of being a bit of a pain to maintain, and it is quite complex. Since monadA
and monadB
may each share a number of common monads of the type (MonadReader m) => m Type
each and every one of those has to be lifted as well. Really it pretty much guarantees that every line now has a lift. I'm not opposed to type based solutions but I don't want to have to spend a huge deal of time just maintaining a unit test.
Each monad with a restriction on the EnvironmentData
could start with a boilerplate akin to:
monadA ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadA = do
env <- view _1 <$> ask
case env of
EnvironmentA ->
...
_ ->
local (set _1 EnvironmentA) monadA
This is nice in that it makes sure everything is always called with the right environment. However the issue is that it silently "fixes" errors in a way that unit-tests or type proofs don't. It really only prevents me from forgetting local
.
EnvironmentData
This one is basically equivalent to the last, perhaps a bit cleaner though. If I change the type of monadA
and monadB
to
( MonadReader Integer m
, MonadRandom m
)
=> m Type
then add a wrapper using runReaderT
withReaderT
(as suggested by Daniel Wagner below) to calls coming from and to my MonadReader Environment
s. I cannot call them with the wrong EnvironmentData
since there is no environment data. This has pretty much the exact issues of the last ones.
So is there a way I can ensure that my monads are always called from the correct environment?
Upvotes: 0
Views: 203
Reputation: 51119
Here's the approach that I would take. As per @Carl's answer, I would differentiate the "A" and "B" environments at the type level by using a GADT parametrized by a type "tag". Using a pair of empty types for the tag (data A
and data B
, like @Carl did) works, though I prefer to use DataKinds
because it makes the intention clearer.
Here are the preliminaries:
{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE DataKinds, GADTs, KindSignatures #-}
import Control.Monad.Reader
import Control.Monad.Random
and here's the definition of the environment type:
data EnvType = A | B
data Environment (e :: EnvType) where
EnvironmentA :: Integer -> Environment 'A
EnvironmentB :: Integer -> Environment 'B
Here, the different environments happen to have the same internal structure (i.e., they each contain an Integer
), but there's no requirement that they do so.
I'm going to make the simplifying assumption that your monad always has the environment ReaderT
as the outermost layer, but we'll maintain polymorphism in the base monad (so you can use IO
or Gen
to supply your randomness). You can do all this using MonadReader
constraints instead, but things get more complicated for some obscure technical reasons (if you really need this, add a comment, and I'll try to post a supplemental answer). That is, for an arbitrary base monad b
, we'll work in the monad:
type E e b = ReaderT (Environment e) b
Now, we can define the mainMonad
action as follows. Note the absence of a MonadReader
constraint, as that's taken care of by the E e b Type
signature. The MonadRandom b
constraint on the base monad ensures that E e b
will have a MonadRandom
instance. Because the signature E e b Type
is polymorphic in e :: EnvType
, the mainMonad
can work with any type of environment. By case matching on the environment GADT, it can bring the constraints e ~ 'A
, etc. into scope allowing it to dispatch to monadA
, etc.
data Type = Type [String] -- some return type
mainMonad ::
( MonadRandom b )
=> E e b Type
mainMonad = do
env <- ask
case env of
EnvironmentA _ -> monadA
EnvironmentB _ -> monadB
The type signatures for monadA
and monadB
are similar, though they fix the EnvType
:
monadA ::
( MonadRandom b )
=> E 'A b Type
monadB ::
( MonadRandom b )
=> E 'B b Type
The monadA
action can call the A-specific helperA
as well as the common helper
:
monadA = do
result1 <- helperA
result2 <- helper
return $ Type [result1, result2]
The helpers can use the MonadRandom
facilities and inspect the environment using functions like getData
that case-match on the environment.
helperA :: (Monad b) => E 'A b String -- we don't need MonadRandom on this one
helperA = do
n <- asks getData
return $ show n
helper :: (MonadRandom b) => E e b String
helper = do
n <- asks getData
x <- getRandomR (0,n)
return $ show x
getData :: Environment e -> Integer
getData (EnvironmentA x) = x
getData (EnvironmentB x) = x
It's also possible to case-match on the environment directly. In a common helper, all environment types need to be handled, but in an EnvType
-specific helper, only that EnvType
needs to be handled (i.e., the pattern match will be exhaustive so even with -Wall
, no warning about unmatched cases will be generated):
helper2 :: (Monad b) => E e b String
helper2 = do
env <- ask
case env of
-- all cases must be handled or you get "non-exhaustive" warnings
EnvironmentA n -> return $ show n ++ " with 'A'-appropriate processing"
EnvironmentB n -> return $ show n ++ " with 'B'-appropriate processing"
helperA2 :: (Monad b) => E 'A b String
helperA2 = do
env <- ask
case env of
-- only A-case need be handled, and trying to match B-case generates warning
EnvironmentA n -> return $ show n
The monadB
action can call common helpers and can dispatch to monadA
with an appropriate withReaderT
call.
monadB = do
Type start <- withReaderT envBtoA monadA
result <- helper
return $ Type $ start ++ [result]
envBtoA :: Environment 'B -> Environment 'A
envBtoA (EnvironmentB x) = EnvironmentA x
Most importantly, of course, you can't accidentally call an A-type action from a B-type action:
badMonadB ::
( MonadRandom b )
=> E 'B b Type
badMonadB = do
monadA -- error: couldn't match A with B
nor can you accidentally call an A-type action from a generic helper:
-- this is a common helper
badHelper :: (Monad b) => E e b String
badHelper = do
-- so it can't assume EnvironmentA is available
helperA -- error: couldn't match "e" with B
though you can use case matching to check for an appropriate environment and then dispatch:
goodHelper :: (Monad b) => E e b String
goodHelper = do
env <- ask
case env of
EnvironmentA _ -> helperA -- if we're "A", it's okay
_ -> return "default"
I feel I should point out the relative advantages and disadvantages of this to @DanielWagner's solution (which I think you've misunderstood).
His solution:
res <- monadB
in the definition of monadA
, it will not type check.MonadRandom
would work fine), but this can be done by introducing a typeclass with instances for EnvironmentA
and EnvironmentB
that provide methods to do whatever it is common helpers are allowed to do with environmentsmainMonad
(though there's some question about why you need mainMonad
in the first place)ReaderT
layer with each environment transition, so could incur a runtime penalty if there is deep recursive A-to-B-to-A-to-B nesting.To see them side-by-side, here's my complete solution:
{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE DataKinds, GADTs, KindSignatures #-}
import Control.Monad.Reader
import Control.Monad.Random
data EnvType = A | B
data Environment (e :: EnvType) where
EnvironmentA :: Integer -> Environment 'A
EnvironmentB :: Integer -> Environment 'B
getData :: Environment e -> Integer
getData (EnvironmentA x) = x
getData (EnvironmentB x) = x
type E e b = ReaderT (Environment e) b
data Type = Type [String] -- some return type
mainMonad :: (MonadRandom b) => E e b Type
mainMonad = do
env <- ask
case env of
EnvironmentA _ -> monadA
EnvironmentB _ -> monadB
monadA :: (MonadRandom b) => E 'A b Type
monadA = do
result1 <- helperA
result2 <- helper
return $ Type [result1, result2]
monadB :: (MonadRandom b) => E 'B b Type
monadB = do
Type start <- withReaderT envBtoA monadA
result <- helper
return $ Type $ start ++ [result]
envBtoA :: Environment 'B -> Environment 'A
envBtoA (EnvironmentB x) = EnvironmentA x
helperA :: (Monad b) => E 'A b String -- we don't need MonadRandom on this one
helperA = do
n <- asks getData
return $ show n
helper :: (MonadRandom b) => E e b String
helper = do
n <- asks getData
x <- getRandomR (0,n)
return $ show x
and here's a version of his:
{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Reader
import Control.Monad.Random
data EnvType = A | B
data EnvironmentMain = EnvironmentMain EnvType Integer
data EnvironmentA = EnvironmentA Integer
data EnvironmentB = EnvironmentB Integer
class Environment e where getData :: e -> Integer
instance Environment EnvironmentA where getData (EnvironmentA n) = n
instance Environment EnvironmentB where getData (EnvironmentB n) = n
convertAToB :: EnvironmentA -> EnvironmentB
convertAToB (EnvironmentA x) = EnvironmentB x
convertBToA :: EnvironmentB -> EnvironmentA
convertBToA (EnvironmentB x) = EnvironmentA x
data Type = Type [String] -- some return type
mainMonad :: (MonadReader EnvironmentMain m, MonadRandom m) => m Type
mainMonad = do
env <- ask
case env of
EnvironmentMain A n -> runReaderT monadA (EnvironmentA n)
EnvironmentMain B n -> runReaderT monadB (EnvironmentB n)
monadA :: (MonadReader EnvironmentA m, MonadRandom m) => m Type
monadA = do
result1 <- helperA
result2 <- helper
return $ Type $ [result1] ++ [result2]
monadB :: (MonadReader EnvironmentB m, MonadRandom m) => m Type
monadB = do
env <- ask
Type start <- runReaderT monadA (convertBToA env)
result <- helper
return $ Type $ start ++ [result]
helperA :: (MonadReader EnvironmentA m) => m String
helperA = do
EnvironmentA n <- ask
return $ show n
helper :: (Environment e, MonadReader e m, MonadRandom m) => m String
helper = do
n <- asks getData
x <- getRandomR (0,n)
return $ show x
Upvotes: 0
Reputation: 27023
Your example is a little too simplified for me to judge how well this applies, but you might also be able to get by making the Environment type parameterized. Maybe a GADT, something like:
data Environment t where
EnvironmentA :: Environment A
EnvironmentB :: Environment B
data A
data B
Then code that cares what specific environment it's running in can have a MonadReader (Environment A) m
or MonadReader (Environment B) m
constraint, while code that works with both can use a MonadReader (Environment t) m
constraint.
The only downside to this approach is the standard GADT downside of sometimes needing to be careful with branches to make sure the compiler has appropriate proofs of type equality in hand. It usually can be done, but it needs a bit more care.
Upvotes: 0
Reputation: 153172
Although it seems a bit strange, I suppose one way would be to introduce a redundant ReaderT
:
data EnvironmentA -- = ...
data EnvironmentB -- = ...
convertAToB :: EnvironmentA -> EnvironmentB
convertBToA :: EnvironmentB -> EnvironmentA
-- convertAToB = ...
-- convertBToA = ...
monadA :: MonadReader EnvironmentA m => m Type
monadA = do
env <- ask
-- ...
res <- runReaderT monadB (convertAToB env)
-- ...
monadB :: MonadReader EnvironmentB m => m Type
monadB = do
env <- ask
-- ...
res <- runReaderT monadA (convertBToA env)
-- ...
Upvotes: 1