Moduł sprawdzania typów pozwala na bardzo złą zamianę typu, a program nadal się kompiluje

99

Podczas próby debugowania problemu w moim programie (2 okręgi o równym promieniu są rysowane w różnych rozmiarach za pomocą Gloss *), natknąłem się na dziwną sytuację. W moim pliku, który obsługuje obiekty, mam następującą definicję Player:

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

aw moim głównym pliku, który importuje Objects.hs, mam następującą definicję:

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

Stało się to przez to, że dodałem i zmieniłem pola dla gracza i zapomniałem zaktualizować startPlayerpo (jego wymiary zostały określone przez jedną liczbę reprezentującą promień, ale zmieniłem to na a, Coordaby reprezentować (szerokość, wysokość); na wypadek, gdyby kiedykolwiek zrobiłem obiekt gracza nie jest kołem).

Niesamowite jest to, że powyższy kod kompiluje się i działa, mimo że drugie pole jest niewłaściwego typu.

Najpierw pomyślałem, że być może mam otwarte różne wersje plików, ale wszelkie zmiany w plikach zostały odzwierciedlone w skompilowanym programie.

Następnie pomyślałem, że może startPlayerz jakiegoś powodu nie był używany. Komentowanie startPlayerpowoduje jednak błąd kompilatora, a co dziwniejsze, zmiana 10in startPlayerpowoduje odpowiednią odpowiedź (zmienia rozmiar początkowy elementu Player); znowu, mimo że jest niewłaściwego typu. Aby upewnić się, że poprawnie odczytuje definicję danych, wstawiłem do pliku literówkę i wystąpił błąd; więc patrzę na właściwy plik.

Próbowałem wklejanie fragmentów powyżej 2 do własnego pliku i go wypluł oczekiwany błąd, że drugie pole Playerw startPlayerjest nieprawidłowy.

Co mogłoby na to pozwolić? Można by pomyśleć, że jest to dokładnie to, co powinno zapobiegać sprawdzaniu typów przez Haskella.


* Odpowiedzią na mój pierwotny problem, polegającą na narysowaniu dwóch okręgów o rzekomo równym promieniu w różnych rozmiarach, było to, że jeden z promieni był w rzeczywistości ujemny.

Carcigenicate
źródło
26
Jak zauważył @Cubic, zdecydowanie powinieneś zgłosić ten problem opiekunom Glossa. Twoje pytanie ładnie ilustruje, w jaki sposób niewłaściwa osierocona instancja biblioteki zepsuła Twój kod.
Christian Conkle,
1
Gotowe. Czy można wykluczyć instancje? Mogą tego wymagać, aby biblioteka działała, ale ja tego nie potrzebuję. Zauważyłem również, że zdefiniowali Num Color. To tylko kwestia czasu, zanim mnie to złapie.
Carcigenicate
@Cubic Cóż, za późno. Pobrałem go dopiero tydzień temu, używając zaktualizowanej, aktualnej wersji Cabal; więc powinno być aktualne.
Carcigenicate
2
@ChristianConkle Jest szansa, że ​​autor glosy nie rozumiał, czym zajmuje się TypeSynonymInstances. W każdym razie to naprawdę musi zniknąć (albo utwórz Pointa newtypelub użyj innych nazw operatorów ala linear)
Cubic
1
@Cubic: TypeSynonymInstances nie jest sama w sobie taka zła (chociaż nie jest całkowicie nieszkodliwa), ale kiedy połączysz ją z OverlappingInstances, robi się bardzo fajnie.
John L

Odpowiedzi:

128

Jedynym sposobem, w jaki mogłoby to się skompilować, jest istnienie Num (Float,Float)instancji. Nie zapewnia tego biblioteka standardowa, chociaż możliwe jest, że jedna z używanych bibliotek dodała ją z jakiegoś szalonego powodu. Spróbuj załadować swój projekt w ghci i sprawdź, czy 10 :: (Float,Float)działa, a następnie spróbuj :i Numdowiedzieć się, skąd pochodzi instancja, a następnie krzycz na osobę, która ją zdefiniowała.

Dodatek: nie ma możliwości wyłączenia instancji. Nie ma nawet sposobu, aby nie wyeksportować ich z modułu. Gdyby to było możliwe, doprowadziłoby to do jeszcze bardziej zagmatwanego kodu. Jedynym prawdziwym rozwiązaniem jest nie definiowanie takich instancji.

Sześcienny
źródło
53
ŁAŁ. 10 :: (Float, Float)daje (10.0,10.0)i :i Numzawiera linię instance Num Point -- Defined in ‘Graphics.Gloss.Data.Point’( Pointjest aliasem Glossa coord). Poważnie? Dziękuję Ci. To uratowało mnie przed bezsenną nocą.
Carcigenicate
6
@Carcigenicate Chociaż zezwalanie na takie instancje wydaje się niepoważne, powodem, dla którego jest to dozwolone, jest to, że programiści mogą pisać własne wystąpienia, w Numktórych ma to sens, na przykład Angletyp danych, który ogranicza Doublemiędzy -pii pi, lub jeśli ktoś chciałby napisać typ danych reprezentowanie kwaternionów lub innego bardziej złożonego typu numerycznego ta funkcja jest bardzo wygodna. Podąża również za tymi samymi regułami, co String/ Text/ ByteString, zezwalanie na te wystąpienia ma sens z punktu widzenia łatwości użycia, ale może być nadużywane, jak w tym przypadku.
bheklilr
4
@bheklilr Rozumiem potrzebę zezwalania na wystąpienia Num. „WOW” wynikało z kilku powodów. Nie wiedziałem, że możesz tworzyć instancje aliasów typów, tworzenie instancji Num w Coord wydaje się po prostu sprzeczne z intuicją i że nie pomyślałem o tym. No cóż, wyciągnięta lekcja.
Carcigenicate
3
Możesz obejść swój problem z osieroconym wystąpieniem z biblioteki, używając newtypedeklaracji dla Coordzamiast pliku type.
Benjamin Hodgson
3
@Carcigenicate Uważam, że potrzebujesz -XTypeSynonymInstances, aby zezwolić na wystąpienia dla synonimów typów, ale nie jest to konieczne, aby utworzyć problematyczną instancję. Instancja dla Num (Float, Float)lub nawet (Floating a) => Num (a,a)nie wymagałaby rozszerzenia, ale spowodowałaby takie samo zachowanie.
crockeea
64

Sprawdzanie typów przez Haskella jest rozsądne. Problem w tym, że autorzy biblioteki, z której korzystasz, zrobili coś ... mniej rozsądnego.

Krótka odpowiedź brzmi: tak, 10 :: (Float, Float)jest całkowicie poprawna, jeśli istnieje instancja Num (Float, Float). Nie ma w tym nic „bardzo złego” z punktu widzenia kompilatora lub języka. Po prostu nie zgadza się to z naszą intuicją dotyczącą literałów numerycznych. Ponieważ jesteś przyzwyczajony do tego, że system typów wyłapuje rodzaj popełnionego błędu, słusznie jesteś zaskoczony i rozczarowany!

Numprzypadki i fromIntegerproblem

Dziwisz się, że kompilator akceptuje 10 :: Coord, tj 10 :: (Float, Float). Rozsądnie jest założyć, że literały numeryczne, takie jak, 10będą miały typy „numeryczne”. Po wyjęciu z pudełka, literały liczbowe można interpretować jako Int, Integer, Float, lub Double. Krotka liczb bez innego kontekstu nie wygląda na liczbę w taki sposób, w jaki te cztery typy są liczbami. Nie rozmawiamy o Complex.

Na szczęście lub niestety, Haskell jest językiem bardzo elastycznym. Standard określa, że ​​literał liczby całkowitej, taki jak, 10będzie interpretowany jako fromInteger 10, który ma typ Num a => a. 10Można więc wywnioskować, że każdy typ, dla którego została Numnapisana instancja. Wyjaśnię to nieco bardziej szczegółowo w innej odpowiedzi .

Kiedy więc opublikowałeś swoje pytanie, doświadczony Haskeller natychmiast zauważył, że 10 :: (Float, Float)aby zostać zaakceptowanym, musi istnieć instancja taka jak Num a => Num (a, a)lub Num (Float, Float). Nie ma takiej instancji w programie Prelude, więc musiała zostać zdefiniowana gdzie indziej. Używając :i Num, szybko zorientowałeś się, skąd pochodzi: glosspaczka.

Wpisz synonimy i przypadki osierocone

Ale poczekaj chwilę. W glosstym przykładzie nie używasz żadnych typów; dlaczego ta instancja glosswpłynęła na Ciebie? Odpowiedź jest w dwóch krokach.

Po pierwsze, synonim typu wprowadzony za pomocą słowa kluczowego typenie tworzy nowego typu . W twoim module pisanie Coordjest po prostu skrótem (Float, Float). Podobnie w Graphics.Gloss.Data.Point, Pointoznacza (Float, Float). Innymi słowy, Coordand gloss„s Pointdosłownie równoważne.

Więc kiedy glossopiekunowie zdecydowali się napisać instance Num Point where ..., również uczynili twój Coordtyp instancją Num. To jest równoważne instance Num (Float, Float) where ...lub instance Num Coord where ....

(Domyślnie Haskell nie pozwala, aby synonimy typów były instancjami klas. glossAutorzy musieli włączyć parę rozszerzeń językowych TypeSynonymInstancesi FlexibleInstancesnapisać instancję).

Po drugie, jest to zaskakujące, ponieważ jest to instancja osierocona , tj. Deklaracja instancji, w instance C Aktórej oba Ci Asą zdefiniowane w innych modułach. Tutaj jest to szczególnie podstępne, ponieważ każda z zaangażowanych części, tj. Num, (,)I Float, pochodzi z Preludei prawdopodobnie będzie objęta zakresem wszędzie.

Twoje oczekiwanie jest Numzdefiniowane w Prelude, krotki i Floatsą zdefiniowane w Prelude, więc wszystko, co dotyczy tych trzech rzeczy, jest zdefiniowane w Prelude. Dlaczego import zupełnie innego modułu miałby cokolwiek zmienić? Idealnie by tak nie było, ale przypadki osierocone łamią tę intuicję.

(Zwróć uwagę, że GHC ostrzega przed osieroconymi instancjami - autorzy glossszczególnie zignorowali to ostrzeżenie. Powinno to podnieść czerwoną flagę i przynajmniej ostrzeżenie w dokumentacji).

Instancje klas są globalne i nie można ich ukryć

Ponadto instancje klas są globalne : każdy przypadek określony w dowolnym module, który jest przechodni importowanego z Twojego modułu będzie w kontekście i dostępny do typechecker robiąc rozdzielczość instancji. To sprawia, że ​​rozumowanie globalne jest wygodne, ponieważ możemy (zwykle) założyć, że funkcja klasy taka jak (+)zawsze będzie taka sama dla danego typu. Jednak oznacza to również, że lokalne decyzje mają skutki globalne; zdefiniowanie instancji klasy nieodwołalnie zmienia kontekst dalszego kodu, bez możliwości zamaskowania lub ukrycia go za granicami modułu.

Nie można używać list importu, aby uniknąć importowania instancji . Podobnie nie można uniknąć eksportowania wystąpień ze zdefiniowanych modułów.

Jest to problematyczny i często dyskutowany obszar projektowania języka Haskell. W tym wątku reddit znajduje się fascynująca dyskusja na temat powiązanych problemów . Zobacz na przykład komentarz Edwarda Kmetta na temat zezwolenia na kontrolę widoczności instancji: „W zasadzie odrzucasz poprawność prawie całego kodu, który napisałem”.

(Nawiasem mówiąc, jak wykazała ta odpowiedź , można pod pewnymi względami złamać założenie instancji globalnej, używając instancji osieroconych!)

Co robić - dla osób wdrażających biblioteki

Pomyśl dwa razy przed wdrożeniem Num. Nie możesz obejść fromIntegerproblemu - nie, zdefiniowanie fromInteger = error "not implemented"go nie poprawia. Czy Twoi użytkownicy będą zdezorientowani lub zaskoczeni - lub, co gorsza, nigdy nie zauważą - jeśli ich literały liczb całkowitych zostaną przypadkowo wywnioskowane jako typ, którego instancję tworzysz? Czy zapewnienie (*)i (+)to krytyczne - szczególnie jeśli musisz je zhakować?

Rozważ użycie alternatywnych operatorów arytmetycznych zdefiniowanych w bibliotece, takich jak Conal Elliott vector-space(dla typów rodzajów *) lub Edward Kmett linear(dla typów rodzajów * -> *). To właśnie robię sam.

Użyj -Wall. Nie implementuj osieroconych instancji i nie wyłączaj ostrzeżenia o osieroconych instancjach.

Alternatywnie, podążaj za przykładem lineari wieloma innymi dobrze wychowanymi bibliotekami i udostępniaj osierocone instancje w oddzielnym module kończącym się na .OrphanInstanceslub .Instances. I nie importuj tego modułu z żadnego innego modułu . Następnie użytkownicy mogą jawnie importować sieroty, jeśli chcą.

Jeśli stwierdzisz, że definiujesz sieroty, rozważ poproszenie zewnętrznych opiekunów o ich implementację, jeśli to możliwe i właściwe. Często pisałem instancję osieroconą Show a => Show (Identity a), dopóki jej nie dodali transformers. Mogłem nawet zgłosić błąd w tej sprawie; Nie pamiętam.

Co robić - dla konsumentów bibliotecznych

Nie masz wielu opcji. Dotrzyj - uprzejmie i konstruktywnie! - do opiekunów biblioteki. Wskaż im to pytanie. Mogli mieć jakiś szczególny powód, by napisać problematyczną sierotę, albo po prostu nie zdają sobie z tego sprawy.

Szerzej: bądź świadomy tej możliwości. Jest to jeden z niewielu obszarów Haskell, w którym istnieją prawdziwe globalne skutki; musiałbyś sprawdzić, czy każdy moduł, który importujesz, i każdy moduł, który te moduły importują, nie implementuje instancji osieroconych. Adnotacje typu mogą czasami ostrzegać o problemach i oczywiście możesz użyć :iw GHCi, aby to sprawdzić.

Zdefiniuj własne newtypezamiast typesynonimów, jeśli jest to wystarczająco ważne. Możesz być całkiem pewien, że nikt z nimi nie zadziera.

Jeśli często masz problemy z pobieraniem z biblioteki open source, możesz oczywiście stworzyć własną wersję biblioteki, ale konserwacja może szybko stać się bólem głowy.

Christian Conkle
źródło