El verificador de tipo permite un reemplazo de tipo muy incorrecto y el programa aún se compila

99

Mientras intentaba depurar un problema en mi programa (se dibujan 2 círculos con un radio igual en diferentes tamaños usando Gloss *), me encontré con una situación extraña. En mi archivo que maneja objetos, tengo la siguiente definición para unPlayer :

type Coord = (Float,Float)
data Obj =  Player  { oPos :: Coord, oDims :: Coord }

y en mi archivo principal, que importa Objects.hs, tengo la siguiente definición:

startPlayer :: Obj
startPlayer = Player (0,0) 10

Esto sucedió debido a que agregué y cambié campos para el jugador, y me olvidé de actualizar startPlayerdespués (sus dimensiones fueron determinadas por un solo número para representar un radio, pero lo cambié a Coordpara representar (ancho, alto); en caso de que alguna vez haga el jugador objeta un no-círculo).

Lo sorprendente es que el código anterior se compila y se ejecuta, a pesar de que el segundo campo es del tipo incorrecto.

Primero pensé que tal vez tenía diferentes versiones de los archivos abiertos, pero cualquier cambio en los archivos se reflejaba en el programa compilado.

Luego pensé que tal vez startPlayerno se estaba usando por alguna razón. Comentando startPlayerproduce un error de compilación sin embargo, y aún más extraño, cambiando el 10en startPlayercausas una respuesta apropiada (cambia el tamaño de partida de la Player); de nuevo, a pesar de ser del tipo incorrecto. Para asegurarme de que está leyendo la definición de datos correctamente, inserté un error tipográfico en el archivo y me dio un error; entonces estoy mirando el archivo correcto.

Intenté pegar los 2 fragmentos de arriba en su propio archivo, y escupió el error esperado de que el segundo campo de Player en startPlayeres incorrecta.

¿Qué podría permitir que esto sucediera? Uno pensaría que esto es exactamente lo que el verificador de tipos de Haskell debería prevenir.


* La respuesta a mi problema original, dos círculos de radio supuestamente iguales dibujados en diferentes tamaños, fue que uno de los radios era en realidad negativo.

Carcigenicate
fuente
26
Como señaló @Cubic, definitivamente debe informar este problema a los mantenedores de Gloss. Su pregunta ilustra muy bien cómo la instancia huérfana incorrecta de una biblioteca estropeó su código.
Christian Conkle
1
Hecho. ¿Es posible excluir instancias? Es posible que lo necesiten para que funcione la biblioteca, pero yo no lo necesito. También noté que definieron Num Color. Es solo cuestión de tiempo antes de que eso me atrape.
Carcigenicate
@Cubic Bueno, demasiado tarde. Y sólo lo descargué hace una semana usando un Cabal actualizado y actualizado; por lo que debería ser actual.
Carcigenicate
2
@ChristianConkle Existe la posibilidad de que el autor de gloss no haya entendido lo que hace TypeSynonymInstances. En cualquier caso, esto realmente debe desaparecer (ya sea hacer Pointa newtypeo usar otros nombres de operador ala linear)
Cubic
1
@Cubic: TypeSynonymInstances no es tan malo por sí solo (aunque no completamente inofensivo), pero cuando lo combinas con OverlappingInstances las cosas se ponen muy divertidas.
John L

Respuestas:

128

La única forma en que esto podría compilarse es si existe una Num (Float,Float)instancia. Esto no lo proporciona la biblioteca estándar, aunque es posible que una de las bibliotecas que está utilizando lo haya agregado por alguna razón loca. Intente cargar su proyecto en ghci y vea si 10 :: (Float,Float)funciona, luego intente :i Numaveriguar de dónde proviene la instancia y luego grite a quien la definió.

Anexo: no hay forma de desactivar las instancias. Ni siquiera hay una forma de no exportarlos desde un módulo. Si esto fuera posible, conduciría a un código aún más confuso. La única solución real aquí es no definir instancias como esa.

Cúbico
fuente
53
GUAU. 10 :: (Float, Float)produce (10.0,10.0)y :i Numcontiene la línea instance Num Point -- Defined in ‘Graphics.Gloss.Data.Point’( Pointes el alias de Coord de Gloss). ¿Seriamente? Gracias. Eso me salvó de una noche de insomnio.
Carcigenicate
6
@Carcigenicate Si bien parece frívolo permitir tales instancias, la razón por la que está permitido es para que los desarrolladores puedan escribir sus propias instancias de Numdónde tiene sentido, como un Angletipo de datos que restringe un Doubleentre -piy pi, o si alguien quisiera escribir un tipo de datos representando cuaterniones o algún otro tipo numérico más complejo, esta característica es muy conveniente. También sigue las mismas reglas que String/ Text/ ByteString, permitir estas instancias tiene sentido desde el punto de vista de la facilidad de uso, pero puede ser mal utilizado como en este caso.
bheklilr
4
@bheklilr Entiendo la necesidad de permitir instancias de Num. El "WOW" surgió de algunas cosas. No sabía que se podían crear instancias de alias de tipo, crear una instancia Num de un Coord parece contrario a la intuición, y no pensé en eso. Oh bien, lección aprendida.
Carcigenicate
3
Puede solucionar su problema con la instancia huérfana de su biblioteca utilizando una newtypedeclaración para en Coordlugar de una type.
Benjamin Hodgson
3
@Carcigenicate Creo que necesitas -XTypeSynonymInstances para permitir instancias para sinónimos de tipo, pero eso no es necesario para crear la instancia problemática. Una instancia para Num (Float, Float)o incluso (Floating a) => Num (a,a)no requeriría la extensión pero daría como resultado el mismo comportamiento.
crockeea
64

El verificador de tipos de Haskell está siendo razonable. El problema es que los autores de una biblioteca que estás usando han hecho algo ... menos razonable.

La respuesta breve es: Sí, 10 :: (Float, Float)es perfectamente válido si hay una instancia Num (Float, Float). No tiene nada de "muy malo" desde la perspectiva del compilador o del lenguaje. Simplemente no cuadra con nuestra intuición sobre lo que hacen los literales numéricos. Ya que está acostumbrado a que el sistema de tipos detecte el tipo de error que cometió, ¡está sorprendido y decepcionado con razón!

Numinstancias y el fromIntegerproblema

Le sorprende que el compilador acepte 10 :: Coord, es decir 10 :: (Float, Float). Es razonable suponer que se inferirá que los literales numéricos como 10tienen tipos "numéricos". Fuera de la caja, literales numéricos se pueden interpretar como Int, Integer, Float, o Double. Una tupla de números, sin otro contexto, no parece un número en la forma en que esos cuatro tipos son números. No estamos hablando de Complex.

Sin embargo, por suerte o por desgracia, Haskell es un idioma muy flexible. El estándar especifica que un literal entero como 10se interpretará como fromInteger 10, que tiene tipo Num a => a. Por 10lo tanto, podría inferirse como cualquier tipo que tenga una Numinstancia escrita para él. Explico esto con un poco más de detalle en otra respuesta .

Entonces, cuando publicó su pregunta, un Haskeller experimentado inmediatamente notó que para 10 :: (Float, Float)ser aceptado, debe haber una instancia como Num a => Num (a, a)o Num (Float, Float). No existe tal instancia en el Prelude, por lo que debe haberse definido en otro lugar. Usando :i Num, rápidamente descubrió de dónde venía: el glosspaquete.

Sinónimos de tipo e instancias huérfanas

Pero espera un minuto. No está utilizando ningún glosstipo en este ejemplo; ¿Por qué glosste afectó la instancia en ? La respuesta viene en dos pasos.

Primero, un sinónimo de tipo introducido con la palabra clave typeno crea un nuevo tipo . En su módulo, escribir Coordes simplemente una abreviatura de (Float, Float). Del mismo modo en Graphics.Gloss.Data.Point, Pointsignifica (Float, Float). En otras palabras, los Coordy gloss's Pointson, literalmente equivalente.

Entonces, cuando los glossmantenedores decidieron escribir instance Num Point where ..., también hicieron de su Coordtipo una instancia de Num. Eso es equivalente a instance Num (Float, Float) where ...o instance Num Coord where ....

(De forma predeterminada, Haskell no permite que los sinónimos de tipo sean instancias de clase. Los glossautores tuvieron que habilitar un par de extensiones de idioma TypeSynonymInstancesy FlexibleInstances, para escribir la instancia).

En segundo lugar, esto es sorprendente porque es una instancia huérfana , es decir, una declaración de instancia instance C Adonde ambos Cy Aestán definidos en otros módulos. Aquí es particularmente insidioso porque cada parte involucrada, es decir Num, (,)y Float, proviene del Preludey es probable que esté dentro del alcance en todas partes.

Su expectativa es que Numse define en Prelude, y las tuplas y Floatse definen en Prelude, por lo que todo sobre cómo funcionan esas tres cosas se define en Prelude. ¿Por qué importar un módulo completamente diferente cambiaría algo? Idealmente no lo haría, pero las instancias huérfanas rompen esa intuición.

(Tenga en cuenta que GHC advierte sobre instancias huérfanas; los autores de glossespecíficamente anularon esa advertencia. Eso debería haber levantado una bandera roja y haber provocado al menos una advertencia en la documentación).

Las instancias de clase son globales y no se pueden ocultar

Además, las instancias de clase son globales : cualquier instancia definida en cualquier módulo que se importe transitivamente desde su módulo estará en contexto y disponible para el verificador de tipos cuando realice la resolución de instancia. Esto hace que el razonamiento global sea conveniente, porque podemos (normalmente) asumir que una función de clase como (+)siempre será la misma para un tipo dado. Sin embargo, también significa que las decisiones locales tienen efectos globales; la definición de una instancia de clase cambia irrevocablemente el contexto del código descendente, sin forma de enmascararlo u ocultarlo detrás de los límites del módulo.

No puede utilizar listas de importación para evitar importar instancias . Del mismo modo, no puede evitar exportar instancias de los módulos que defina.

Esta es un área problemática y muy discutida del diseño del lenguaje Haskell. Hay una discusión fascinante sobre temas relacionados en este hilo de reddit . Vea, por ejemplo, el comentario de Edward Kmett sobre permitir el control de visibilidad para casos: "Básicamente, descarta la exactitud de casi todo el código que he escrito".

(Por cierto, como demostró esta respuesta , ¡ puede romper la suposición de instancia global en algunos aspectos utilizando instancias huérfanas!)

Qué hacer: para los implementadores de bibliotecas

Piense dos veces antes de implementar Num. No se puede evitar el fromIntegerproblema, no, la definición fromInteger = error "not implemented"no no hacerlo mejor. ¿Se confundirán o sorprenderán sus usuarios, o peor aún, nunca se darán cuenta, si se infiere accidentalmente que sus literales enteros tienen el tipo que está instanciando? ¿Proporcionar (*)y (+)eso es fundamental, especialmente si tienes que piratearlo?

Considere utilizar operadores aritméticos alternativos definidos en una biblioteca como la de Conal Elliott vector-space(para tipos de género *) o la de Edward Kmett linear(para tipos de género * -> *). Esto es lo que suelo hacer yo mismo.

Utilice -Wall. No implemente instancias huérfanas y no deshabilite la advertencia de instancia huérfana.

Alternativamente, siga el ejemplo de lineary muchas otras bibliotecas con buen comportamiento, y proporcione instancias huérfanas en un módulo separado que termine en .OrphanInstanceso .Instances. Y no importe ese módulo desde ningún otro módulo . Luego, los usuarios pueden importar los huérfanos explícitamente si lo desean.

Si se encuentra definiendo a los huérfanos, considere pedirles a los mantenedores ascendentes que los implementen en su lugar, si es posible y apropiado. Solía ​​escribir con frecuencia la instancia huérfana Show a => Show (Identity a), hasta que la agregaron a transformers. Puede que incluso haya presentado un informe de error al respecto; No recuerdo

Qué hacer para los consumidores de bibliotecas

No tienes muchas opciones. Comuníquese, de manera educada y constructiva, con los encargados de la biblioteca. Indíqueles esta pregunta. Es posible que hayan tenido alguna razón especial para escribir sobre el huérfano problemático, o es posible que simplemente no se den cuenta.

En términos más generales: tenga en cuenta esta posibilidad. Esta es una de las pocas áreas de Haskell donde hay verdaderos efectos globales; tendría que verificar que cada módulo que importe, y cada módulo que importen esos módulos, no implemente instancias huérfanas. Las anotaciones de tipo a veces pueden alertarlo sobre problemas y, por supuesto, puede usarlas :ien GHCi para verificar.

Defina sus propios newtypes en lugar de typesinónimos si es lo suficientemente importante. Puede estar bastante seguro de que nadie se meterá con ellos.

Si tiene problemas frecuentes derivados de una biblioteca de código abierto, por supuesto, puede crear su propia versión de la biblioteca, pero el mantenimiento puede convertirse rápidamente en un dolor de cabeza.

Christian Conkle
fuente