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!
Num
istanze e il fromInteger
problema
Sei sorpreso che il compilatore accetti 10 :: Coord
, ad es 10 :: (Float, Float)
. È ragionevole presumere che i letterali numerici come 10
verranno 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 10
verrà interpretato come fromInteger 10
, che ha tipo Num a => a
. Quindi 10
potrebbe essere dedotto come qualsiasi tipo per cui è stata Num
scritta 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 gloss
pacchetto.
Digita sinonimi e istanze orfane
Ma aspetta un minuto. Non stai usando alcun gloss
tipo in questo esempio; perché l'istanza gloss
ti ha colpito? La risposta arriva in due fasi.
Innanzitutto, un sinonimo di tipo introdotto con la parola chiave type
non crea un nuovo tipo . Nel tuo modulo, la scrittura Coord
è semplicemente una scorciatoia per (Float, Float)
. Allo stesso modo in Graphics.Gloss.Data.Point
, Point
significa (Float, Float)
. In altre parole, i tuoi Coord
e gloss
's Point
sono letteralmente equivalenti.
Quindi, quando i gloss
manutentori hanno scelto di scrivere instance Num Point where ...
, hanno anche reso il tuo Coord
tipo 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 gloss
autori dovevano abilitare una coppia di estensioni di lingua TypeSynonymInstances
e FlexibleInstances
, per scrivere l'istanza.)
Secondo, questo è sorprendente perché è un'istanza orfana , cioè una dichiarazione di istanza in instance C A
cui entrambi C
e A
sono definiti in altri moduli. Qui è particolarmente insidioso perché ogni parte coinvolta, vale a dire Num
, (,)
e Float
, proviene dalla Prelude
ed è probabile che sia portata in tutto il mondo.
La tua aspettativa è che Num
sia definita in Prelude
, e le tuple e Float
siano 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 gloss
tale 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 fromInteger
problema: 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 linear
molte altre librerie ben educate e fornisci istanze orfane in un modulo separato che termina con .OrphanInstances
o .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 :i
avvisarti di problemi e ovviamente puoi usarle in GHCi per controllare.
Definisci i tuoi messaggi newtype
invece dei type
sinonimi, 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.