测试是否在错误的环境中调用了读取器 monad



我有一个MonadReader,可以为我正在处理的应用程序生成数据。 这里的主 monad 根据一些环境变量生成数据。 monad 通过根据环境选择要运行的其他几个 monad 之一来生成数据。我的代码看起来有点像以下内容,mainMonad是主要的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
...

这里值得注意的事情是:

  • 我们有一个主单子(mainMonad),它既是MonadReader Environment又是MonadRandom
  • 主单子向同
  • 类型的monadAmonadB的顺从单子发出呼唤。
  • 我们有第四个monad,作为monadAmonadB的助手。
  • monadBmonadA发出呼叫(但使用local来更改环境)

最重要的是:

  • 每当monadAhelperA被称为EnvironmentData时,就会被EnvironmentA,每当monadB被称为EnvironmentData时,就会EnvironmentB

我的代码库几乎是这个的放大版本。 有更多的顺从的Monads(目前有12个,但将来可能会增加),有更多的助手,我的EnvironmentData类型有点复杂(我的Environment几乎相同)。

最后一个要点很重要,因为EnvironmentData用于帮助程序,使用错误的Environment将导致帮助程序的结果发生细微变化。

现在我的问题是,很容易错过代码中的local,而只是在错误的环境中直接调用monad。我也害怕在不使用local的情况下调用monad,因为我认为它期待一个它不是的环境。这些很小而且容易出错(我已经做过几次了),这样做的结果通常相当微妙且多种多样。 这最终使问题的症状很难通过单元测试来捕捉。 所以我想直接针对这个问题。 我的第一直觉是在我的单元测试中添加一个子句,内容如下:

打电话mainMonad检查一下,在评估过程中,我们从来没有一个在错误的环境中调用monad。

这样我就可以抓住这些错误,而不必非常仔细地梳理代码。 现在,在考虑了一段时间之后,我还没有想出一个非常巧妙的方法。 我想到了几种可行的方法,但我不太满意:

1.在错误的环境中调用时发生硬崩溃

我可以通过在 monad 的前面添加一个条件来解决此问题,如果它检测到它在错误的环境中被调用,则会硬崩溃。 例如:

monadA ::
( MonadReader m
)
=> m Type
monadA = do
env <- view _1  ask
case env of
EnvironmentA -> return ()
_ -> undefined
...

崩溃将在单元测试期间捕获,我会发现问题。但是,这并不理想,因为我真的希望客户遇到因在错误环境中调用事物而导致的轻微问题,而不是在测试处理程序未捕获问题的情况下发生硬崩溃。这似乎有点像核选择。 这并不可怕,但以我的标准和三者中最糟糕的标准来看并不令人满意。

2. 使用类型安全

我还尝试更改monadAmonadB的类型,以便无法直接从monadB调用monadA,反之亦然。 这非常好,因为它在编译时捕获了问题。这有一个维护起来有点痛苦的问题,而且它非常复杂。 由于monadAmonadB可能各自共享许多共同的单子(MonadReader m) => m Type因此它们中的每一个也必须被解除。 实际上,它几乎可以保证现在每条线路都有电梯。 我不反对基于类型的解决方案,但我不想花费大量时间维护单元测试。

3. 将当地人移到声明内部

每个对EnvironmentData有限制的 monad 都可以从类似于以下内容的样板开始:

monadA ::
( MonadReader Environment m
, MonadRandom m
)
=> m Type
monadA = do
env <- view _1 <$> ask
case env of
EnvironmentA ->
...
_ ->
local (set _1 EnvironmentA) monadA

这很好,因为它确保始终在正确的环境中调用所有内容。 然而,问题是它以单元测试或类型证明所没有的方式静默地"修复"错误。 它真的只能防止我忘记local.

3.5. 删除EnvironmentData

这个基本上等同于最后一个,也许更干净一些。 如果我将monadAmonadB的类型更改为

( MonadReader Integer m
, MonadRandom m
)
=> m Type

然后使用runReaderTwithReaderT(如下面的丹尼尔·瓦格纳建议的那样)添加一个包装器,以来自和传给我MonadReader Environment的呼叫。我不能用错误EnvironmentData调用它们,因为没有环境数据。 这几乎与最后一个问题完全相同。


那么有没有办法确保我的monads总是从正确的环境中调用呢?

虽然看起来有点奇怪,但我想一种方法是引入一个多余的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)
-- ...

您的示例有点过于简化,无法判断其适用程度,但您也可以通过使环境类型参数化来获得。也许是 GADT,类似于:

data Environment t where
EnvironmentA :: Environment A
EnvironmentB :: Environment B
data A
data B

然后,关心它运行的特定环境的代码可以具有MonadReader (Environment A) mMonadReader (Environment B) m约束,而同时使用两者的代码可以使用MonadReader (Environment t) m约束。

这种方法的唯一缺点是标准的 GADT 缺点,有时需要小心分支以确保编译器手头有适当的类型相等证明。它通常是可以做到的,但它需要更多的照顾。

这是我会采取的方法。 根据@Carl的回答,我将通过使用由类型"标签"参数化的 GADT 在类型级别区分"A"和"B"环境。 为标签使用一对空类型(data Adata B,就像@Carl所做的那样)是有效的,尽管我更喜欢使用DataKinds,因为它使意图更清晰。

以下是初步结果:

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

在这里,不同的环境恰好具有相同的内部结构(即,它们每个都包含一个Integer),但没有要求它们这样做。

我将做出一个简化的假设,即您的 monad 始终将环境ReaderT作为最外层,但我们将在基本 monad 中保持多态性(因此您可以使用IOGen来提供随机性)。 你可以使用MonadReader约束来完成所有这些工作,但由于一些晦涩的技术原因,事情变得更加复杂(如果你真的需要这个,请添加评论,我会尝试发布一个补充答案)。 也就是说,对于任意基 monadb,我们将在 monad 中工作:

type E e b = ReaderT (Environment e) b

现在,我们可以按如下方式定义mainMonad操作。 请注意,没有MonadReader约束,因为这由E e b Type签名处理。 基 monad 上的MonadRandom b约束可确保E e b具有MonadRandom实例。 因为签名E e b Typee :: EnvType是多态的,mainMonad可以与任何类型的环境一起工作。 通过对环境 GADT 进行案例匹配,它可以将约束e ~ 'A等纳入范围,允许它调度到monadA等。

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

monadAmonadB的类型签名相似,尽管它们修复了EnvType

monadA ::
( MonadRandom b )
=> E 'A b Type
monadB ::
( MonadRandom b )
=> E 'B b Type

monadA操作可以调用 A 特定的helperA以及常见的helper

monadA = do
result1 <- helperA
result2 <- helper
return $ Type [result1,  result2]

帮助程序可以使用MonadRandom设施,并使用与环境进行大小写匹配的getData等功能检查环境。

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

也可以直接在环境中进行大小写匹配。 在公共帮助程序中,需要处理所有环境类型,但在特定于EnvType的帮助程序中,只需要处理该EnvType(即,模式匹配将是详尽的,因此即使使用-Wall,也不会生成有关不匹配情况的警告):

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

monadB操作可以调用常用帮助程序,也可以通过适当的withReaderT呼叫调度到monadA

monadB = do
Type start <- withReaderT envBtoA monadA
result <- helper
return $ Type $ start ++ [result]
envBtoA :: Environment 'B -> Environment 'A
envBtoA (EnvironmentB x) = EnvironmentA x

当然,最重要的是,您不会意外地从 B 类型的操作调用 A 类型的操作:

badMonadB ::
( MonadRandom b )
=> E 'B b Type
badMonadB = do
monadA  -- error: couldn't match A with B

您也不会意外地从通用帮助程序调用 A 类型操作:

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

尽管您可以使用大小写匹配来检查适当的环境,然后调度:

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"

我觉得我应该指出这对@DanielWagner解决方案的相对优点和缺点(我认为你误解了)。

他的解决方案:

  • 确实提供类型安全。 如果您尝试在monadA的定义中粘贴res <- monadB,则不会键入检查。
  • 正如所写的那样,没有提供一种机制来定义访问环境的公共帮助程序函数(只需要MonadRandom的常见帮助程序就可以正常工作),但这可以通过引入一个 typeclass 来完成,该 typeclass 具有用于EnvironmentAEnvironmentB的实例,这些实例提供了执行允许常见帮助程序对环境执行的任何操作的方法
  • 需要在mainMonad中对环境进行特殊处理(尽管首先需要mainMonad存在一些问题)
  • 避免了高级类型级别的欺骗,因此可能更容易使用
  • 我相信每次环境转换都会添加一个额外的ReaderT层,因此如果存在深度递归 A 到 B 到 A 到 B 嵌套,可能会导致运行时损失。

要并排查看它们,以下是我的完整解决方案:

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

这是他的一个版本:

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

相关内容

  • 没有找到相关文章

最新更新