¿Cómo ayudan los tipos en la verificación formal de los programas?

Tikhon Jelvis ya ha proporcionado una respuesta magnífica.
Agregaré un pequeño detalle que la mayoría de los programadores desconocen: la correspondencia de Curry Howard.

Existe una equivalencia entre programas de computadora y pruebas matemáticas.
Para algunos lenguajes (lenguajes funcionales totales o, más genéricamente, cálculos lambda mecanografiados), la equivalencia significa que un tipo de función representa un teorema y la existencia de la implementación de la función es una prueba de ese teorema. Más generalmente, en algunos idiomas, un tipo es una afirmación y un valor de ese tipo es testigo de la verdad de esa afirmación.
(Nota: no estoy siendo formal aquí, estoy tratando de hacer que el concepto sea generalmente accesible incluso sin conocimiento matemático).

Si entrecierra un poco los ojos, verá cómo esto implica que se puede hacer MUCHO en términos de “detección de errores” porque el lenguaje le permite codificar declaraciones formales y sus pruebas. Estas declaraciones formales pueden muy bien ser “sobre el programa” en sí.

Incluso en idiomas con un sistema de tipos demasiado débil para tener pruebas formales (como Haskell), el sistema de tipos puede ser lo suficientemente poderoso como para que pueda razonar sobre el código simplemente mirando los tipos. En Haskell, a menudo puede ver el tipo de función y saber qué hace la función sin ver ninguna documentación y sin mirar la implementación. La razón es que no se pueden codificar “errores” (no escribirían) y todas las implementaciones posibles de esa función hacen exactamente lo mismo.
Esto no es 100% cierto, pero casi: las implementaciones hacen lo mismo O no terminan O lanzan una excepción, y lanzar una excepción o no terminar se considera más o menos un error, en Haskell.

Para verificar formalmente el software, necesita un sistema de tipos significativamente más expresivo que los de lenguajes populares como C ++ y Java. Si esos son los sistemas con los que está más familiarizado, definitivamente es difícil ver cómo podría funcionar.

El truco es que los sistemas de tipos más avanzados pueden codificar cualquier propiedad estática del programa basada en la sintaxis . Van mucho más allá de simplemente decirle qué es un número y qué es una cadena; pueden decirle cosas como “este es un número par, no cero menor que 8” o “esta es una matriz 4 × 4” o incluso “esto es un programa basado en pila que necesita al menos cuatro entradas en la pila “.

Esta expresividad le permite codificar su especificación como un tipo y hace que la verificación sea solo una cuestión de verificación de tipo.

Un ejemplo simple de tipos más avanzados es LiquidHaskell, que extiende Haskell con tipos de refinamiento . Estos tipos le permiten definir “subconjuntos” de tipos existentes utilizando predicados lógicos; Los predicados lógicos se resuelven con un poderoso teorema probador para asegurar que sean consistentes con el programa real. Con LiquidHaskell, podría escribir mi primer tipo de ejemplo (par, distinto de cero, <8) aproximadamente de la siguiente manera:

{ [correo electrónico protegido] {x: Int | incluso x && x <8 && x / = 0} @ -}
x = 6

(El { [email protected] y @-} definen un tipo especial de comentario que LiquidHaskell busca ya que es una herramienta externa, no parte de Haskell propiamente dicha).

Más interesante aún, podría tener este tipo en el dominio o codominio de una función, que le permite verificar estáticamente las condiciones previas y posteriores de sus funciones.

LiquidHaskell es excelente y ya le permite verificar propiedades interesantes de sus programas. Sin embargo, tiene un par de limitaciones. Por un lado, debe expresar sus limitaciones de manera que el probador de teoremas automatizado (y el solucionador SMT) entiendan. Esto es lo suficientemente expresivo para muchos invariantes interesantes, pero demasiado limitado para una especificación completa de la mayoría del software. La otra limitación es que Haskell es Turing completo; Esto significa que siempre puede tener un programa que simplemente no termina. Incluso si ha verificado que coincide con todos los tipos, ¡no siempre puede probar que alguna vez le dará una respuesta!

Los tipos de refinamiento también existen como complementos a otros lenguajes como ML y C, así como una característica de primera clase de ATS, un lenguaje para la programación de sistemas de bajo nivel de seguridad crítica.

Para verificar completamente un programa, necesita un sistema de tipos aún más potente, ¡y probablemente desee un lenguaje que no sea ​​completo para Turing! Esto hace que estos idiomas sean mucho más difíciles de usar, pero los tipos le brindan garantías mucho mejores.

Actualmente, el enfoque más popular para estos idiomas es usar tipos dependientes . Un tipo dependiente es uno que puede depender del valor de un término. Por ejemplo, podría tener un programa que tome un número natural y luego una lista de esa * longitud y le brinde un elemento de la lista. Se vería así (en pseudocódigo):

f: (n: ℕ) → (l: Listar) → x: a

Tenga en cuenta cómo n refiere al número específico pasado, y luego aparece en el tipo de l !

Esto significa que puede expresar tipos utilizando valores normales, ¡e incluso funciones normales! Esto es lo suficientemente expresivo como para codificar cualquier invariante que desee, incluida una especificación completa de su programa.

Una nota interesante es que estos tipos aún pueden verificarse estáticamente. En esencia, lo que sucede es que debe evaluar partes del programa a medida que verifica los tipos, propagando cualquier valor desconocido (es decir, cosas que solo puede saber en tiempo de ejecución) sin evaluarlos. Es un tipo especial de evaluación parcial . Si no tiene suficiente información para garantizar que todo coincida, terminará con un error de tipo.

Puede verificar formalmente el software con tipos porque los tipos pueden ser básicamente cualquier tipo de propiedad que se verifique estáticamente. Sin embargo, esto requiere un sistema de tipos más expresivo que se base en un solucionador lógico externo (como LiquidHaskell) o que en realidad pueda tener un código arbitrario en los tipos (como los tipos dependientes).