Средство проверки типов допускает очень неправильную замену типа, а программа все еще компилируется

100

Пытаясь отладить проблему в моей программе (2 круга с одинаковым радиусом рисуются для разных размеров с помощью Gloss *), я наткнулся на странную ситуацию. В моем файле, который обрабатывает объекты, у меня есть следующее определение для Player:

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

и в моем основном файле, который импортирует Objects.hs, у меня есть следующее определение:

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

Это произошло из-за того, что я добавил и изменил поля для игрока и забыл обновить их startPlayerпосле (его размеры были определены одним числом для представления радиуса, но я изменил его на a Coordдля представления (ширина, высота); на случай, если я когда-нибудь сделаю игрок возражает против круга).

Удивительно то, что приведенный выше код компилируется и запускается, несмотря на то, что второе поле имеет неправильный тип.

Сначала я подумал, что, возможно, у меня были открыты разные версии файлов, но любые изменения в любых файлах отражались в скомпилированной программе.

Затем я подумал, что, возможно startPlayer, по какой-то причине не используется. Комментирование startPlayerприводит к ошибке компилятора, и, что еще более странно, изменение 10in startPlayerвызывает соответствующий ответ (изменяет начальный размер Player); опять же, несмотря на то, что он не того типа. Чтобы убедиться, что определение данных правильно читается, я вставил в файл опечатку, и это дало мне ошибку; поэтому я ищу правильный файл.

Я попытался вставить 2 приведенных выше фрагмента в их собственный файл, и он выплюнул ожидаемую ошибку, что второе поле Playerin startPlayerневерно.

Что могло позволить этому случиться? Можно подумать, что это как раз то, что должна предотвратить проверка типов в Haskell.


* Ответ на мою первоначальную проблему, когда два круга предположительно равного радиуса были нарисованы разного размера, заключался в том, что один из радиусов на самом деле был отрицательным.

Канцерогенат
источник
26
Как отметил @Cubic, вам обязательно следует сообщить об этой проблеме разработчикам Gloss. Ваш вопрос прекрасно иллюстрирует, как неподходящий сиротский экземпляр библиотеки испортил ваш код.
Christian Conkle 06
1
Готово. Можно ли исключить экземпляры? Они могут потребовать это для работы библиотеки, но мне это не нужно. Я также заметил, что они определили Num Color. Это только вопрос времени, когда это меня зацепит.
Carcigenicate
@Cubic Ну, слишком поздно. И я загрузил его всего неделю назад, используя обновленную версию Cabal; так что он должен быть актуальным.
Carcigenicate
2
@ChristianConkle Есть шанс, что автор глянца не понял, что делает TypeSynonymInstances. В любом случае, от этого действительно нужно избавиться (либо сделайте Pointa, newtypeлибо используйте другие имена операторов ala linear)
Cubic
1
@Cubic: TypeSynonymInstances не так уж и плох сам по себе (хотя и не совсем безобидный), но когда вы комбинируете его с OverlappingInstances, все становится очень весело.
John L

Ответы:

128

Единственный способ, которым это может быть скомпилировано, - это если существует Num (Float,Float)экземпляр. Это не предусмотрено стандартной библиотекой, хотя возможно, что одна из используемых вами библиотек добавила его по какой-то безумной причине. Попробуйте загрузить свой проект в ghci и посмотреть, 10 :: (Float,Float)работает ли он , затем попытайтесь :i Numвыяснить, откуда исходит экземпляр, а затем кричите на того, кто его определил.

Приложение: Нет возможности отключить инстансы. Нет даже способа не экспортировать их из модуля. Если бы это было возможно, это привело бы к еще более запутанному коду. Единственное реальное решение здесь - не определять такие экземпляры.

Кубический
источник
53
ВОТ ЭТО ДА. 10 :: (Float, Float)дает (10.0,10.0)и :i Numсодержит строку instance Num Point -- Defined in ‘Graphics.Gloss.Data.Point’( Pointэто псевдоним Coord для Gloss). Шутки в сторону? Спасибо. Это спасло меня от бессонной ночи.
Carcigenicate
6
@Carcigenicate Хотя допускать такие экземпляры кажется несерьезным, причина, по которой это разрешено, заключается в том, что разработчики могут писать свои собственные экземпляры Numтам, где это имеет смысл, например, Angleтип данных, который ограничивает Doubleмежду -piи pi, или если кто-то хочет написать тип данных Представляя кватернионы или какой-либо другой более сложный числовой тип, эта функция очень удобна. Он также следует тем же правилам, что и String/ Text/ ByteString, разрешая эти экземпляры, имеет смысл с точки зрения простоты использования, но его можно использовать неправильно, как в этом случае.
bheklilr 06
4
@bheklilr Я понимаю необходимость разрешить экземпляры Num. «ВАУ» возникло из-за нескольких вещей. Я не знал, что вы можете создавать экземпляры псевдонимов типов, создание экземпляра Num Coord кажется нелогичным, и я не думал об этом. Ну что ж, урок усвоен.
Carcigenicate
3
Вы можете обойти проблему с осиротевшим экземпляром из своей библиотеки, используя newtypeобъявление for Coordвместо файла type.
Бенджамин Ходжсон
3
@Carcigenicate Я считаю, что вам нужен -XTypeSynonymInstances, чтобы разрешить экземпляры для синонимов типов, но это не обязательно для создания проблемного экземпляра. Экземпляр для Num (Float, Float)или даже (Floating a) => Num (a,a)не потребует расширения, но приведет к тому же поведению.
crockeea
64

Проверка типов в Haskell разумна. Проблема в том, что авторы библиотеки, которую вы используете, сделали что-то ... менее разумное.

Краткий ответ: да, 10 :: (Float, Float)совершенно верно, если есть экземпляр Num (Float, Float). В этом нет ничего «очень плохого» с точки зрения компилятора или языка. Это просто не согласуется с нашей интуицией о том, что делают числовые литералы. Поскольку вы привыкли к тому, что система типов улавливает подобную ошибку, вы вполне удивлены и разочарованы!

Numпримеры и fromIntegerпроблема

Вы удивлены , что компилятор принимает 10 :: Coord, то есть 10 :: (Float, Float). Разумно предположить, что числовые литералы, например, 10будут иметь «числовые» типы. Из коробки, числовые литералы можно интерпретировать как Int, Integer, Float, или Double. Набор чисел без какого-либо другого контекста не похож на число в том смысле, в котором эти четыре типа являются числами. Мы не о чем Complex.

Однако, к счастью или к сожалению, Haskell - очень гибкий язык. Стандарт определяет, что целочисленный литерал like 10будет интерпретироваться как fromInteger 10, который имеет тип Num a => a. Таким образом, 10можно сделать вывод о любом типе, для которого был Numнаписан экземпляр. Я объясню это более подробно в другом ответе .

Поэтому, когда вы разместили свой вопрос, опытный Haskeller сразу заметил, что для того, 10 :: (Float, Float)чтобы его приняли, должен существовать такой экземпляр, как Num a => Num (a, a)или Num (Float, Float). В классе нет такого экземпляра Prelude, поэтому он должен быть определен где-то еще. Используя :i Num, вы быстро определили, откуда он взялся: glossпакет.

Синонимы типов и бесхозные экземпляры

Но подожди минутку. В glossэтом примере вы не используете никаких типов; почему этот случай glossповлиял на вас? Ответ состоит из двух шагов.

Во-первых, синоним типа, введенный с ключевым словом type, не создает новый тип . В вашем модуле запись Coord- это просто сокращение от (Float, Float). Аналогично Graphics.Gloss.Data.Point, Pointзначит (Float, Float). Другими словами, ваши Coordи gloss«s Pointбуквально эквивалентны.

Поэтому, когда glossсопровождающие решили писать instance Num Point where ..., они также сделали ваш Coordтип экземпляром Num. Это эквивалентно instance Num (Float, Float) where ...или instance Num Coord where ....

(По умолчанию Haskell не позволяет синонимам типов быть экземплярами классов. glossАвторам пришлось включить пару языковых расширений TypeSynonymInstancesи FlexibleInstances, чтобы написать экземпляр.)

Во-вторых, это удивительно, потому что это бесхозный экземпляр , то есть объявление экземпляра, в instance C Aкотором оба Cи Aопределены в других модулях. Здесь это особенно коварно, потому что каждая задействованная часть, т. Е. Num, (,)И Float, происходит от Preludeи, вероятно, будет присутствовать везде.

Вы ожидаете, что Numэто определено в Prelude, а кортежи и Floatопределены в Prelude, поэтому все о том, как эти три вещи работают, определено в Prelude. Почему импорт совершенно другого модуля может что-то изменить? В идеале - нет, но экземпляры-сироты нарушают эту интуицию.

(Обратите внимание, что GHC предупреждает о бесхозных экземплярах - авторы glossспециально игнорировали это предупреждение. Это должно было вызвать красный флаг и вызвать как минимум предупреждение в документации.)

Экземпляры классов глобальны и не могут быть скрыты

Более того, экземпляры классов являются глобальными : любой экземпляр, определенный в любом модуле, который транзитивно импортируется из вашего модуля, будет в контексте и доступен для проверки типов при выполнении разрешения экземпляра. Это делает глобальные рассуждения удобными, потому что мы можем (обычно) предположить, что функция класса, например (+), всегда будет одинаковой для данного типа. Однако это также означает, что локальные решения имеют глобальные последствия; определение экземпляра класса безвозвратно изменяет контекст нижележащего кода без возможности замаскировать или скрыть его за границами модуля.

Вы не можете использовать списки импорта, чтобы избежать импорта экземпляров . Точно так же вы не можете избежать экспорта экземпляров из определяемых вами модулей.

Это проблемная и широко обсуждаемая область проектирования языка Haskell. В этой ветке Reddit есть увлекательное обсуждение связанных вопросов . См., Например, комментарий Эдварда Кметта о разрешении управления видимостью для примеров: «Вы в основном выкидываете корректность почти всего кода, который я написал».

(Кстати, как показал этот ответ , вы можете в некоторых отношениях нарушить предположение о глобальном экземпляре, используя экземпляры-сироты!)

Что делать - разработчикам библиотеки

Дважды подумайте, прежде чем внедрять Num. Пока Вы не можете обойти эту fromIntegerпроблему, не, определяя fromInteger = error "not implemented"это не делает его лучше. Будут ли ваши пользователи сбиты с толку или удивлены - или, что еще хуже, никогда не заметят, - если их целочисленные литералы будут случайно выведены как имеющие тип, который вы создаете? Является ли обеспечение (*)и (+)это критичным - особенно если вам нужно его взломать?

Рассмотрите возможность использования альтернативных арифметических операторов, определенных в библиотеке, такой как Conal Elliott vector-space(для типов *) или Edward Kmett linear(для типов * -> *). Это то, чем я обычно занимаюсь.

Используйте -Wall. Не реализуйте сиротские экземпляры и не отключайте предупреждение о сиротских экземплярах.

В качестве альтернативы, следуйте примеру linearи многим другим хорошо управляемым библиотекам и предоставьте бесхозные экземпляры в отдельном модуле, оканчивающемся на .OrphanInstancesили .Instances. И не импортируйте этот модуль из любого другого модуля . Затем пользователи могут явно импортировать сирот, если захотят.

Если вы обнаружите, что определяете сирот, подумайте о том, чтобы попросить разработчиков апстрима реализовать их вместо этого, если это возможно и целесообразно. Я часто писал сиротский экземпляр Show a => Show (Identity a), пока его не добавили в transformers. Возможно, я даже написал об этом отчет об ошибке; Не помню.

Что делать - пользователям библиотеки

У вас не так много вариантов. Обратитесь - вежливо и конструктивно! - к сопровождающим библиотеки. Укажите им на этот вопрос. У них могла быть какая-то особая причина написать проблемному сироте, или они могут просто не осознавать.

В более широком смысле: помните об этой возможности. Это одна из немногих областей Haskell, где есть настоящие глобальные эффекты; вам нужно будет проверить, что каждый модуль, который вы импортируете, и каждый модуль, который они импортируют, не реализуют бесхозные экземпляры. Аннотации типов могут иногда предупреждать вас о проблемах, и, конечно, вы можете использовать :iGHCi для проверки.

Определите свои собственные newtypes вместо typeсинонимов, если это достаточно важно. Вы можете быть уверены, что никто не станет с ними связываться.

Если у вас часто возникают проблемы, связанные с библиотекой с открытым исходным кодом, вы, конечно, можете создать свою собственную версию библиотеки, но обслуживание может быстро стать головной болью.

Кристиан Конкл
источник