【问题标题】:Testing if a reader monad is called in the wrong environment测试是否在错误的环境中调用了 reader monad
【发布时间】:2019-11-30 21:45:13
【问题描述】:

我有一个MonadReader,它为我正在处理的应用程序生成数据。这里的主要 monad 根据一些环境变量生成数据。 monad 通过根据环境选择要运行的其他几个 monad 之一来生成数据。我的代码看起来有点像下面的 mainMonad 是主要的单子:

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

这里值得注意的是:

  • 我们有一个主 monad (mainMonad),它既是 MonadReader Environment,又是 MonadRandom
  • 主 monad 调用同一类型的从属 monad monadAmonadB
  • 我们有第四个 monad 作为 monadAmonadB 的助手。
  • monadB 调用monadA(但使用local 更改环境)

最重要的是:

  • 每当monadAhelperA 被调用时,EnvironmentData 就是EnvironmentA,而每当monadB 被调用时,EnvironmentData 就是EnvironmentB

我的代码库几乎是这个的放大版本。有更多的从属 Monad(目前 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) =&gt; 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

然后使用 runReaderT withReaderT(如下面的Daniel Wagner 建议)为来自我的MonadReader Environments 的呼叫添加一个包装器。我不能用错误的EnvironmentData 给他们打电话,因为没有环境数据。这几乎与上一个问题完全相同。


那么有没有一种方法可以确保我的 monad 总是从正确的环境中调用?

【问题讨论】:

  • 你也可以看看withReaderT,尽管这需要你选择一个特定的monad堆栈(或者至少它的外部位直到ReaderT)而不是使用mtl风格类多态性。
  • 根据您的描述,基于类型的解决方案显然是正确的方法,不应该要求您取消所有操作(例如,@DanielWagner 的回答)。如果您发现他的回答不令人满意,我怀疑我们可以提供更好的东西,但是您的最小示例太抽象且太伪代码(例如,这些类型签名对于MonadReader 的通常定义无效)。您能否发布一个更真实的玩具示例来编译并说明您正在尝试做的事情?
  • @K.A.Buhr 感谢您的反馈。我让事情变得更加具体(我还修复了我的类型签名,这只是我的一个错误)。如果有人愿意,我可以让它更真实,甚至进一步填写特定部分。我只是不确定与这种东西分享多少。
  • 在这个应用程序中使用 monad 约束(例如,MonadReader)而不是具体的 monad 类型对您有多重要?我知道有些人提倡将此作为最佳实践,但您是否真的需要在多个具体 monad 上以多态方式运行此代码?
  • @K.A.Buhr 不幸的是,我使用MonadRandom 而不是特定环境实际上相当重要。这是因为测试处理程序使用Gen 作为实例,而应用程序本身使用IO

标签: haskell testing hspec reader-monad


【解决方案1】:

这是我将采取的方法。根据@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 约束来完成所有这些操作,但由于一些晦涩的技术原因,事情变得更加复杂(如果您真的需要这个,请添加评论,我会尝试发布补充答案)。也就是说,对于任意基础 monad b,我们将在 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 &lt;- monadB,它将不会进行类型检查。
  • 没有提供一种机制来定义访问环境的通用帮助函数(只需要MonadRandom 的通用帮助函数可以正常工作),但这可以通过引入带有@987654370 实例的类型类来完成@ 和 EnvironmentB 提供方法来做任何常见的助手可以对环境做的事情
  • 需要对mainMonad 中的环境进行特殊处理(尽管有一些关于为什么首先需要mainMonad 的问题)
  • 避免了高级类型级别的诡计,因此可能更易于使用
  • 我相信会在每个环境转换中添加一个额外的 ReaderT 层,因此如果存在深度递归 A-to-B-to-A-to-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

【讨论】:

    【解决方案2】:

    您的示例有点过于简单,我无法判断这是否适用,但您也可以通过将 Environment 类型参数化来获得。也许是 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 的缺点,有时需要小心处理分支以确保编译器手头有适当的类型相等证明。它通常可以完成,但需要多加注意。

    【讨论】:

    • 实际上,这与我的回答中的EnvironmentAEnvironmentB 这两种类型有何不同?似乎编写MonadReader (Environment t) m 的能力是您提到的唯一区别,但问题中提出的任何操作都不能被赋予这种类型,那么这种区别真的重要吗?
    • @DanielWagner 正如我所说,很难用如此简单的设置来判断。但是,如果环境中还有 其他 内容,尤其是类型之间共享的一些内容,那将有很大帮助。
    • @SriothilismO'Zaic 我不明白这如何阻止任何事情。当然,类型需要固定在某个地方。这是关于让各种帮助器具有更窄的类型,以便您必须正确组装它们。
    • 我最初误解了这里的建议。一旦我看到丹尼尔瓦格纳的评论,我就更好地理解了。
    【解决方案3】:

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

    【讨论】:

      猜你喜欢
      • 2017-07-04
      • 1970-01-01
      • 2012-06-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多