Haskell mecanografiado dependientemente, ¿ahora?
Haskell es, en pequeña medida, un lenguaje de tipo dependiente. Existe una noción de datos de nivel de tipo, ahora se escribe con mayor sensatez gracias a ella DataKinds
, y hay algunos medios ( GADTs
) para dar una representación en tiempo de ejecución a los datos de nivel de tipo. Por lo tanto, los valores de material en tiempo de ejecución se muestran efectivamente en tipos , que es lo que significa que un idioma se tipee de forma dependiente.
Los tipos de datos simples se promueven al nivel de clase, de modo que los valores que contienen se pueden usar en tipos. De ahí el ejemplo arquetípico
data Nat = Z | S Nat
data Vec :: Nat -> * -> * where
VNil :: Vec Z x
VCons :: x -> Vec n x -> Vec (S n) x
se hace posible, y con él, definiciones como
vApply :: Vec n (s -> t) -> Vec n s -> Vec n t
vApply VNil VNil = VNil
vApply (VCons f fs) (VCons s ss) = VCons (f s) (vApply fs ss)
lo cual es bueno. Tenga en cuenta que la longitud n
es algo puramente estático en esa función, asegurando que los vectores de entrada y salida tengan la misma longitud, aunque esa longitud no desempeñe ningún papel en la ejecución de
vApply
. Por el contrario, es mucho más complicado (es decir, imposible) implementar la función que hace n
copias de un determinado x
(que sería pure
para vApply
's <*>
)
vReplicate :: x -> Vec n x
porque es vital saber cuántas copias hacer en tiempo de ejecución. Ingresa singletons.
data Natty :: Nat -> * where
Zy :: Natty Z
Sy :: Natty n -> Natty (S n)
Para cualquier tipo promocionable, podemos construir la familia singleton, indexada sobre el tipo promocionado, habitada por duplicados en tiempo de ejecución de sus valores. Natty n
es el tipo de copias en tiempo de ejecución del nivel de tipo n
:: Nat
. Ahora podemos escribir
vReplicate :: Natty n -> x -> Vec n x
vReplicate Zy x = VNil
vReplicate (Sy n) x = VCons x (vReplicate n x)
Entonces, tiene un valor de nivel de tipo unido a un valor de tiempo de ejecución: inspeccionar la copia en tiempo de ejecución refina el conocimiento estático del valor de nivel de tipo. A pesar de que los términos y los tipos están separados, podemos trabajar de forma dependiente usando la construcción singleton como una especie de resina epoxi, creando enlaces entre las fases. Eso está muy lejos de permitir expresiones arbitrarias en tiempo de ejecución en tipos, pero no es nada.
¿Qué es desagradable? ¿Qué falta?
Pongamos un poco de presión en esta tecnología y veamos qué comienza a tambalearse. Podríamos tener la idea de que los singletons deberían ser manejables un poco más implícitamente
class Nattily (n :: Nat) where
natty :: Natty n
instance Nattily Z where
natty = Zy
instance Nattily n => Nattily (S n) where
natty = Sy natty
permitiéndonos escribir, decir,
instance Nattily n => Applicative (Vec n) where
pure = vReplicate natty
(<*>) = vApply
Eso funciona, pero ahora significa que nuestro Nat
tipo original ha generado tres copias: una clase, una familia singleton y una clase singleton. Tenemos un proceso bastante torpe para intercambiar Natty n
valores explícitos y Nattily n
diccionarios. Además, Natty
no lo es Nat
: tenemos algún tipo de dependencia de los valores de tiempo de ejecución, pero no del tipo en el que primero pensamos. ¡Ningún lenguaje tipeado totalmente dependiente hace que los tipos dependientes sean tan complicados!
Mientras tanto, aunque Nat
puede promoverse, Vec
no puede. No puede indexar por un tipo indexado. Completo en los idiomas mecanografiados de forma dependiente no impone tal restricción, y en mi carrera como presumido mecanografiado de forma dependiente, he aprendido a incluir ejemplos de indexación de dos capas en mis charlas, solo para enseñar a las personas que han hecho una indexación de una capa difícil pero posible no esperar que me doble como un castillo de naipes. ¿Cuál es el problema? Igualdad. Los GADT funcionan traduciendo las restricciones que logras implícitamente cuando le das a un constructor un tipo de retorno específico en demandas ecológicas explícitas. Me gusta esto.
data Vec (n :: Nat) (x :: *)
= n ~ Z => VNil
| forall m. n ~ S m => VCons x (Vec m x)
En cada una de nuestras dos ecuaciones, ambos lados tienen tipo Nat
.
Ahora intente la misma traducción para algo indexado sobre vectores.
data InVec :: x -> Vec n x -> * where
Here :: InVec z (VCons z zs)
After :: InVec z ys -> InVec z (VCons y ys)
se convierte
data InVec (a :: x) (as :: Vec n x)
= forall m z (zs :: Vec x m). (n ~ S m, as ~ VCons z zs) => Here
| forall m y z (ys :: Vec x m). (n ~ S m, as ~ VCons y ys) => After (InVec z ys)
y ahora formamos restricciones equitativas entre as :: Vec n x
y
VCons z zs :: Vec (S m) x
donde los dos lados tienen tipos sintácticamente distintos (pero demostrablemente iguales). ¡El núcleo GHC no está equipado actualmente para tal concepto!
¿Qué más falta? Bueno, la mayoría de Haskell falta en el nivel de tipo. El lenguaje de los términos que puede promover tiene solo variables y constructores no GADT, realmente. Una vez que los tenga, la type family
maquinaria le permite escribir programas de nivel de tipo: algunos de ellos podrían ser funciones que consideraría escribir a nivel de término (p. Ej., Equipar Nat
con la suma, por lo que puede dar un buen tipo para agregar Vec
) , pero eso es solo una coincidencia!
Otra cosa que falta, en la práctica, es una biblioteca que utiliza nuestras nuevas habilidades para indexar tipos por valores. ¿Qué hacer Functor
y Monad
convertirse en este valiente mundo nuevo? Estoy pensando en eso, pero aún queda mucho por hacer.
Ejecución de programas de nivel de tipo
Haskell, como la mayoría de los lenguajes de programación de tipo dependiente, tiene dos
semánticas operativas. Existe la forma en que el sistema de tiempo de ejecución ejecuta programas (solo expresiones cerradas, después de la eliminación de tipos, altamente optimizado) y luego está la forma en que el typechecker ejecuta programas (sus familias de tipos, su "Prólogo de clase de tipo", con expresiones abiertas). Para Haskell, normalmente no se mezclan los dos, porque los programas que se ejecutan están en diferentes idiomas. Los lenguajes de tipo dependiente tienen modelos de ejecución estáticos y de tiempo de ejecución separados para el mismo lenguaje de programas, pero no se preocupe, el modelo de tiempo de ejecución todavía le permite borrar el tipo y, de hecho, borrar la prueba: eso es lo que extrae Coqel mecanismo te da; eso es al menos lo que hace el compilador de Edwin Brady (aunque Edwin borra valores innecesariamente duplicados, así como tipos y pruebas). La distinción de fase puede que ya no sea una distinción de categoría sintáctica
, pero está viva y bien.
Los lenguajes mecanografiados de forma dependiente, al ser total, le permiten al typechecker ejecutar programas sin temor a nada peor que una larga espera. A medida que Haskell se vuelve más tipeado de forma dependiente, nos enfrentamos a la pregunta de cuál debería ser su modelo de ejecución estático. Un enfoque podría ser restringir la ejecución estática a funciones totales, lo que nos permitiría la misma libertad de ejecución, pero podría obligarnos a hacer distinciones (al menos para el código de nivel de tipo) entre datos y codatos, para que podamos saber si hacer cumplir la terminación o la productividad. Pero ese no es el único enfoque. Somos libres de elegir un modelo de ejecución mucho más débil que sea reacio a ejecutar programas, a costa de hacer que salgan menos ecuaciones solo por cálculo. Y en efecto, eso es lo que realmente hace GHC. Las reglas de escritura para GHC core no mencionan la ejecución
programas, pero solo para verificar la evidencia de ecuaciones. Al traducir al núcleo, el solucionador de restricciones de GHC intenta ejecutar sus programas de nivel de tipo, generando un pequeño rastro plateado de evidencia de que una expresión dada es igual a su forma normal. Este método de generación de evidencia es un poco impredecible e inevitablemente incompleto: por ejemplo, lucha contra la recurrencia de aspecto aterrador, y eso probablemente sea sabio. Una cosa de la que no debemos preocuparnos es la ejecución de los IO
cálculos en el typechecker: ¡recuerde que el typechecker no tiene que dar
launchMissiles
el mismo significado que el sistema de tiempo de ejecución!
Cultura Hindley-Milner
¡El sistema de tipo Hindley-Milner logra la coincidencia verdaderamente asombrosa de cuatro distinciones distintas, con el desafortunado efecto secundario cultural de que muchas personas no pueden ver la distinción entre las distinciones y asumir que la coincidencia es inevitable! De que estoy hablando
- términos vs tipos
- cosas escritas explícitamente vs cosas escritas implícitamente
- presencia en tiempo de ejecución frente a borrado antes del tiempo de ejecución
- abstracción no dependiente vs cuantificación dependiente
Estamos acostumbrados a escribir términos y dejar tipos para inferir ... y luego borrar. Estamos acostumbrados a cuantificar las variables de tipo con la abstracción de tipo correspondiente y la aplicación que ocurre de forma silenciosa y estática.
No tiene que desviarse demasiado de la vainilla Hindley-Milner antes de que estas distinciones se desalineen, y eso no es malo . Para empezar, podemos tener tipos más interesantes si estamos dispuestos a escribirlos en algunos lugares. Mientras tanto, no tenemos que escribir diccionarios de clase de tipo cuando usamos funciones sobrecargadas, pero esos diccionarios están ciertamente presentes (o en línea) en tiempo de ejecución. En lenguajes de tipo dependiente, esperamos borrar más que solo los tipos en tiempo de ejecución, pero (como con las clases de tipos) que algunos valores inferidos implícitamente no se borrarán. Por ejemplo, vReplicate
el argumento numérico a menudo es inferible del tipo del vector deseado, pero aún necesitamos saberlo en tiempo de ejecución.
¿Qué opciones de diseño de lenguaje debemos revisar porque estas coincidencias ya no son válidas? Por ejemplo, ¿es correcto que Haskell no proporcione una forma de instanciar un forall x. t
cuantificador explícitamente? Si el typechecker no puede adivinar x
unificando t
, no tenemos otra manera de decir lo que x
debe ser.
En términos más generales, no podemos tratar la "inferencia de tipos" como un concepto monolítico del que tenemos todo o nada. Para empezar, necesitamos separar el aspecto de "generalización" (la regla de "dejar" de Milner), que depende en gran medida de restringir qué tipos existen para garantizar que una máquina estúpida pueda adivinar uno, desde el aspecto de "especialización" ("var" de Milner "regla) que es tan eficaz como su solucionador de restricciones. Podemos esperar que los tipos de nivel superior sean más difíciles de inferir, pero que la información de tipos internos seguirá siendo bastante fácil de propagar.
Próximos pasos para Haskell
Estamos viendo que el tipo y los niveles de tipo se vuelven muy similares (y ya comparten una representación interna en GHC). También podríamos fusionarlos. Sería divertido tomarlo * :: *
si podemos: perdimos la
solidez lógica hace mucho tiempo, cuando permitimos el fondo, pero la
solidez del tipo suele ser un requisito más débil. Hay que comprobarlo. Si debemos tener distintos niveles de tipo, tipo, etc., al menos podemos asegurarnos de que todo en el nivel de tipo y superior siempre se puede promover. Sería genial reutilizar el polimorfismo que ya tenemos para los tipos, en lugar de reinventar el polimorfismo a nivel amable.
Deberíamos simplificar y generalizar el sistema actual de restricciones al permitir ecuaciones heterogéneasa ~ b
donde los tipos de a
y
b
no son sintácticamente idénticos (pero pueden probarse iguales). Es una técnica antigua (en mi tesis, del siglo pasado) que hace que la dependencia sea mucho más fácil de manejar. Podríamos expresar restricciones sobre las expresiones en GADT y, por lo tanto, relajar las restricciones sobre lo que se puede promover.
Debemos eliminar la necesidad de la construcción Singleton mediante la introducción de un tipo de función dependiente pi x :: s -> t
. Una función con dicho tipo podría aplicarse explícitamente a cualquier expresión de tipo s
que viva en la intersección de los lenguajes de tipo y término (por lo tanto, variables, constructores, con más por venir más adelante). La lambda y la aplicación correspondientes no se borrarían en tiempo de ejecución, por lo que podríamos escribir
vReplicate :: pi n :: Nat -> x -> Vec n x
vReplicate Z x = VNil
vReplicate (S n) x = VCons x (vReplicate n x)
sin reemplazar Nat
por Natty
. El dominio de pi
puede ser de cualquier tipo promocionable, por lo que si se pueden promover los GADT, podemos escribir secuencias cuantificadoras dependientes (o "telescopios" como los llamó De Briuijn)
pi n :: Nat -> pi xs :: Vec n x -> ...
a cualquier longitud que necesitemos.
El objetivo de estos pasos es eliminar la complejidad trabajando directamente con herramientas más generales, en lugar de conformarse con herramientas débiles y codificaciones torpes. La aceptación parcial actual hace que los beneficios de los tipos dependientes de Haskell sean más caros de lo que deberían ser.
¿Demasiado duro?
Los tipos dependientes ponen nerviosas a muchas personas. Me ponen nervioso, pero me gusta estar nervioso, o al menos me resulta difícil no estar nervioso de todos modos. Pero no ayuda que haya tanta niebla de ignorancia en torno al tema. Algo de eso se debe al hecho de que todos todavía tenemos mucho que aprender. Pero se sabe que los defensores de enfoques menos radicales avivan el miedo a los tipos dependientes sin asegurarse siempre de que los hechos estén totalmente de acuerdo con ellos. No nombraré nombres. Estos "controles de tipo indecidibles", "Turing incompleto", "sin distinción de fase", "sin borrado de tipo", "pruebas en todas partes", etc., los mitos persisten, a pesar de que son basura.
Ciertamente, no es el caso de que los programas mecanografiados de forma dependiente siempre se demuestren correctos. Uno puede mejorar la higiene básica de sus programas, imponiendo invariantes adicionales en tipos sin tener que llegar a una especificación completa. Pequeños pasos en esta dirección a menudo resultan en garantías mucho más fuertes con pocas o ninguna obligación de prueba adicional. No es cierto que los programas de tipo dependiente estén inevitablemente llenos de pruebas, de hecho, generalmente considero la presencia de cualquier prueba en mi código como la señal para cuestionar mis definiciones .
Porque, como con cualquier aumento en la articulación, nos volvemos libres de decir cosas malas y nuevas. Por ejemplo, hay muchas formas malas de definir árboles de búsqueda binarios, pero eso no significa que no haya una buena manera . Es importante no presumir que las malas experiencias no pueden ser mejoradas, incluso si esto perjudica al ego para admitirlo. ¡El diseño de definiciones dependientes es una nueva habilidad que requiere aprendizaje, y ser un programador de Haskell no te convierte automáticamente en un experto! E incluso si algunos programas son sucios, ¿por qué negarías a otros la libertad de ser justos?
¿Por qué todavía molestarse con Haskell?
Realmente disfruto los tipos dependientes, pero la mayoría de mis proyectos de piratería aún están en Haskell. ¿Por qué? Haskell tiene clases de tipo. Haskell tiene bibliotecas útiles. Haskell tiene un tratamiento viable (aunque lejos de ser ideal) de programación con efectos. Haskell tiene un compilador de fuerza industrial. Los idiomas de tipo dependiente se encuentran en una etapa mucho más temprana en el crecimiento de la comunidad y la infraestructura, pero llegaremos allí, con un cambio generacional real en lo que es posible, por ejemplo, mediante metaprogramación y genéricos de tipo de datos. Pero solo tiene que mirar a su alrededor lo que la gente está haciendo como resultado de los pasos de Haskell hacia los tipos dependientes para ver que también se pueden obtener muchos beneficios al impulsar la generación actual de idiomas.