Засіб перевірки типів дозволяє дуже неправильно замінити тип, і програма все ще компілюється


99

Під час спроби налагодити проблему в моїй програмі (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виникає помилка компілятора, і навіть незнайома, зміна 10в startPlayerвикликає відповідну відповідь (змінює початковий розмір Player); знову ж таки, незважаючи на те, що він неправильного типу. Щоб переконатися, що він читає визначення даних правильно, я вставив помилку в файл, і це дало мені помилку; тому я дивлюся на правильний файл.

Я спробував вставити 2 фрагменти вище у свій власний файл, і він виплюнув очікувану помилку, що друге поле Playerв startPlayerневірно.

Що могло б дозволити цьому статися? Ви б могли подумати, що це саме те, що має запобігти перевірка типу Haskell.


* Відповідь на мою первісну проблему: два кола, начебто, однакового радіуса, намальованих різними розмірами, полягала в тому, що один із радіусів насправді був негативним.


26
Як зазначав @Cubic, ви обов'язково повинні повідомити про цю проблему технічному обслуговувачу глянцю. Ваше запитання чудово ілюструє те, як неналежний примірник-сирота бібліотеки зіпсував ваш код.
Крістіан Конкл

1
Зроблено. Чи можливо виключити екземпляри? Вони можуть вимагати, щоб бібліотека функціонувала, але мені це не потрібно. Я також помітив, що вони визначали Num Color. Лише питання часу до того, як мене зачепить.
Carcigenicate

@Cubic Ну, пізно. І я завантажив його лише тиждень або близько тому, використовуючи оновлений, сучасний Cabal; тому воно повинно бути поточним.
Carcigenicate

2
@ChristianConkle Є ймовірність, що автор блиску не зрозумів, що робить TypeSynonymousmInsances. У будь-якому випадку, це дійсно потрібно піти (або зробити або використовувати інші імена операторів ала )Pointnewtypelinear
Cubic

1
@Cubic: TypeSynonymInsances сама по собі не є поганою (хоча і не зовсім нешкідливою), але коли ви поєднуєте її з OverlappingInsances, речі отримують дуже задоволення.
Джон Л

Відповіді:


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псевдонім Глоса Коорда). Серйозно? Дякую. Це врятувало мене від безсонної ночі.
Carcigenicate

6
@Carcigenicate Хоча це здається несерйозним , щоб такі випадки, причина це дозволило так що розробники можуть створювати свої власні екземпляри , Numде це має сенс, наприклад , як Angleтип даних , який стримує Doubleміж -piі pi, або якщо хто - то хотів написати тип даних що представляє кватерніони або інший більш складний числовий тип, ця функція дуже зручна. Він також керується тими ж правилами, що і String// Text/ ByteString, дозволяючи цим примірникам мати сенс з точки зору простоти у використанні, але їх можна неправомірно використовувати, як у цьому випадку.
bheklilr

4
@bheklilr Я розумію необхідність дозволу екземплярів Num. "WOW" випливав із кількох речей. Я не знав, що ви можете створювати екземпляри псевдонімів типу. Створення Num примірника Coord просто здається протилежним інтуїтивно зрозумілим, і я про це не думав. Ну добре, урок засвоєний.
Carcigenicate

3
Ви можете вирішити свою проблему з осиротілим екземпляром зі своєї бібліотеки, використовуючи newtypeдекларацію для Coordзамість type.
Бенджамін Ходжсон

3
@Carcigenicate Я вважаю, що вам потрібні -XTypeSynonymInsances, щоб дозволити екземпляри для синонімів типу, але це не потрібно для створення проблемного примірника. Екземпляр для Num (Float, Float)або навіть (Floating a) => Num (a,a)не вимагає розширення, але призведе до такої ж поведінки.
крокей

64

Перевірка типу 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написаний екземпляр. Я пояснюю це трохи детальніше в іншій відповіді .

Тож, коли ви розмістили своє запитання, досвідчений 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"це НЕ робить його краще. Чи будуть ваші користувачі розгублені чи здивовані, або, що ще гірше, ніколи не помітять, якщо їх цілі буквені знаки випадково роблять висновок щодо типу, який ви створюєте? Чи надає це, (*)і (+)що критично важливо - особливо якщо вам доведеться його зламати?

Подумайте про використання альтернативних арифметичних операторів, визначених у бібліотеці, таких як Конал Елліотт vector-space(для типів роду *) або Едвард Кметт linear(для типів роду * -> *). Це те, що я схильний робити сам.

Використовуйте -Wall. Не реалізовуйте осиротілі екземпляри та не вимикайте попередження про осиротілий екземпляр.

Крім того, слідкуйте за керівництвом linearта багатьма іншими чудотворними бібліотеками та надайте сиротні екземпляри в окремий модуль, що закінчується на .OrphanInstancesабо .Instances. І не імпортуйте цей модуль з будь-якого іншого модуля . Тоді користувачі можуть імпортувати дітей-сиріт явно, якщо хочуть.

Якщо ви виявите, що ви визначаєте сиріт, подумайте, чи потрібно, якщо це можливо і доцільно, попросити технічних працівників, які їх надають. Я часто писав екземпляр-сироту Show a => Show (Identity a), поки вони не додали його transformers. Я, можливо, навіть підняв звіт про помилку; Я не пам'ятаю.

Що робити - для споживачів бібліотеки

У вас не багато варіантів. Вийдіть - ввічливо та конструктивно! - до бібліотечних працівників. Вкажіть їх на це питання. У них, можливо, були якісь особливі причини писати проблемну сироту, або вони просто не усвідомлюють.

Більш широко: пам’ятайте про таку можливість. Це одна з небагатьох областей Хаскелл, де є справжні глобальні ефекти; вам доведеться перевірити, чи кожен модуль, який ви імпортуєте, і кожен модуль, який вони імпортують, не реалізують осиротілі екземпляри. Анотації про тип іноді можуть попереджати про проблеми, і звичайно ви можете скористатися :iв GHCi для перевірки.

Визначте власне newtypes замість typeсинонімів, якщо це досить важливо. Ви можете бути впевнені, що з ними ніхто не зіпсується.

Якщо у вас виникають часті проблеми, пов’язані з бібліотекою з відкритим кодом, ви, звичайно, можете скласти власну версію бібліотеки, але обслуговування може швидко стати головним болем.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.