Wheat Wizard
Wheat Wizard

Reputation: 4219

Testing if a reader monad is called in the wrong environment

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:

Most importantly:

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:

1. Hard crash when called with the wrong environment

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.

2. Use type safety

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.

3. Move the locals inside of the of the declaration

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.

3.5. Remove the 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 Environments. 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

Answers (3)

K. A. Buhr
K. A. Buhr

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:

  • does provide type safety. If you try sticking res <- monadB in the definition of monadA, it will not type check.
  • doesn't, as written, provide a mechanism to define common helper functions that access the environment (common helpers that only need 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 environments
  • requires special handling of the environment in mainMonad (though there's some question about why you need mainMonad in the first place)
  • avoids advanced type-level trickery, so may be easier to work with
  • I believe adds an extra 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

Carl
Carl

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

Daniel Wagner
Daniel Wagner

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

Related Questions