QuickCheck: как использовать проверку целостности для предотвращения забытых конструкторов типа суммы
У меня есть тип данных Haskell, например
data Mytype
= C1
| C2 Char
| C3 Int String
Если я case
на a Mytype
и забудьте обработать один из случаев, GHC дает мне предупреждение (проверка полноты).
Теперь я хочу написать экземпляр QuickCheck Arbitrary
для генерации MyTypes
, например:
instance Arbitrary Mytype where
arbitrary = do
n <- choose (1, 3 :: Int)
case n of
1 -> C1
2 -> C2 <$> arbitrary
3 -> C3 <$> arbitrary <*> someCustomGen
Проблема заключается в том, что я могу добавить новую альтернативу Mytype
и забыть обновить экземпляр Arbitrary, поэтому мои тесты не тестируют эту альтернативу.
Я хотел бы найти способ использования проверки GHC, чтобы напомнить мне о забытых случаях в моем произвольном экземпляре.
Лучшее, что я придумал, это
arbitrary = do
x <- elements [C1, C2 undefined, C3 undefined undefined]
case x of
C1 -> C1
C2 _ -> C2 <$> arbitrary
C3 _ _ -> C3 <$> arbitrary <*> someCustomGen
Но это не очень элегантно.
Я интуитивно чувствую, что на это нет 100% чистого решения, но будет признателен за все, что уменьшит вероятность забыть такие случаи - особенно в большом проекте, где разделены код и тесты.
Ответы
Ответ 1
Здесь я использую неиспользуемую переменную _x
. Однако это не очень элегантно, чем ваше решение.
instance Arbitrary Mytype where
arbitrary = do
let _x = case _x of C1 -> _x ; C2 _ -> _x ; C3 _ _ -> _x
n <- choose (1, 3 :: Int)
case n of
1 -> C1
2 -> C2 <$> arbitrary
3 -> C3 <$> arbitrary <*> someCustomGen
Конечно, нужно сохранить последний case
когерентный с фиктивным определением _x
, поэтому он не является полностью DRY.
В качестве альтернативы, можно использовать Template Haskell для создания команды утверждения времени компиляции, чтобы конструкторы в Data.Data.dataTypeOf
были ожидаемыми. Это утверждение должно быть согласовано с экземпляром Arbitrary
, поэтому это также не является полностью сухим.
Если вам не нужны специальные генераторы, я считаю, что Data.Data
может быть использован для генерации экземпляров Arbitrary
через Template Haskell (я думаю, что видел какой-то код, выполняющий именно это, но я не могу вспомнить, где). Таким образом, нет возможности, чтобы экземпляр мог пропустить конструктор.
Ответ 2
Я реализовал решение с TemplateHaskell, вы можете найти прототип в https://gist.github.com/nh2/d982e2ca4280a03364a8. С этим вы можете написать:
instance Arbitrary Mytype where
arbitrary = oneof $(exhaustivenessCheck ''Mytype [|
[ pure C1
, C2 <$> arbitrary
, C3 <$> arbitrary <*> arbitrary
]
|])
Он работает следующим образом: вы даете ему имя типа (например, ''Mytype
) и выражение (в моем случае список arbitrary
style Gen
s). Он получает список всех конструкторов для этого имени типа и проверяет, содержит ли выражение все эти конструкторы хотя бы один раз. Если вы просто добавили конструктор, но забыли добавить его в произвольный экземпляр, эта функция предупредит вас во время компиляции.
Вот как это реализовано с TH:
exhaustivenessCheck :: Name -> Q Exp -> Q Exp
exhaustivenessCheck tyName qList = do
tyInfo <- reify tyName
let conNames = case tyInfo of
TyConI (DataD _cxt _name _tyVarBndrs cons _derives) -> map conNameOf cons
_ -> fail "exhaustivenessCheck: Can only handle simple data declarations"
list <- qList
case list of
[email protected](ListE l) -> do
-- We could be more specific by searching for `ConE`s in `l`
let cons = toListOf tinplate l :: [Name]
case filter (`notElem` cons) conNames of
[] -> return input
missings -> fail $ "exhaustivenessCheck: missing case: " ++ show missings
_ -> fail "exhaustivenessCheck: argument must be a list"
Я использую GHC.Generics
, чтобы легко пересечь дерево синтаксиса Exp
: С toListOf tinplate exp :: [Name]
(от lens
) я могу легко найти все Name
в целом Exp
.
Я был удивлен, что типы из Language.Haskell.TH
не имеют экземпляров Generic
, и ни один из них (с текущим GHC 7.8) не выполняет Integer
или Word8
- Generic
экземпляры для них, потому что они появляются в Exp
. Поэтому я добавил их как сиротские экземпляры (для большинства вещей StandaloneDeriving
делает это, но для примитивных типов, таких как Integer
мне приходилось копировать экземпляры в качестве Int
).
Решение не является совершенным, потому что оно не использует проверку целостности, как case
, но, как мы согласны, это невозможно при пребывании DRY, и это TH-решение DRY.
Одним из возможных улучшений/альтернатив было бы написать функцию TH, которая проверяет все произвольные экземпляры всего модуля одновременно, вместо вызова exhaustivenessCheck
внутри каждого произвольного экземпляра.
Ответ 3
Вы хотите, чтобы ваш код действовал определенным образом; самый простой способ проверить поведение кода - проверить его.
В этом случае желаемое поведение заключается в том, что каждый конструктор получает разумное покрытие в тестах. Мы можем проверить это с помощью простого теста:
allCons xs = length xs > 100 ==> length constructors == 3
where constructors = nubBy eqCons xs
eqCons C1 C1 = True
eqCons C1 _ = False
eqCons (C2 _) (C2 _) = True
eqCons (C2 _) _ = False
eqCons (C3 _ _) (C3 _ _) = True
eqCons (C3 _ _) _ = False
Это довольно наивно, но это хороший первый выстрел. Его преимущества:
-
eqCons
вызовет предупреждение об исчерпании, если новые конструкторы будут добавлены, что вы хотите
- Он проверяет, что ваш экземпляр обрабатывает все конструкторы, что вы хотите
- Он также проверяет, что все конструкторы фактически сгенерированы с некоторой полезной вероятностью (в данном случае не менее 1%)
- Он также проверяет, что ваш экземпляр можно использовать, например. не висеть
Его недостатки:
- Требуется большое количество тестовых данных, чтобы отфильтровать их с длиной > 100
-
eqCons
является довольно многословным, так как catch-all eqCons _ _ = False
обходит проверку исчерпания
- Использует магические числа 100 и 3
- Не очень общий
Есть способы улучшить это, например. мы можем вычислить конструкторы с помощью модуля Data.Data:
allCons xs = sufficient ==> length constructors == consCount
where sufficient = length xs > 100 * consCount
constructors = length . nub . map toConstr $ xs
consCount = dataTypeConstrs (head xs)
Это теряет время исчерпывающей проверки времени компиляции, но оно избыточно, пока мы регулярно проверяем и наш код становится более общим.
Если мы действительно хотим проверить исчерпываемость, есть несколько мест, где мы могли бы обучить его снова:
allCons xs = sufficient ==> length constructors == consCount
where sufficient = length xs > 100 * consCount
constructors = length . nub . map toConstr $ xs
consCount = length . dataTypeConstrs $ case head xs of
[email protected](C1) -> x
[email protected](C2 _) -> x
[email protected](C3 _ _) -> x
Обратите внимание, что мы используем consCount для полного устранения магии 3
. Магия 100
(определяющая минимальную требуемую частоту конструктора) теперь масштабируется с помощью consCount, но для этого требуется еще больше тестовых данных!
Мы можем решить это довольно легко, используя newtype:
consCount = length (dataTypeConstrs C1)
newtype MyTypeList = MTL [MyType] deriving (Eq,Show)
instance Arbitrary MyTypeList where
arbitrary = MTL <$> vectorOf (100 * consCount) arbitrary
shrink (MTL xs) = MTL (shrink <$> xs)
allCons (MTL xs) = length constructors == consCount
where constructors = length . nub . map toConstr $ xs
Мы можем поставить простую проверку полноты где-нибудь, если хотите, например.
instance Arbitrary MyTypeList where
arbitrary = do x <- arbitrary
MTL <$> vectorOf (100 * consCount) getT
where getT = do x <- arbitrary
return $ case x of
C1 -> x
C2 _ -> x
C3 _ _ -> x
shrink (MTL xs) = MTL (shrink <$> xs)