Página de Guillaume Hoffmann

View on GitHub

% Teórico Haskell: QuickCheck

Antes que nada:

cabal install QuickCheck

QuickCheck

Una función para comprobar

Supongamos que queremos fusionar dos listas ordenadas.

type MergeFun = Ord a => [a] -> [a] -> [a]
merge :: MergeFun
merge all_xs@(x:xs) all_ys@(y:ys)
  | x < y     = x : merge all_ys xs
  | otherwise = y : merge all_xs ys
merge xs            []            = xs
merge _             _             = []

¿Esta función anda como lo esperamos? Podríamos probarla unas veces en GHCi, pero es un poco insatisfactorio: tenemos que hacer el trabajo de pensar en un test, pero lo usamos solo una vez. En lugar de eso, es mucho mejor escribir el test dentro del archivo, de tal forma que lo puedamos volver a ejecutar cada vez que modificamos la función merge.

QuickCheck provee formas de testear nuestros programas. Se enfoca en tests unitarios, donde solo una pequeña parte de un programa es escrutinada, en búsqueda de problemas.

QuickCheck: tests basados en propiedades

Es aburrido escribir casos de tests. Y es fácil olvidarse de un comportamiento inesperado. Mucho mejor sería definir propiedades que queramos que nuestra función tenga. Luego, podríamos hacer que la computadora genere los casos de tests para nosotros.

QuickCheck es la librería estándar en Haskell para hacer tests basados en propiedades. La idea es que definís alguna propiedad, que luego se chequea usando datos pseudoaleatorios.

Por ejemplo:

prop_numElements_merge :: [Integer] -> [Integer] -> Bool
prop_numElements_merge xs ys
  = length xs + length ys == length (merge3 xs ys)

Esta propiedad dice que la suma de los tamaños de las listas de entrada debería ser la misma que el tamaño de la lista de salida. (Es costumbre empezar los nombres de propiedades con prop_.) ¡Probemos!

*Main> quickCheck prop_numElements_merge
*** Failed! Falsifiable (after 5 tests and 4 shrinks): 
[]
[0]

(Tu resultado puede ser un poco distinto. Acordate que está usando aleatoriedad.)

La primera cosa que observamos es que nuestra función está claramente equivocada. Luego vemos que QuickCheck hizo 5 tests antes de descubrir un caso de tests fallando, entonces nuestra función no es tan mala. QuickCheck nos dice que los parámetros que fallan son [] y [0]. Efectivamente, GHCi nos dice que merge3 [] [0] es [], lo que es falso.

Lo lindo acá es que QuickCheck nos encontró un caso de test pequeño que demuestra que nuestra función está equivocada. La manera de hacerlo es que usa una técnica llamada shrinking (encogimiento). Despúes de encontrar un caso de test que causa una falla, QuickCheck intenta buscando parámetros cada vez más pequeños que sigan produciendo una falla. Es maravilloso, porque sino QuickCheck nos generaría casos inmanejables y difíciles de tomar en cuenta.

Una nota final sobre esta propiedad es que la signatura de tipo nos dice que la propiedad toma listas de enteros, no cualquier tipo a. Esto es para que GHC no elija un tipo tonto, como (). Debemos siempre tener cuidado con eso cuando escribimos propiedades sobre funciones polimórficas. Los números son siempre una buena elección.

Implicaciones

Como lo hicimos arriba, generalicemos nuestro tests sobre implementaciones de nuestra operación de fusión. Otra vez, probablemente no sea necesario que lo hagas.

prop_numElements :: MergeFun -> [Integer] -> [Integer] -> Bool
prop_numElements merge xs ys
  = length xs + length ys == length (merge xs ys)

E intentamos otra vez con nuesta función:

merge2 :: MergeFun
merge2 all_xs@(x:xs) all_ys@(y:ys)
  | x < y     = x : merge2 xs all_ys
  | otherwise = y : merge2 all_xs ys
merge2 xs            ys            = xs ++ ys
*Main> quickCheck (prop_numElements merge4)
+++ OK, passed 100 tests.

¡Huzzah!

¿Ya está? ¿Hemos terminado? Todavía no. Intentemos otra propiedad:

prop_sorted1 :: MergeFun -> [Integer] -> [Integer] -> Bool
prop_sorted1 merge xs ys
  = merge xs ys == sort (xs ++ ys)
*Main> quickCheck (prop_sorted1 merge2)
*** Failed! Falsifiable (after 4 tests and 3 shrinks): 
[]
[1,0]

Rayos. QuickCheck, razonablemente, intentó con la lista [1,0]. Por supuesto, no va a funcionar porque no está ordenada. Necesitamos especificar una propiedad de implicación:

prop_sorted2 :: MergeFun -> [Integer] -> [Integer] -> Property
prop_sorted2 merge xs ys
  = isSorted xs && isSorted ys ==> merge xs ys == sort (xs ++ ys)

isSorted :: Ord a => [a] -> Bool
isSorted (a:b:rest) = a <= b && isSorted (b:rest)
isSorted _          = True    -- tiene menos de 2 elementos

En prop_sorted, vemos que usamos el operador (==>). Su tipo es Testable prop => Bool -> prop -> Property. (Es un Testable distinto del de HUnit.) Toma un Bool y un Testable y produce un Property. Observá como prop_sorted devuelve un Property, no un Bool. Vamos a poner orden en estos tipos más adelante, pero es interesante la aparición de Property acá.

Veamos como esto funciona:

*Main> quickCheck (prop_sorted2 merge2)
*** Gave up! Passed only 21 tests.

(Eso tomo capaz 20 segundos.) No hay ninguna falla, pero no hay muchos éxitos tampoco. El problema es que QuickCheck va a ejecutar el test solamente cuando las dos listas generadas aleatoriamente de tamaño n son ordenadas. Pero la probabilidad de que eso pase es de 1/n!, lo que es, en general, muy bajo. Y además necesitamos dos listas ordenadas. Eso no va a funcionar bien.

Los tipos de QuickCheck

¿Cómo hace QuickCheck para generar los casos arbitrarios? Usa la clase Arbitrary:

class Arbitrary a where
  arbitrary :: Gen a
  shrink    :: a -> [a]

Dejemos shrink a la documentación en línea, y enfoquemonos en arbitrary. La función arbitrary nos da un Gen a - un generador para el tipo a. Por supuesto, a la función arbitrary para listas no le importa el ordenamiento (de hecho, no le puede importar, dada la parametricidad), pero a nosotros sí. Por suerte, es un problema común, y QuickCheck provee una solución bajo la forma de un OrderedList, un envoltorio alrededor de listas que tiene la instancia Arbitrary que necesitábamos:

newtype OrderedList a = Ordered { getOrdered :: [a] }
instance (Ord a, Arbitrary a) => Arbitrary (OrderedList a) where ...

(newtype es casi igual a data. Buscá en línea para más información.)

Ahora, reescribamos nuestra propiedad:

prop_sorted3 :: MergeFun
             -> OrderedList Integer -> OrderedList Integer -> Bool
prop_sorted3 merge (Ordered xs) (Ordered ys)
  = merge xs ys == sort (xs ++ ys)
*Main> quickCheck (prop_sorted3 merge4)
+++ OK, passed 100 tests.

¡Huzzah! Solo cambiando un poco los tipos, podemos afectar la selección de la instancia para conseguir lo que queríamos.

Sí, todo esto parece magia negra. ¿Cómo hace QuickCheck? Veamos más en detalle los tipos.

quickCheck :: Testable prop => prop -> IO ()

class Testable prop where ...
instance Testable Bool where ...
instance Testable Property where ...
instance (Arbitrary a, Show a, Testable prop) => Testable (a -> prop) where ...

Podemos quickCheck-ear cualquier cosa que sea Testable. Los valores Booleanon son Testable, tanto como el misterioso Property. Pero es la última instancia de Testable que nos llama la atención. Dice que una function es Testable siempre y cuando su parámetro tiene una función arbitrary, su parámetro puede ser mostrado (en caso de falla) y el restultado es Testable.

¿Es Testable [Integer] -> [Integer] -> Bool? Por supuesto. Recordá que [Integer] -> [Integer] -> Bool es equivalente a [Integer] -> ([Integer] -> Bool). Porque [Integer] tiene las instancias para Arbitrary y Show, podemos usar la última instancia más arriba siempre y cuando [Integer] -> Bool es Testable. Y eso es Testable porque tenemos (otra vez) una instancia Arbitrary y Show para [Integer], y Bool es Testable. Entonces, así es como quickCheck trabaja - utiliza las instancias de Arbitrary para los tipos de los parámetros. Y, así es como cambiar los tipos de argumentos de OrderedList Integer nos produjo el resultado que queríamos.

Generando datos arbitrarios

Cuando querés usar QuickCheck sobre tus propios tipos de datos, es necesario escribir una instancia Arbitrary para ellos. Ahora, veamos como hacerlo.

Supongamos que tenemos un tipo personalizado de listas:

data MyList a = Nil | Cons a (MyList a)

instance Show a => Show (MyList a) where
  show = show . toList
  
toList :: MyList a -> [a]
toList Nil           = []
toList (a `Cons` as) = a : toList as

fromList :: [a] -> MyList a
fromList []     = Nil
fromList (a:as) = a `Cons` fromList as

Si queremos una instancia para Arbitrary, debemos definir la función arbitrary, de tipo Gen (MyList a). Por suerte para nosotros, Gen es una mónada, asi que algunos detalles ya son familiares. Tambíen nos damos cuenta que si queremos una lista arbitraria de a, vamos a necesitar generar valores a arbitrarios. Entonces, nuestra instancia se parece a:

instance Arbitrary a => Arbitrary (MyList a) where
  arbitrary = genMyList1 

A esta algura, es necesario chequear los combinadores disponibles en la sección “Generator combinators” de la documentación de QuickCheck.

Es útil pensar cómo vos, un ser humano, haría para generar una lista arbitraria. Una manera de hacerlo es elegir un tamaño arbitrario (por ejemplo, entre 0 y 10), y luego elegir cada elemento de manera arbitraria. Acá tenemos una implementación:

genMyList1 :: Arbitrary a => Gen (MyList a)
genMyList1 = do
  len <- choose (0, 10)
  vals <- replicateM len arbitrary
  return $ fromList vals

Probamos:

*Main> sample genMyList1
[(),(),(),(),(),()]
[]
[(),(),(),(),(),(),(),(),()]
[(),(),(),(),(),()]
[(),(),(),(),(),(),(),(),()]
[()]
[(),(),(),(),(),(),(),()]
[(),(),(),(),(),(),(),(),()]
[(),(),()]
[(),(),(),(),(),()]
[(),(),(),(),(),(),(),(),(),()]

Los tamaños arbitrarios funcionan, pero la generación de elementos luce muy aburrida. Usemos una anotación de tipo para hacerlo más interesante!

*Main> sample (genMyList1 :: Gen (MyList Integer))
[0,0,0,0,0,0,0,0,0,0]
[]
[-2,3,1,0,4,-1]
[-5,0,2,1,-1,-3]
[-5,-6,-7,-2,-8,7,-3,4,-6]
[4,-3,-3,2,-9,9]
[]
[10,-1]
[9,-7,-16,3,15]
[0,14,-1,0]
[3,18,-13,-17,-20,-8]

Mucho mejor.

Pero esta generación todavia no es genial, porque puede ser que una función escrita para MyList falle solo con listas mayores a 10 elementos. Deseamos tamaños sin límites. Hay una manera de hacerlo:

genMyList2 :: Arbitrary a => Gen (MyList a)
genMyList2 = do
  make_nil <- arbitrary
  if make_nil    -- type inference tells GHC that make_nil should be a Bool
     then return Nil
     else do
       x <- arbitrary
       xs <- genMyList2
       return (x `Cons` xs)
*Main> sample (genMyList2 :: Gen (MyList Integer))
[0,0,0,0,0,0]
[]
[3,-3]
[]
[]
[-1,-1]
[-10]
[]
[]
[11]
[-20,-14]

Los tamaños no tienen límite (me vas a tener que creer), pero estamos consiguiendo muchas listas vacías. Eso es porque en cada nodo de una lista, tenemos un 50% de probabilidad de producir Nil. Eso significa que una lista de tamaño n va a aparecer cada 2^n veces. Entonce, los tamaños no son acodatos pero son muy poco probables.

La forma de progresar acá es de utilizar el combinador sized. QuickCheck está configurado para probar con cosas arbitrarias “simples” antes de “complejas”. La manera de hacerlo es usando un parámetro de tamaño, interno a la mónada Gen. Cuanto más generador es QuickCheck, más alto se vuelve ese parámetro. Queremos usar el parámetro de tamaño para hacer nuestra generación.

Miremos el tipo de sized:

sized :: (Int -> Gen a) -> Gen a

La mejor forma de explicar como funciona es con un ejemplo:

genMyList3 :: Arbitrary a => Gen (MyList a)
genMyList3 = sized $ \size -> do
  len <- choose (0, size)
  vals <- replicateM len arbitrary
  return $ fromList vals
*Main> sample (genMyList3 :: Gen (MyList Integer))
[]
[-2]
[-1,3,4]
[-4,-2,1,-1]
[]
[]
[12,3,11,0,3,-12,10,5,11,12]
[-4,-8,-9,2,14,5,8,11,-1,7,11,-8,2,-6]
[6,10,-5,15,6]
[-3,-18,-4]
[9,19,13,-19]

Esto anduvo lindo - las listas tienden a volverse más larga a medida que aparecen tarde. La idea es que sized toma una continuación: la cosa que hay que hacer con el parámetro. Solo usamos una función lambda como argumento de sized. Si es demasiado complicado (por ejemplo solo queremos producir el parámetro de tamaño, sin usar una continuación), siempre podés hacer algo como lo siguiente:

getSize :: Gen Int
getSize = sized return

Te dejo entender vos mismo como esto funciona. ¡Seguí los tipos!

Más ejemplos con QuickCheck