Le vérificateur de type autorise un remplacement de type très incorrect, et le programme compile toujours

99

En essayant de déboguer un problème dans mon programme (2 cercles avec un rayon égal sont dessinés à différentes tailles en utilisant Gloss *), je suis tombé sur une situation étrange. Dans mon fichier qui gère les objets, j'ai la définition suivante pour un Player:

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

et dans mon fichier principal, qui importe Objects.hs, j'ai la définition suivante:

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

Cela s'est produit parce que j'ai ajouté et modifié des champs pour le joueur, et startPlayerque j'ai oublié de mettre à jour après (ses dimensions étaient déterminées par un seul nombre pour représenter un rayon, mais je l'ai changé en un Coordpour représenter (largeur, hauteur); au cas où je ferais jamais le joueur objecte un non-cercle).

Ce qui est étonnant, c'est que le code ci-dessus se compile et s'exécute, bien que le deuxième champ soit du mauvais type.

J'ai d'abord pensé que j'avais peut-être différentes versions des fichiers ouvertes, mais toutes les modifications apportées aux fichiers étaient reflétées dans le programme compilé.

Ensuite, j'ai pensé que cela startPlayern'était peut-être pas utilisé pour une raison quelconque. La mise en commentaire startPlayerproduit une erreur de compilation, et même plus étrange, la modification de 10in startPlayerprovoque une réponse appropriée (change la taille de départ de Player); encore une fois, bien qu'il soit du mauvais type. Pour m'assurer qu'il lit correctement la définition des données, j'ai inséré une faute de frappe dans le fichier, et cela m'a donné une erreur; donc je regarde le bon fichier.

J'ai essayé de coller les 2 extraits ci-dessus dans leur propre fichier, et cela a craché l'erreur attendue que le deuxième champ de Playerin startPlayerest incorrect.

Qu'est-ce qui pourrait permettre que cela se produise? On pourrait penser que c'est précisément ce que le vérificateur de type de Haskell devrait empêcher.


* La réponse à mon problème initial, deux cercles de rayon supposé égal étant dessinés à des tailles différentes, était que l'un des rayons était en fait négatif.

Carcigénicate
la source
26
Comme @Cubic l'a noté, vous devez absolument signaler ce problème aux responsables de Gloss. Votre question illustre bien comment une instance orpheline incorrecte d'une bibliothèque a gâché votre code.
Christian Conkle
1
Terminé. Est-il possible d'exclure des instances? Ils peuvent en avoir besoin pour que la bibliothèque fonctionne, mais je n'en ai pas besoin. J'ai également remarqué qu'ils définissaient Num Color. Ce n'est qu'une question de temps avant que ça m'arrive.
Carcigenicate
@Cubic Eh bien, trop tard. Et je l'ai téléchargé il y a seulement une semaine environ en utilisant une Cabale mise à jour et à jour; il devrait donc être courant.
Carcigenicate
2
@ChristianConkle Il est possible que l'auteur de gloss n'ait pas compris ce que fait TypeSynonymInstances. Dans tous les cas, cela doit vraiment disparaître (soit faire Pointun newtypeou utiliser d'autres noms d'opérateurs ala linear)
Cubic
1
@Cubic: TypeSynonymInstances n'est pas si mal en soi (bien que pas complètement inoffensif), mais lorsque vous le combinez avec OverlappingInstances, les choses deviennent très amusantes.
John L

Réponses:

128

La seule façon dont cela pourrait éventuellement se compiler est s'il existe une Num (Float,Float)instance. Cela n'est pas fourni par la bibliothèque standard, bien qu'il soit possible que l'une des bibliothèques que vous utilisez l'ait ajoutée pour une raison insensée. Essayez de charger votre projet dans ghci et voyez si cela 10 :: (Float,Float)fonctionne, puis essayez :i Numde savoir d'où vient l'instance, puis criez à celui qui l'a définie.

Addendum: il n'y a aucun moyen de désactiver les instances. Il n'y a même pas moyen de ne pas les exporter à partir d'un module. Si cela était possible, cela conduirait à un code encore plus déroutant. La seule vraie solution ici est de ne pas définir de telles instances.

Cubique
la source
53
SENSATIONNEL. 10 :: (Float, Float)renvoie (10.0,10.0)et :i Numcontient la ligne instance Num Point -- Defined in ‘Graphics.Gloss.Data.Point’( Pointest l'alias de Gloss de Coord). Sérieusement? Je vous remercie. Cela m'a sauvé d'une nuit sans sommeil.
Carcigenicate
6
@Carcigenicate Bien qu'il semble frivole d'autoriser de telles instances, la raison pour laquelle cela est autorisé est que les développeurs puissent écrire leurs propres instances Numlà où cela a du sens, comme un Angletype de données qui contraint un Doubleentre -piet pi, ou si quelqu'un voulait écrire un type de données représentant des quaternions ou un autre type numérique plus complexe, cette fonction est très pratique. Il suit également les mêmes règles que String/ Text/ ByteString, autoriser ces instances a du sens du point de vue de la facilité d'utilisation, mais il peut être mal utilisé comme dans ce cas.
bheklilr
4
@bheklilr Je comprends la nécessité d'autoriser les instances de Num. Le "WOW" provenait de plusieurs choses. Je ne savais pas que vous pouviez créer des instances d'alias de type, créer une instance Num d'un Coord semble juste contre-intuitif, et je n'y ai pas pensé. Oh bien, leçon apprise.
Carcigenicate
3
Vous pouvez contourner votre problème avec l'instance orpheline de votre bibliothèque en utilisant une newtypedéclaration pour Coordau lieu d'un type.
Benjamin Hodgson
3
@Carcigenicate Je crois que vous avez besoin de -XTypeSynonymInstances pour autoriser les instances pour les synonymes de type, mais ce n'est pas nécessaire pour créer l'instance problématique. Une instance pour Num (Float, Float)ou même (Floating a) => Num (a,a)ne nécessiterait pas l'extension mais entraînerait le même comportement.
crockeea
64

Le vérificateur de type de Haskell est raisonnable. Le problème est que les auteurs d'une bibliothèque que vous utilisez ont fait quelque chose ... de moins raisonnable.

La réponse brève est: Oui, 10 :: (Float, Float)c'est parfaitement valable s'il y a une instance Num (Float, Float). Il n'y a rien de "très mal" à ce sujet du point de vue du compilateur ou du langage. Cela ne correspond tout simplement pas à notre intuition sur ce que font les littéraux numériques. Puisque vous êtes habitué au système de typage qui détecte le genre d'erreur que vous avez fait, vous êtes à juste titre surpris et déçu!

Numles instances et le fromIntegerproblème

Vous êtes surpris que le compilateur accepte 10 :: Coord, c'est à dire 10 :: (Float, Float). Il est raisonnable de supposer que les littéraux numériques comme 10seront déduits pour avoir des types "numériques". Hors de la boîte, les littéraux numériques peuvent être interprétées comme Int, Integer, Floatou Double. Un tuple de nombres, sans autre contexte, ne semble pas être un nombre au sens où ces quatre types sont des nombres. Nous ne parlons pas de Complex.

Heureusement ou malheureusement, Haskell est un langage très flexible. La norme spécifie qu'un entier littéral comme 10sera interprété comme fromInteger 10, qui a un type Num a => a. Ainsi 10pourrait être déduit comme n'importe quel type pour lequel une Numinstance a été écrite. J'explique cela un peu plus en détail dans une autre réponse .

Ainsi, lorsque vous avez posté votre question, un Haskeller expérimenté l'a immédiatement repérée pour 10 :: (Float, Float)être acceptée, il doit y avoir une instance comme Num a => Num (a, a)ou Num (Float, Float). Il n'y a pas d'exemple de ce genre dans le Prelude, donc il doit avoir été défini ailleurs. En utilisant :i Num, vous avez rapidement repéré d'où il venait: le glosspaquet.

Saisissez des synonymes et des instances orphelines

Mais attendez une minute. Vous n'utilisez aucun glosstype dans cet exemple; pourquoi l'instance en question glossvous a-t-elle affecté? La réponse se fait en deux étapes.

Premièrement, un synonyme de type introduit avec le mot type- clé ne crée pas de nouveau type . Dans votre module, l'écriture Coordest simplement un raccourci pour (Float, Float). De même dans Graphics.Gloss.Data.Point, Pointsignifie (Float, Float). En d' autres termes, vos Coordet gloss« s Pointsont littéralement équivalent.

Ainsi, lorsque les glossresponsables ont choisi d'écrire instance Num Point where ..., ils ont également fait de votre Coordtype une instance de Num. C'est équivalent à instance Num (Float, Float) where ...ou instance Num Coord where ....

(Par défaut, Haskell n'autorise pas les synonymes de type à être des instances de classe. Les glossauteurs devaient activer une paire d'extensions de langage TypeSynonymInstanceset FlexibleInstances, pour écrire l'instance.)

Deuxièmement, c'est surprenant car c'est une instance orpheline , c'est-à-dire une déclaration d'instance instance C Aoù les deux Cet Asont définis dans d'autres modules. Ici, c'est particulièrement insidieux car chaque partie impliquée, c'est-à-dire Num, (,)et Float, vient du Preludeet est susceptible d'être présente partout.

Vous vous attendez à ce que ce Numsoit défini dans Prelude, et les tuples et Floatsont définis dans Prelude, donc tout ce qui concerne le fonctionnement de ces trois éléments est défini dans Prelude. Pourquoi importer un module complètement différent changerait-il quelque chose? Idéalement, ce ne serait pas le cas, mais les instances orphelines brisent cette intuition.

(Notez que GHC met en garde contre les instances orphelines - les auteurs ont glossspécifiquement ignoré cet avertissement. Cela aurait dû déclencher un drapeau rouge et déclencher au moins un avertissement dans la documentation.)

Les instances de classe sont globales et ne peuvent pas être masquées

De plus, les instances de classe sont globales : toute instance définie dans un module importé de manière transitoire depuis votre module sera en contexte et disponible pour le vérificateur de type lors de la résolution d'instance. Cela rend le raisonnement global pratique, car nous pouvons (généralement) supposer qu'une fonction de classe comme (+)sera toujours la même pour un type donné. Cependant, cela signifie également que les décisions locales ont des effets globaux; la définition d'une instance de classe modifie irrévocablement le contexte du code en aval, sans aucun moyen de le masquer ou de le cacher derrière les limites du module.

Vous ne pouvez pas utiliser de listes d'importation pour éviter d'importer des instances . De même, vous ne pouvez pas éviter d'exporter des instances à partir des modules que vous définissez.

C'est un domaine problématique et très discuté de la conception du langage Haskell. Il y a une discussion fascinante sur les problèmes connexes dans ce fil de discussion reddit . Voir, par exemple, le commentaire d'Edward Kmett sur l'autorisation du contrôle de visibilité pour les instances: "Vous rejetez fondamentalement l'exactitude de presque tout le code que j'ai écrit."

(En passant, comme cette réponse l'a démontré , vous pouvez casser l'hypothèse d'instance globale à certains égards en utilisant des instances orphelines!)

Que faire - pour les développeurs de bibliothèques

Réfléchissez à deux fois avant de mettre en œuvre Num. Vous ne pouvez pas contourner le fromIntegerproblème - non, définir fromInteger = error "not implemented"ne l’ améliore pas . Vos utilisateurs seront-ils confus ou surpris - ou pire, ne le remarqueront jamais - si leurs littéraux entiers sont supposés accidentellement avoir le type que vous instanciez? Est-ce que fournir (*)et (+)que cela est essentiel - en particulier si vous devez le pirater?

Pensez à utiliser d'autres opérateurs arithmétiques définis dans une bibliothèque comme celle de Conal Elliott vector-space(pour les types de genre *) ou d'Edward Kmett linear(pour les types de genre * -> *). C'est ce que j'ai tendance à faire moi-même.

Utilisez -Wall. N'implémentez pas d'instances orphelines et ne désactivez pas l'avertissement d'instance orpheline.

Sinon, suivre l'exemple de linearet beaucoup d' autres bibliothèques, et fournir des instances orphelines se sont bien comportés dans un module séparé se terminant par .OrphanInstancesou .Instances. Et n'importez pas ce module à partir d'un autre module . Ensuite, les utilisateurs peuvent importer les orphelins explicitement s'ils le souhaitent.

Si vous vous trouvez en train de définir des orphelins, pensez à demander aux responsables en amont de les implémenter à la place, si possible et approprié. J'écrivais fréquemment l'instance orpheline Show a => Show (Identity a), jusqu'à ce qu'ils l'ajoutent transformers. J'ai peut-être même signalé un bug à ce sujet; Je ne m'en souviens pas.

Que faire - pour les utilisateurs de bibliothèques

Vous n'avez pas beaucoup d'options. Contactez - poliment et constructivement! - les responsables de la bibliothèque. Dirigez-les vers cette question. Ils peuvent avoir eu une raison particulière d'écrire l'orphelin problématique, ou ils peuvent simplement ne pas s'en rendre compte.

Plus largement: soyez conscient de cette possibilité. C'est l'une des rares régions de Haskell où il y a de véritables effets globaux; vous devez vérifier que chaque module que vous importez, et chaque module que ces modules importent, n'implémente pas d'instances orphelines. Les annotations de type peuvent parfois vous alerter en cas de problèmes, et vous pouvez bien sûr les utiliser :idans GHCi pour vérifier.

Définissez vos propres newtypes au lieu de typesynonymes si elle est assez important. Vous pouvez être sûr que personne ne les dérangera.

Si vous rencontrez fréquemment des problèmes liés à une bibliothèque open-source, vous pouvez bien sûr créer votre propre version de la bibliothèque, mais la maintenance peut rapidement devenir un casse-tête.

Christian Conkle
la source