Почему этот код с использованием UndecidableInstances компилируется, а затем генерирует бесконечный цикл времени выполнения?
При написании кода с использованием UndecidableInstances
раньше я столкнулся с чем-то очень странным. Мне удалось непреднамеренно создать некоторый код, который проверяет типы, когда я полагал, что это не должно:
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE UndecidableInstances #-}
data Foo = Foo
class ConvertFoo a b where
convertFoo :: a -> b
instance (ConvertFoo a Foo, ConvertFoo Foo b) => ConvertFoo a b where
convertFoo = convertFoo . (convertFoo :: a -> Foo)
evil :: Int -> String
evil = convertFoo
В частности, convertFoo
Проверка типов функций при наличии любого ввода для получения любого вывода, как показано evil
функция. Сначала я подумал, что, возможно, мне удалось случайно осуществить unsafeCoerce
, но это не совсем так: на самом деле пытается позвонить convertFoo
функция (делая что-то вроде evil 3
например) просто заходит в бесконечный цикл.
Я в некотором смысле понимаю, что происходит. Мое понимание проблемы примерно так:
- Пример
ConvertFoo
что я определил работает на любом входе и выходе,a
а такжеb
ограничивается только двумя дополнительными ограничениями, из которых должны существовать функции преобразованияa -> Foo
а такжеFoo -> b
, - Каким-то образом это определение может соответствовать любым типам ввода и вывода, поэтому может показаться, что вызов
convertFoo :: a -> Foo
выбирает само определениеconvertFoo
сам, так как это единственный доступный, в любом случае. - поскольку
convertFoo
вызывая себя бесконечно, функция входит в бесконечный цикл, который никогда не заканчивается. - поскольку
convertFoo
никогда не завершается, тип этого определения является нижним, так что технически ни один из типов никогда не нарушается, и типы программ проверяются.
Теперь, даже если вышеприведенное понимание верно, я все еще не понимаю, почему все типы программ проверяются. В частности, я бы ожидал ConvertFoo a Foo
а также ConvertFoo Foo b
ограничения на неудачу, учитывая, что таких экземпляров не существует.
Я понимаю (по крайней мере, нечетко), что ограничения не имеют значения при выборе экземпляра - экземпляр выбирается только на основе заголовка экземпляра, затем ограничения проверяются, поэтому я мог видеть, что эти ограничения могут разрешиться очень хорошо из-за моего ConvertFoo a b
экземпляр, который примерно настолько разрешительный, насколько это возможно. Однако для этого потребуется разрешить тот же набор ограничений, который, я думаю, поместит проверку типов в бесконечный цикл, в результате чего GHC либо зависнет при компиляции, либо выдаст мне ошибку переполнения стека (последнее из которых я видел до).
Тем не менее, очевидно, что средство проверки типов не зацикливается, потому что оно успешно работает на дне и скомпилирует мой код. Зачем? Как контекст экземпляра удовлетворяется в этом конкретном примере? Почему это не приводит к ошибке типа или к циклу проверки типов?
2 ответа
Я искренне согласен с тем, что это отличный вопрос. Это говорит о том, как наши представления о классах типов отличаются от реальности.
Типкласс сюрприз
Чтобы увидеть, что здесь происходит, собираемся поднять ставки на подпись типа для evil
:
data X
class Convert a b where
convert :: a -> b
instance (Convert a X, Convert X b) => Convert a b where
convert = convert . (convert :: a -> X)
evil :: a -> b
evil = convert
Очевидно, что Covert a b
экземпляр выбирается, так как существует только один экземпляр этого класса. Типограф проверяет что-то вроде этого:
Convert a X
верно, если...Convert a X
верно [верно по предположению]- а также
Convert X X
правдаConvert X X
верно, если...Convert X X
верно [верно по предположению]Convert X X
верно [верно по предположению]
Convert X b
верно, если...Convert X X
верно [верно сверху]- а также
Convert X b
верно [верно по предположению]
Типепекер нас удивил. Мы не ожидаем Convert X X
быть правдой, поскольку мы не определили ничего подобного. Но (Convert X X, Convert X X) => Convert X X
это своего рода тавтология: она автоматически верна и верна независимо от того, какие методы определены в классе.
Это может не соответствовать нашей ментальной модели классов типов. Мы ожидаем, что на этом этапе компилятор будет поглазеть и пожаловаться на Convert X X
не может быть правдой, потому что мы не определили экземпляр для него. Мы ожидаем, что компилятор будет стоять на Convert X X
, чтобы найти другое место, чтобы идти туда, где Convert X X
это правда, и сдаться, потому что нет другого места, где это правда. Но компилятор умеет рекурсировать! Рекурсировать, зациклить и быть завершенным по Тьюрингу.
Мы благословили тестера типов этой возможностью, и мы сделали это сUndecidableInstances
, Когда в документации говорится, что можно отправить компилятор в цикл, легко предположить худшее, и мы предположили, что плохие циклы - это всегда бесконечные циклы. Но здесь мы продемонстрировали цикл, еще более смертоносный, цикл, которыйзаканчивается - за исключением удивительного способа.
(Это еще более убедительно продемонстрировано в комментарии Даниила:
class Loop a where
loop :: a
instance Loop a => Loop a where
loop = loop
.)
Опасности неразрешимости
Это именно та ситуация, UndecidableInstances
позволяет. Если мы выключим это расширение и включим FlexibleContexts
(безобидное расширение, которое по своей природе является просто синтаксическим), мы получаем предупреждение о нарушении одного из условий Патерсона:
...
Constraint is no smaller than the instance head
in the constraint: Convert a X
(Use UndecidableInstances to permit this)
In the instance declaration for ‘Convert a b’
...
Constraint is no smaller than the instance head
in the constraint: Convert X b
(Use UndecidableInstances to permit this)
In the instance declaration for ‘Convert a b’
"Не меньше, чем голова экземпляра", хотя мы можем мысленно переписать его как "возможно, этот экземпляр будет использован, чтобы доказать самоутверждение и причинить вам много страданий, скрежетов и печатания". Условия Патерсона вместе предотвращают зацикливание в разрешении экземпляра. Наше нарушение здесь демонстрирует, почему они необходимы, и мы можем, вероятно, обратиться к какой-то статье, чтобы понять, почему они достаточны.
Вниз
Что касается того, почему программа во время выполнения бесконечных циклов: есть скучный ответ, где evil :: a -> b
не может не вызывать бесконечный цикл или выбрасывать исключение или вообще снизу, потому что мы доверяем проверке типов в Haskell, и нет значения, которое может населять a -> b
кроме дна.
Более интересный ответ заключается в том, что, так как Convert X X
тавтологически верно, его определение экземпляра является этот бесконечный цикл
convertXX :: X -> X
convertXX = convertXX . convertXX
Мы можем аналогичным образом расширить Convert A B
определение экземпляра
convertAB :: A -> B
convertAB =
convertXB . convertAX
where
convertAX = convertXX . convertAX
convertXX = convertXX . convertXX
convertXB = convertXB . convertXX
Это удивительное поведение и то, как ограниченное разрешение экземпляров (по умолчанию без расширений) предназначено для того, чтобы избежать такого поведения, возможно, может быть взято в качестве хорошей причины того, почему система классов типов Haskell еще не получила широкого распространения. Несмотря на его впечатляющую популярность и мощь, у него есть странные углы (будь то в документации, сообщениях об ошибках или в синтаксисе, или, может быть, даже в его базовой логике), которые кажутся особенно плохо подходящими к тому, как мы, люди, думаем об абстракциях на уровне типов.
Вот как я мысленно обрабатываю эти случаи:
class ConvertFoo a b where convertFoo :: a -> b
instance (ConvertFoo a Foo, ConvertFoo Foo b) => ConvertFoo a b where
convertFoo = ...
evil :: Int -> String
evil = convertFoo
Сначала мы начнем с вычисления набора необходимых экземпляров.
evil
прямо требуетConvertFoo Int String
(1).- Тогда (1) требует
ConvertFoo Int Foo
(2) иConvertFoo Foo String
(3). - Тогда (2) требует
ConvertFoo Int Foo
(мы уже это посчитали) иConvertFoo Foo Foo
(4). - Тогда (3) требует
ConvertFoo Foo Foo
(посчитано) иConvertFoo Foo String
(Счет). - Тогда (4) требует
ConvertFoo Foo Foo
(посчитано) иConvertFoo Foo Foo
(Счет).
Следовательно, мы достигаем фиксированной точки, которая является конечным набором обязательных экземпляров. У компилятора нет проблем с вычислениями, которые устанавливаются за конечное время: просто применяйте определения экземпляров до тех пор, пока больше не нужно больше ограничений.
Затем мы приступаем к предоставлению кода для этих экземпляров. Вот.
convertFoo_1 :: Int -> String
convertFoo_1 = convertFoo_3 . convertFoo_2
convertFoo_2 :: Int -> Foo
convertFoo_2 = convertFoo_4 . convertFoo_2
convertFoo_3 :: Foo -> String
convertFoo_3 = convertFoo_3 . convertFoo_4
convertFoo_4 :: Foo -> Foo
convertFoo_4 = convertFoo_4 . convertFoo_4
Мы получаем кучу взаимно рекурсивных определений экземпляров. В этом случае они будут зациклены во время выполнения, но нет причин отклонять их во время компиляции.