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

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

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

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

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

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

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

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

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

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

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


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

Ответ 1

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

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

Ответ 2

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

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

Экземпляры Num и проблема fromInteger

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

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

Поэтому, когда вы разместили свой вопрос, опытный Хаскеллер сразу заметил, что для принятия 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 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 специально отвергли это предупреждение. Это должно было поднять красный флаг и вызвать по крайней мере предупреждение в документации.)

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

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

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

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

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

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

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

Попробуйте использовать альтернативные арифметические операторы, определенные в библиотеке, такие как Конал Эллиотт vector-space (для типов вида *) или Эдвард Кметт linear (для типов вида * -> *). Это то, что я делаю сам.

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

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

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

Что делать - для потребителей библиотеки

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

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

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

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