Il controllo del tipo consente una sostituzione del tipo molto sbagliata e il programma viene comunque compilato


99

Durante il tentativo di eseguire il debug di un problema nel mio programma (2 cerchi con un raggio uguale vengono disegnati in dimensioni diverse utilizzando Gloss *), mi sono imbattuto in una strana situazione. Nel mio file che gestisce gli oggetti, ho la seguente definizione per a Player:

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

e nel mio file principale, che importa Objects.hs, ho la seguente definizione:

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

Ciò è accaduto perché aggiungevo e cambiavo i campi per il giocatore e dimenticavo di aggiornare startPlayerdopo (le sue dimensioni erano determinate da un singolo numero per rappresentare un raggio, ma l'ho cambiato in un Coordper rappresentare (larghezza, altezza); nel caso in cui io abbia mai fatto il giocatore obietta un non cerchio).

La cosa sorprendente è che il codice sopra si compila e viene eseguito, nonostante il secondo campo sia del tipo sbagliato.

All'inizio ho pensato che forse avevo diverse versioni dei file aperte, ma qualsiasi modifica a qualsiasi file si rifletteva nel programma compilato.

Poi ho pensato che forse startPlayernon veniva usato per qualche motivo. Commentare startPlayerrestituisce tuttavia un errore del compilatore e, ancora più strano, la modifica di 10in startPlayerprovoca una risposta appropriata (modifica la dimensione iniziale di Player); di nuovo, nonostante sia del tipo sbagliato. Per assicurarmi che legga correttamente la definizione dei dati, ho inserito un errore di battitura nel file e mi ha dato un errore; quindi sto guardando il file corretto.

Ho provato a incollare i 2 frammenti di cui sopra nel loro file e ha sputato l'errore previsto che il secondo campo di Playerin startPlayernon è corretto.

Cosa potrebbe permettere che ciò accada? Penseresti che questa sia proprio la cosa che il controllo del tipo di Haskell dovrebbe impedire.


* La risposta al mio problema originale, due cerchi di raggio apparentemente uguale disegnati a dimensioni diverse, era che uno dei raggi era effettivamente negativo.


26
Come ha notato @Cubic, dovresti assolutamente segnalare questo problema ai manutentori di Gloss. La tua domanda illustra bene come un'istanza orfana impropria di una libreria abbia incasinato il tuo codice.
Christian Conkle

1
Fatto. È possibile escludere istanze? Potrebbero richiederlo per il funzionamento della libreria, ma non ne ho bisogno. Ho anche notato che hanno definito Num Color. È solo questione di tempo prima che mi impatta.
Carcigenicate

@Cubic Beh, troppo tardi. E l'ho scaricato solo una settimana fa o giù di lì utilizzando un Cabal aggiornato e aggiornato; quindi dovrebbe essere attuale.
Carcigenicate

2
@ChristianConkle C'è una possibilità che l'autore di gloss non abbia capito cosa fa TypeSynonymInstances. In ogni caso, questo deve davvero scomparire (creare Pointun newtypeo utilizzare altri nomi di operatore ala linear)
cubo

1
@Cubic: TypeSynonymInstances non è poi così male di per sé (sebbene non del tutto innocuo), ma quando lo combini con OverlappingInstances le cose diventano molto divertenti.
John L

Risposte:


128

L'unico modo in cui questo potrebbe essere compilato è se esiste Num (Float,Float)un'istanza. Questo non è fornito dalla libreria standard, anche se è possibile che una delle librerie che stai utilizzando lo abbia aggiunto per qualche folle motivo. Prova a caricare il tuo progetto in ghci e vedi se 10 :: (Float,Float)funziona, poi prova :i Numa scoprire da dove proviene l'istanza e poi urla a chi l'ha definita.

Addendum: non è possibile disattivare le istanze. Non c'è nemmeno un modo per non esportarli da un modulo. Se ciò fosse possibile, porterebbe a codice ancora più confuso. L'unica vera soluzione qui è non definire istanze del genere.


53
WOW. 10 :: (Float, Float)restituisce (10.0,10.0)e :i Numcontiene la linea instance Num Point -- Defined in ‘Graphics.Gloss.Data.Point’( Pointè l'alias di Gloss di Coord). Sul serio? Grazie. Questo mi ha salvato da una notte insonne.
Carcigenicate

6
@Carcigenicate Anche se sembra futile consentire tali istanze, il motivo per cui è consentito è che gli sviluppatori possano scrivere le proprie istanze di Numdove ha senso, come un Angletipo di dati che vincola un Doubletra -pie pi, o se qualcuno volesse scrivere un tipo di dati rappresentando quaternioni o qualche altro tipo numerico più complesso questa caratteristica è molto comoda. Segue anche le stesse regole di String/ Text/ ByteString, consentendo queste istanze ha senso dal punto di vista della facilità d'uso, ma può essere utilizzato in modo improprio come in questo caso.
bheklilr

4
@bheklilr Capisco la necessità di consentire istanze di Num. Il "WOW" derivava da poche cose. Non sapevo che potessi creare istanze di alias di tipo, creare un'istanza Num di una Coord sembra controintuitivo e che non ci ho pensato. Oh beh, lezione imparata.
Carcigenicate

3
Puoi aggirare il tuo problema con l'istanza orfana dalla tua libreria usando una newtypedichiarazione per Coordinvece di un file type.
Benjamin Hodgson

3
@Carcigenicate Credo che tu abbia bisogno di -XTypeSynonymInstances per consentire istanze per sinonimi di tipo, ma non è necessario per creare l'istanza problematica. Un'istanza per Num (Float, Float)o addirittura (Floating a) => Num (a,a)non richiederebbe l'estensione ma produrrebbe lo stesso comportamento.
crockeea

64

Il controllo del tipo di Haskell è ragionevole. Il problema è che gli autori di una libreria che stai utilizzando hanno fatto qualcosa di ... meno ragionevole.

La risposta breve è: Sì, 10 :: (Float, Float)è perfettamente valido se c'è un'istanza Num (Float, Float). Non c'è niente di "molto sbagliato" in questo dal punto di vista del compilatore o del linguaggio. Semplicemente non coincide con la nostra intuizione su cosa fanno i letterali numerici. Dato che sei abituato al sistema di caratteri che rileva il tipo di errore che hai fatto, sei giustamente sorpreso e deluso!

Numistanze e il fromIntegerproblema

Sei sorpreso che il compilatore accetti 10 :: Coord, ad es 10 :: (Float, Float). È ragionevole presumere che i letterali numerici come 10verranno dedotti per avere tipi "numerici". Fuori dalla scatola, letterali numerici possono essere interpretati come Int, Integer, Float, o Double. Una tupla di numeri, senza altro contesto, non sembra un numero nel modo in cui quei quattro tipi sono numeri. Non stiamo parlando Complex.

Fortunatamente o sfortunatamente, Haskell è un linguaggio molto flessibile. Lo standard specifica che un intero letterale like 10verrà interpretato come fromInteger 10, che ha tipo Num a => a. Quindi 10potrebbe essere dedotto come qualsiasi tipo per cui è stata Numscritta un'istanza. Lo spiego un po 'più in dettaglio in un'altra risposta .

Quindi, quando hai pubblicato la tua domanda, un Haskeller esperto ha immediatamente notato che per 10 :: (Float, Float)essere accettata, deve esserci un'istanza come Num a => Num (a, a)o Num (Float, Float). Non esiste un'istanza del genere in Prelude, quindi deve essere stata definita da qualche altra parte. Utilizzando :i Num, hai subito individuato da dove proveniva: il glosspacchetto.

Digita sinonimi e istanze orfane

Ma aspetta un minuto. Non stai usando alcun glosstipo in questo esempio; perché l'istanza glossti ha colpito? La risposta arriva in due fasi.

Innanzitutto, un sinonimo di tipo introdotto con la parola chiave typenon crea un nuovo tipo . Nel tuo modulo, la scrittura Coordè semplicemente una scorciatoia per (Float, Float). Allo stesso modo in Graphics.Gloss.Data.Point, Pointsignifica (Float, Float). In altre parole, i tuoi Coorde gloss's Pointsono letteralmente equivalenti.

Quindi, quando i glossmanutentori hanno scelto di scrivere instance Num Point where ..., hanno anche reso il tuo Coordtipo un'istanza di Num. È equivalente a instance Num (Float, Float) where ...o instance Num Coord where ....

(Per impostazione predefinita, Haskell non consente ai sinonimi di tipo di essere istanze di classe. Gli glossautori dovevano abilitare una coppia di estensioni di lingua TypeSynonymInstancese FlexibleInstances, per scrivere l'istanza.)

Secondo, questo è sorprendente perché è un'istanza orfana , cioè una dichiarazione di istanza in instance C Acui entrambi Ce Asono definiti in altri moduli. Qui è particolarmente insidioso perché ogni parte coinvolta, vale a dire Num, (,)e Float, proviene dalla Preludeed è probabile che sia portata in tutto il mondo.

La tua aspettativa è che Numsia definita in Prelude, e le tuple e Floatsiano definite in Prelude, quindi tutto su come funzionano queste tre cose è definito in Prelude. Perché importare un modulo completamente diverso cambierebbe qualcosa? Idealmente non sarebbe così, ma le istanze orfane rompono quell'intuizione.

(Si noti che GHC mette in guardia sulle istanze orfane: gli autori di glosstale avviso hanno specificamente ignorato. Ciò avrebbe dovuto sollevare una bandiera rossa e richiedere almeno un avviso nella documentazione.)

Le istanze di classe sono globali e non possono essere nascoste

Inoltre, le istanze di classe sono globali : qualsiasi istanza definita in qualsiasi modulo importato in modo transitivo dal tuo modulo sarà nel contesto e disponibile per il typechecker durante la risoluzione dell'istanza. Questo rende conveniente il ragionamento globale, perché possiamo (solitamente) presumere che una funzione di classe come (+)sarà sempre la stessa per un dato tipo. Tuttavia, significa anche che le decisioni locali hanno effetti globali; la definizione di un'istanza di classe cambia irrevocabilmente il contesto del codice a valle, senza alcun modo per mascherarlo o nasconderlo dietro i confini del modulo.

Non è possibile utilizzare elenchi di importazione per evitare di importare istanze . Allo stesso modo, non puoi evitare di esportare istanze dai moduli che definisci.

Questa è un'area problematica e molto discussa del design del linguaggio Haskell. C'è un'affascinante discussione sui problemi correlati in questo thread reddit . Vedi, ad esempio, il commento di Edward Kmett sul consentire il controllo della visibilità per le istanze: "Fondamentalmente butti via la correttezza di quasi tutto il codice che ho scritto".

(A proposito, come ha dimostrato questa risposta , puoi infrangere l'ipotesi di istanza globale in alcuni aspetti usando istanze orfane!)

Cosa fare per gli implementatori di librerie

Pensaci due volte prima di implementare Num. Non puoi aggirare il fromIntegerproblema: no, definire fromInteger = error "not implemented"non lo rende migliore. I tuoi utenti saranno confusi o sorpresi, o peggio, non se ne accorgeranno mai se si deduce accidentalmente che i loro interi letterali abbiano il tipo che stai creando? Fornire (*)e (+)questo è fondamentale, soprattutto se devi hackerarlo?

Considera l'utilizzo di operatori aritmetici alternativi definiti in una libreria come quella di Conal Elliott vector-space(per i tipi di genere *) o di Edward Kmett linear(per i tipi di genere * -> *). Questo è quello che tendo a fare da solo.

Usa -Wall. Non implementare istanze orfane e non disabilitare l'avviso di istanza orfana.

In alternativa, segui l'esempio di linearmolte altre librerie ben educate e fornisci istanze orfane in un modulo separato che termina con .OrphanInstanceso .Instances. E non importare quel modulo da nessun altro modulo . Quindi gli utenti possono importare gli orfani in modo esplicito, se lo desiderano.

Se ti ritrovi a definire gli orfani, considera di chiedere ai manutentori a monte di implementarli invece, se possibile e appropriato. Scrivevo spesso l'istanza orfana Show a => Show (Identity a), finché non l'hanno aggiunta a transformers. Potrei anche aver sollevato una segnalazione di bug a riguardo; Non ricordo

Cosa fare per i consumatori di biblioteche

Non hai molte opzioni. Contatta - educatamente e costruttivamente! - i gestori della biblioteca. Indica loro questa domanda. Potrebbero aver avuto qualche motivo speciale per scrivere l'orfano problematico, oppure potrebbero semplicemente non rendersene conto.

Più in generale: essere consapevoli di questa possibilità. Questa è una delle poche aree di Haskell dove ci sono veri effetti globali; dovresti controllare che ogni modulo che importi, e ogni modulo importato da quei moduli, non implementa istanze orfane. Le annotazioni di tipo a volte possono :iavvisarti di problemi e ovviamente puoi usarle in GHCi per controllare.

Definisci i tuoi messaggi newtypeinvece dei typesinonimi, se è abbastanza importante. Puoi essere abbastanza sicuro che nessuno li rovinerà.

Se hai frequenti problemi derivanti da una libreria open source, puoi ovviamente creare la tua versione della libreria, ma la manutenzione può diventare rapidamente un mal di testa.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.