O verificador de tipo está permitindo uma substituição de tipo muito errada, e o programa ainda compila

99

Ao tentar depurar um problema em meu programa (2 círculos com raio igual estão sendo desenhados em tamanhos diferentes usando o Gloss *), me deparei com uma situação estranha. Em meu arquivo que manipula objetos, tenho a seguinte definição para um Player:

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

e em meu arquivo principal, que importa Objects.hs, tenho a seguinte definição:

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

Isso aconteceu porque eu adicionei e alterei campos para o jogador e esqueci de atualizar startPlayerdepois (suas dimensões foram determinadas por um único número para representar um raio, mas eu mudei para um Coordpara representar (largura, altura); caso eu algum dia faça o jogador objetar um não-círculo).

O incrível é que o código acima é compilado e executado, apesar do segundo campo ser do tipo errado.

A princípio pensei que talvez houvesse versões diferentes dos arquivos abertos, mas qualquer alteração em qualquer arquivo se refletia no programa compilado.

Em seguida, pensei que talvez startPlayernão estivesse sendo usado por algum motivo. startPlayerPorém, comentar produz um erro do compilador, e ainda mais estranho, alterar o 10in startPlayercausa uma resposta apropriada (altera o tamanho inicial do Player); novamente, apesar de ser do tipo errado. Para ter certeza de que ele está lendo a definição de dados corretamente, inseri um erro de digitação no arquivo e ocorreu um erro; então estou olhando para o arquivo correto.

Tentei colar as 2 trechos acima em seu próprio arquivo, e ele cuspiu o erro esperado que o segundo campo de Playerem startPlayerestá incorreto.

O que poderia permitir que isso acontecesse? Você pensaria que isso é exatamente o que o verificador de tipo de Haskell deve evitar.


* A resposta ao meu problema original, dois círculos de raio supostamente igual sendo desenhados em tamanhos diferentes, era que um dos raios era na verdade negativo.

Carcigenicar
fonte
26
Como @Cubic notou, você definitivamente deve relatar este problema aos mantenedores do Gloss. Sua pergunta ilustra bem como uma instância órfã imprópria de uma biblioteca bagunçou seu código.
Christian Conkle
1
Feito. É possível excluir instâncias? Eles podem exigir que a biblioteca funcione, mas eu não preciso disso. Também notei que eles definiram Num Color. É apenas uma questão de tempo antes que isso me atrapalhe.
Carcigenicar
@Cubic Bem, tarde demais. E eu só baixei uma semana ou mais atrás usando um Cabal atualizado, atualizado; então deve ser atual.
Carcigenicar em
2
@ChristianConkle Existe uma chance de o autor do gloss não entender o que TypeSynonymInstances faz. Em qualquer caso, isso realmente precisa desaparecer (faça Pointa newtypeou use outros nomes de operador ala linear)
Cúbico
1
@Cubic: TypeSynonymInstances não é tão ruim por si só (embora não seja completamente inofensivo), mas quando você o combina com OverlappingInstances, as coisas ficam muito divertidas.
John L

Respostas:

128

A única maneira de compilar isso é se houver uma Num (Float,Float)instância. Isso não é fornecido pela biblioteca padrão, embora seja possível que uma das bibliotecas que você está usando o tenha adicionado por algum motivo insano. Tente carregar seu projeto no ghci e ver se 10 :: (Float,Float)funciona, depois tente :i Numdescobrir de onde a instância está vindo e grite com quem a definiu.

Adendo: não há como desativar as instâncias. Não há nem uma maneira de não exportá-los de um módulo. Se isso fosse possível, levaria a um código ainda mais confuso. A única solução real aqui é não definir instâncias como essa.

Cúbico
fonte
53
UAU. 10 :: (Float, Float)produz (10.0,10.0)e :i Numcontém a linha instance Num Point -- Defined in ‘Graphics.Gloss.Data.Point’( Pointé o alias de Gloss para Coord). Seriamente? Obrigado. Isso me salvou de uma noite sem dormir.
Carcigenicar em
6
@Carcigenicate Embora pareça frívolo permitir tais instâncias, o motivo pelo qual é permitido é para que os desenvolvedores possam escrever suas próprias instâncias de Numonde faz sentido, como um Angletipo de dados que restringe Doubleentre -pie pi, ou se alguém quisesse escrever um tipo de dados representando quatérnions ou algum outro tipo numérico mais complexo, esse recurso é muito conveniente. Ele também segue as mesmas regras que String/ Text/ ByteString, permitir essas instâncias faz sentido do ponto de vista de facilidade de uso, mas pode ser mal utilizado como neste caso.
bheklilr
4
@bheklilr Eu entendo a necessidade de permitir instâncias de Num. O "WOW" resultou de algumas coisas. Eu não sabia que você podia criar instâncias de aliases de tipo, criar uma instância Num de um Coord simplesmente parece contra-intuitivo, e eu não pensei nisso. Bem, lição aprendida.
Carcigenicar em
3
Você pode contornar seu problema com a instância órfã de sua biblioteca usando uma newtypedeclaração para em Coordvez de a type.
Benjamin Hodgson
3
@Carcigenicate Eu acredito que você precisa de -XTypeSynonymInstances para permitir instâncias para sinônimos de tipo, mas isso não é necessário para fazer a instância problemática. Uma instância para Num (Float, Float)ou mesmo (Floating a) => Num (a,a)não exigiria a extensão, mas resultaria no mesmo comportamento.
crockeea
64

O verificador de tipo de Haskell está sendo razoável. O problema é que os autores de uma biblioteca que você está usando fizeram algo ... menos razoável.

A resposta breve é: Sim, 10 :: (Float, Float)é perfeitamente válido se houver uma instância Num (Float, Float). Não há nada de "muito errado" nisso da perspectiva do compilador ou da linguagem. Simplesmente não se enquadra em nossa intuição sobre o que os literais numéricos fazem. Como você está acostumado com o sistema de tipos que detecta o tipo de erro que você cometeu, você está justificadamente surpreso e desapontado!

Numinstâncias e o fromIntegerproblema

Você está surpreso que o compilador aceita 10 :: Coord, ou seja 10 :: (Float, Float). É razoável supor que literais numéricos como 10serão inferidos como tendo tipos "numéricos". Fora da caixa, literais numéricos pode ser interpretado como Int, Integer, Float, ou Double. Uma tupla de números, sem nenhum outro contexto, não parece um número da mesma forma que esses quatro tipos são números. Não estamos falando sobre Complex.

Feliz ou infelizmente, porém, Haskell é uma linguagem muito flexível. O padrão especifica que um literal inteiro like 10será interpretado como fromInteger 10, que tem tipo Num a => a. Portanto, 10pode ser inferido como qualquer tipo que tenha uma Numinstância escrita para ele. Eu explico isso um pouco mais detalhadamente em outra resposta .

Então, quando você postou sua pergunta, um Haskeller experiente percebeu imediatamente que, para 10 :: (Float, Float)ser aceita, deve haver uma instância como Num a => Num (a, a)ou Num (Float, Float). Não existe tal instância no Prelude, então ela deve ter sido definida em outro lugar. Usando :i Num, você percebeu rapidamente de onde veio: o glosspacote.

Digite sinônimos e instâncias órfãs

mas espere um minuto. Você não está usando nenhum glosstipo neste exemplo; por que a instância glossafetou você? A resposta vem em duas etapas.

Primeiro, um sinônimo de tipo introduzido com a palavra-chave typenão cria um novo tipo . Em seu módulo, escrever Coordé simplesmente um atalho para (Float, Float). Da mesma forma em Graphics.Gloss.Data.Point, Pointsignifica (Float, Float). Em outras palavras, seus Coorde gloss's Pointsão, literalmente equivalente.

Portanto, quando os glossmantenedores decidiram escrever instance Num Point where ..., eles também transformaram seu Coordtipo em uma instância de Num. Isso é equivalente a instance Num (Float, Float) where ...ou instance Num Coord where ....

(Por padrão, Haskell não permite que sinônimos de tipo sejam instâncias de classe. Os glossautores tiveram que habilitar um par de extensões de linguagem, TypeSynonymInstancese FlexibleInstances, para escrever a instância.)

Em segundo lugar, isso é surpreendente porque é uma instância órfã , ou seja, uma declaração de instância instance C Aonde Ce Asão definidos em outros módulos. Aqui é particularmente insidiosa porque cada parte envolvida, ou seja Num, (,)e Float, vem do Preludee é provável que seja no escopo em todos os lugares.

Sua expectativa é que Numseja definido em Prelude, e tuplas e Floatsejam definidos em Prelude, então tudo sobre como essas três coisas funcionam é definido em Prelude. Por que importar um módulo completamente diferente mudaria alguma coisa? Idealmente, não seria, mas instâncias órfãs quebram essa intuição.

(Observe que o GHC avisa sobre instâncias órfãs - os autores de glosssubstituíram especificamente esse aviso. Isso deveria ter levantado uma bandeira vermelha e gerado pelo menos um aviso na documentação.)

As instâncias de classe são globais e não podem ser ocultadas

Além disso, as instâncias de classe são globais : qualquer instância definida em qualquer módulo que é importado transitivamente de seu módulo estará no contexto e disponível para o inspetor de tipos ao fazer a resolução da instância. Isso torna o raciocínio global conveniente, porque podemos (geralmente) assumir que uma função de classe como (+)será sempre a mesma para um determinado tipo. No entanto, também significa que as decisões locais têm efeitos globais; definir uma instância de classe muda irrevogavelmente o contexto do código downstream, sem nenhuma maneira de mascará-lo ou ocultá-lo atrás dos limites do módulo.

Você não pode usar listas de importação para evitar a importação de instâncias . Da mesma forma, você não pode evitar a exportação de instâncias de módulos que você define.

Esta é uma área problemática e muito discutida do design da linguagem Haskell. Há uma discussão fascinante de questões relacionadas neste tópico do reddit . Veja, por exemplo, o comentário de Edward Kmett sobre permitir o controle de visibilidade para instâncias: "Você basicamente descarta a exatidão de quase todo o código que escrevi."

(A propósito, como esta resposta demonstrou , você pode quebrar a suposição de instância global em alguns aspectos usando instâncias órfãs!)

O que fazer - para implementadores de biblioteca

Pense duas vezes antes de implementar Num. Você não pode contornar o fromIntegerproblema, não, definindo fromInteger = error "not implemented"que não torná-lo melhor. Seus usuários ficarão confusos ou surpresos - ou pior, nunca perceberão - se seus literais inteiros forem acidentalmente inferidos como tendo o tipo que você está instanciando? Fornecer (*)e (+)isso é crítico - especialmente se você tiver que hackear?

Considere o uso de operadores aritméticos alternativos definidos em uma biblioteca como Conal Elliott vector-space(para tipos de tipo *) ou Edward Kmett linear(para tipos de tipo * -> *). Isso é o que eu mesmo costumo fazer.

Use -Wall. Não implemente instâncias órfãs e não desative o aviso de instância órfã.

Como alternativa, siga o exemplo de lineare de muitas outras bibliotecas bem comportadas e forneça instâncias órfãs em um módulo separado terminando em .OrphanInstancesou .Instances. E não importe esse módulo de qualquer outro módulo . Então, os usuários podem importar os órfãos explicitamente, se quiserem.

Se você se descobrir definindo órfãos, considere pedir aos mantenedores originais para implementá-los, se possível e apropriado. Eu costumava escrever frequentemente a instância órfã Show a => Show (Identity a), até que a adicionassem transformers. Posso até ter levantado um relatório de bug sobre isso; Não me lembro.

O que fazer - para consumidores de bibliotecas

Você não tem muitas opções. Alcance - educada e construtivamente! - os mantenedores da biblioteca. Mostre-lhes esta questão. Eles podem ter tido algum motivo especial para escrever sobre o órfão problemático, ou podem simplesmente não perceber.

De forma mais ampla: esteja ciente dessa possibilidade. Esta é uma das poucas áreas de Haskell onde existem verdadeiros efeitos globais; você teria que verificar se cada módulo importado e cada módulo importado por esses módulos não implementa instâncias órfãs. As anotações de tipo podem, às vezes, alertá-lo sobre problemas e, claro, você pode usar :ino GHCi para verificar.

Defina seus próprios newtypes em vez de typesinônimos se for importante o suficiente. Você pode ter certeza de que ninguém vai mexer com eles.

Se você está tendo problemas frequentes derivados de uma biblioteca de código aberto, é claro que pode fazer sua própria versão da biblioteca, mas a manutenção pode rapidamente se tornar uma dor de cabeça.

Christian Conkle
fonte