и в моем главном файле, который импортирует Objects.hs, у меня есть следующее определение:
Это произошло из-за того, что я добавил и изменил поля для игрока и забыл обновить startPlayer
после (его размеры были определены одним числом для представления радиуса, но я изменил его на Coord
для представления (ширина, высота); если я когда-либо сделаю объект игрока не круглым).
Удивительно то, что приведенный выше код компилируется и запускается, несмотря на то, что второе поле имеет неправильный тип.
Сначала я подумал, что, возможно, у меня были открыты разные версии файлов, но любые изменения любых файлов были отражены в скомпилированной программе.
Я попытался вставить 2 вышеупомянутых фрагмента в их собственный файл, и он выдал ожидаемую ошибку, что второе поле Player
в startPlayer
неверно.
Что может позволить этому случиться? Вы можете подумать, что это именно то, что должно предотвращать средство проверки типов в Haskell.
Ответ 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
, если это достаточно важно. Вы можете быть уверены, что никто не будет связываться с ними.
Если у вас часто возникают проблемы с созданием библиотеки с открытым исходным кодом, вы, конечно, можете создать свою собственную версию библиотеки, но обслуживание может быстро стать головной болью.