Leí en esta pregunta que los programadores funcionales tienden a usar pruebas matemáticas para asegurarse de que su programa funciona correctamente. Esto suena mucho más fácil y rápido que las pruebas unitarias, pero viniendo de un fondo de OOP / Pruebas unitarias, nunca lo he visto hecho.
¿Me lo puedes explicar y darme un ejemplo?
testing
functional-programming
leeand00
fuente
fuente
null
).Respuestas:
Una prueba es mucho más difícil en el mundo de OOP debido a los efectos secundarios, la herencia sin restricciones y
null
ser miembro de todo tipo. La mayoría de las pruebas se basan en un principio de inducción para demostrar que ha cubierto todas las posibilidades, y las 3 cosas hacen que sea más difícil de probar.Digamos que estamos implementando árboles binarios que contienen valores enteros (en aras de mantener la sintaxis más simple, no incluiré programación genérica en esto, aunque no cambiaría nada). En ML estándar, definiría eso como esta:
datatype tree = Empty | Node of (tree * int * tree)
Esto introduce un nuevo tipo llamado
tree
cuyos valores pueden venir exactamente en dos variedades (o clases, que no deben confundirse con el concepto OOP de una clase): unEmpty
valor que no contiene información yNode
valores que llevan una tupla 3 cuya primera y última los elementos sontree
sy cuyo elemento medio es anint
. La aproximación más cercana a esta declaración en OOP se vería así:Con la advertencia de que las variables de tipo Árbol nunca pueden ser
null
.Ahora escribamos una función para calcular la altura (o profundidad) del árbol, y supongamos que tenemos acceso a una
max
función que devuelve el mayor de dos números:Hemos definido la
height
función por casos: hay una definición paraEmpty
árboles y una definición paraNode
árboles. El compilador sabe cuántas clases de árboles existen y emitiría una advertencia si no define ambos casos. La expresiónNode (leftChild, value, rightChild)
en la firma de la función une los valores de la 3-tupla a las variablesleftChild
,value
yrightChild
respectivamente, de manera que podemos hacer referencia a ellos en la definición de función. Es similar a haber declarado variables locales como esta en un lenguaje OOP:¿Cómo podemos demostrar que hemos implementado
height
correctamente? Podemos usar la inducción estructural , que consiste en: 1. Probar queheight
es correcto en los casos base de nuestrotree
tipo (Empty
) 2. Suponiendo que las llamadas recursivasheight
sean correctas, demuestre queheight
es correcto para los casos no base ) (cuando el árbol es realmente aNode
).Para el paso 1, podemos ver que la función siempre devuelve 0 cuando el argumento es un
Empty
árbol. Esto es correcto por definición de la altura de un árbol.Para el paso 2, la función vuelve
1 + max( height(leftChild), height(rightChild) )
. Suponiendo que las llamadas recursivas realmente devuelven la altura de los niños, podemos ver que esto también es correcto.Y eso completa la prueba. Los pasos 1 y 2 combinados agotan todas las posibilidades. Tenga en cuenta, sin embargo, que no tenemos mutación, ni nulos, y que hay exactamente dos variedades de árboles. Elimine esas tres condiciones y la prueba rápidamente se vuelve más complicada, si no práctica.
EDITAR: Dado que esta respuesta ha llegado a la cima, me gustaría agregar un ejemplo menos trivial de una prueba y cubrir la inducción estructural un poco más a fondo. Arriba probamos que si
height
devuelve , su valor de retorno es correcto. Sin embargo, no hemos demostrado que siempre devuelva un valor. Podemos usar la inducción estructural para probar esto también (o cualquier otra propiedad). Nuevamente, durante el paso 2, se nos permite asumir las propiedades retenidas de las llamadas recursivas siempre que todas las llamadas recursivas operen en un hijo directo del árbol.Una función puede fallar al devolver un valor en dos situaciones: si arroja una excepción y si se repite para siempre. Primero demostremos que si no se lanzan excepciones, la función termina:
Demuestre que (si no se lanzan excepciones) la función termina para los casos base (
Empty
). Como incondicionalmente devolvemos 0, termina.Probar que la función termina en los casos no base (
Node
). Hay tres llamadas a funciones aquí:+
,max
, yheight
. Lo sabemos+
ymax
terminamos porque son parte de la biblioteca estándar del lenguaje y están definidos de esa manera. Como se mencionó anteriormente, se nos permite asumir que la propiedad que intentamos probar es verdadera en las llamadas recursivas siempre que operen en subárboles inmediatos, por lo que las llamadasheight
finalizarán también.Eso concluye la prueba. Tenga en cuenta que no podrá probar la terminación con una prueba unitaria. Ahora todo lo que queda es mostrar que
height
no arroja excepciones.height
no arroja excepciones en el caso base (Empty
). Devolver 0 no puede arrojar una excepción, así que hemos terminado.height
no arroja una excepción en el caso no base (Node
). Asuma una vez más que sabemos+
ymax
no arroje excepciones. Y la inducción estructural nos permite asumir que las llamadas recursivas tampoco se lanzarán (porque operan en los hijos inmediatos del árbol). ¡Pero esperen! Esta función es recursiva, pero no recursiva de cola . ¡Podríamos volar la pila! Nuestro intento de prueba ha descubierto un error. Podemos arreglarlo cambiandoheight
para que sea recursivo en la cola .Espero que esto muestre que las pruebas no tienen que ser aterradoras o complicadas. De hecho, cada vez que escribe un código, ha construido informalmente una prueba en su cabeza (de lo contrario, no estaría convencido de que acaba de implementar la función). Al evitar la mutación nula e innecesaria y la herencia sin restricciones, puede demostrar que su intuición es corregir con bastante facilidad. Estas restricciones no son tan duras como podría pensar:
null
es un defecto del idioma y eliminarlo es incondicionalmente bueno.fuente
Es mucho más fácil razonar sobre el código cuando todo es inmutable . Como resultado, los bucles se escriben con mayor frecuencia como recursividad. En general, es más fácil verificar la corrección de una solución recursiva. A menudo, dicha solución también se leerá de manera muy similar a una definición matemática del problema.
Sin embargo, hay muy poca motivación para llevar a cabo una prueba formal real de corrección en la mayoría de los casos. Las pruebas son difíciles, toman mucho tiempo (humano) y tienen un ROI bajo.
Algunos lenguajes funcionales (especialmente de la familia ML) tienen sistemas de tipos extremadamente expresivos que pueden ofrecer garantías mucho más completas que un sistema de tipos de estilo C (pero algunas ideas como los genéricos también se han vuelto comunes en los idiomas convencionales). Cuando un programa pasa una verificación de tipo, este es un tipo de prueba automatizada. En algunos casos, esto podrá detectar algunos errores (por ejemplo, olvidar el caso base en una recursión u olvidar manejar ciertos casos en una coincidencia de patrón).
Por otro lado, estos sistemas de tipo deben mantenerse muy limitados para que sean decidibles . Entonces, en cierto sentido, obtenemos garantías estáticas al renunciar a la flexibilidad, y estas restricciones son una razón por la cual existen trabajos académicos complicados en la línea de " Una solución monádica a un problema resuelto, en Haskell ".
Me gustan los idiomas muy liberales y los idiomas muy restringidos, y ambos tienen sus respectivas dificultades. Pero no es el caso de que uno sea "mejor", cada uno es más conveniente para un tipo diferente de tarea.
Luego hay que señalar que las pruebas y las pruebas unitarias no son intercambiables. Ambos nos permiten poner límites a la corrección del programa:
Las pruebas ponen un límite superior a la corrección: si una prueba falla, el programa es incorrecto, si ninguna prueba falla, estamos seguros de que el programa manejará los casos probados, pero aún puede haber errores no descubiertos.
Las pruebas ponen un límite inferior a la corrección: puede ser imposible probar ciertas propiedades. Por ejemplo, puede ser fácil demostrar que una función siempre devuelve un número (eso es lo que hacen los sistemas de tipos). Pero puede ser imposible demostrar que el número siempre será
< 10
.fuente
Una palabra de advertencia puede estar en orden aquí:
Si bien es generalmente cierto lo que otros escriben aquí, en resumen, que los sistemas de tipos avanzados, la inmutabilidad y la transparencia referencial contribuyen mucho a la corrección, no es el caso que las pruebas no se realicen en el mundo funcional. Por el contrario !
Esto se debe a que tenemos herramientas como Quickcheck, que generan casos de prueba de forma automática y aleatoria. Simplemente establece las leyes que una función debe obedecer, y luego la verificación rápida verificará estas leyes para cientos de casos de prueba aleatorios.
Verá, este es un nivel un poco más alto que las verificaciones de igualdad triviales en un puñado de casos de prueba.
Aquí hay un ejemplo de una implementación de un árbol AVL:
La segunda ley (o propiedad) podemos leer lo siguiente: para todos los árboles arbitrarios
t
, se cumple lo siguiente: sit
no está vacío, entonces, para todas las clavesk
de ese árbol, se mantendrá esa búsquedak
en el árbol que es el resultado de eliminark
desdet
, el resultado seráNothing
(que indica: no encontrado).Esto verifica la funcionalidad adecuada para la eliminación de una clave existente. ¿Qué leyes deberían regir la eliminación de una clave no existente? Ciertamente queremos que el árbol resultante sea el mismo que el que eliminamos. Podemos expresar esto fácilmente:
De esta manera, las pruebas son realmente divertidas. Y además, una vez que aprende a leer las propiedades de la comprobación rápida, sirven como una especificación comprobable por máquina .
fuente
No entiendo exactamente qué significa la respuesta vinculada al "lograr la modularidad a través de las leyes matemáticas", pero creo que tengo una idea de lo que significa.
Echa un vistazo a Functor :
No viene con casos de prueba, sino con un par de leyes que deben cumplirse.
Ahora supongamos que implementa
Functor
( fuente ):El problema es verificar que su implementación cumpla con las leyes. ¿Cómo haces para hacer eso?
Un enfoque es escribir casos de prueba. La limitación fundamental de este enfoque es que está verificando el comportamiento en un número finito de casos (¡buena suerte probando exhaustivamente una función con 8 parámetros!), Por lo que pasar las pruebas no puede garantizar nada más que que las pruebas pasen.
Otro enfoque es utilizar el razonamiento matemático, es decir, una prueba, basada en la definición real (en lugar de en el comportamiento en un número limitado de casos). La idea aquí es que una prueba matemática puede ser más efectiva; sin embargo, esto depende de qué tan adecuado sea su programa para la prueba matemática.
No puedo guiarlo a través de una prueba formal real de que la
Functor
instancia anterior cumple con las leyes, pero intentaré dar un resumen de cómo se vería la prueba:fmap id = id
Nothing
fmap id Nothing
=Nothing
por parte 1 de la implementaciónid Nothing
=Nothing
por la definición deid
Just x
fmap id (Just x)
=Just (id x)
=Just x
por la parte 2 de la implementación, luego por la definición deid
fmap (p . q) = (fmap p) . (fmap q)
Nothing
fmap (p . q) Nothing
=Nothing
por parte 1(fmap p) . (fmap q) $ Nothing
=(fmap p) $ Nothing
=Nothing
por dos aplicaciones de la parte 1Just x
fmap (p . q) (Just x)
=Just ((p . q) x)
=Just (p (q x))
por la parte 2, luego por la definición de.
(fmap p) . (fmap q) $ (Just x)
=(fmap p) $ (Just (q x))
=Just (p (q x))
por dos aplicaciones de la segunda partefuente
"Tenga cuidado con los errores en el código anterior; solo he demostrado que es correcto, no lo he probado". - Donald Knuth
En un mundo perfecto, los programadores son perfectos y no cometen errores, por lo que no hay errores.
En un mundo perfecto, los informáticos y los matemáticos también son perfectos, y tampoco cometen errores.
Pero no vivimos en un mundo perfecto. Por lo tanto, no podemos confiar en que los programadores no cometan errores. Pero no podemos suponer que ningún informático que presente una prueba matemática de que un programa es correcto no cometió ningún error en esa prueba. Así que no le prestaría atención a nadie que intente probar que su código funciona. Escriba pruebas unitarias y muéstreme que el código se comporta de acuerdo con las especificaciones. Cualquier otra cosa no me convencerá de nada.
fuente