Подтвердить что ты не робот

Почему исключение является исключением, но исключение является чистым?

В Haskell вы можете исключить исключение из чисто функционального кода, но вы можете использовать только код IO.

  • Почему?
  • Можете ли вы поймать в других контекстах или только монаду IO?
  • Как справляются с этим другие чисто функциональные языки?
4b9b3361

Ответ 1

Поскольку бросание исключения внутри функции не приводит к тому, что результат этой функции зависит от чего-либо, кроме значений аргументов и определения функции; функция остается чистой. OTOH, ловящее исключение внутри функции, делает (или, по крайней мере, может) эту функцию больше не чистой функцией.

Я рассмотрю два вида исключений. Первый - недетерминированный; такие исключения возникают непредсказуемо во время выполнения и включают такие вещи, как ошибки в памяти. Существование этих исключений не включается в значение функций, которые могут их генерировать. Это всего лишь неприятный факт, с которым нам приходится иметь дело, потому что у нас есть реальные физические машины в реальном мире, которые не всегда совпадают с абстракциями, которые мы используем, чтобы помочь нам их программировать.

Если функция генерирует такое исключение, это означает, что одна конкретная попытка оценить функцию не привела к созданию значения. Это не обязательно означает, что результат функции undefined (по аргументам, которые он вызывал в это время), но система не смогла произвести результат.

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

Из-за чисто эксплуатационных сбоев нам приходится позволять, чтобы оценка функции могла давать дно, а не значение, которое оно должно "произвести". Это не полностью разрушает нашу семантическую интерпретацию программ Haskell, потому что мы знаем, что дно приведет к тому, что все вызывающие вызовы будут создавать дно (если они не нуждаются в ценности, которая должна была быть вычислена, но в этом случае, строгая оценка предполагает, что система никогда бы не попыталась оценить эту функцию и не получилась). Это звучит плохо, но когда мы выходим на вычисление в монаде IO, что мы можем безопасно поймать такие исключения. Значения в монаде IO позволяют зависеть от вещей "вне" программы; на самом деле они могут изменить свою ценность, зависящую от чего-либо в мире (поэтому одна общая интерпретация значений IO заключается в том, что они, как если бы они были переданы представление всей вселенной). Таким образом, для значения IO вполне нормально иметь один результат, если в чистом подсчете заканчивается память, а другой результат - нет.


Но как насчет детерминированных исключений? Здесь я говорю об исключениях, которые всегда бросаются при оценке конкретной функции по определенному набору аргументов. Такие исключения включают в себя ошибки "разделить на нуль", а также любое исключение, явно выведенное из чистой функции (поскольку его результат может зависеть только от его аргументов и его определения, если он оценивает бросок, когда он всегда будет оценивать один и тот же бросок для тех же аргументов [1]).

Может показаться, что этот класс исключений должен быть увлекательным в чистом коде. В конце концов, значение 1 / 0 просто равно ошибке с делением на нуль. Если функция может иметь другой результат, зависящий от того, оценивает ли суб-вычисление ошибку деления на нуль, проверяя, проходит ли она в нуле, почему она не может это сделать, проверяя, является ли результат делением -zero ошибка?

Здесь мы возвращаемся к точке larsmans, сделанной в комментарии. Если чистая функция может наблюдать, какое исключение она получает от throw ex1 + throw ex2, то ее результат становится зависимым от порядка выполнения. Но это до системы времени выполнения, и это, возможно, может даже измениться между двумя разными исполнениями одной и той же системы. Возможно, у нас есть продвинутая автоматическая параллелистическая реализация, которая пытается выполнить различные стратегии параллелизации при каждом выполнении, чтобы попытаться сблизиться с лучшей стратегией в течение нескольких запусков. Это приведет к тому, что результат функции catch-catch будет зависеть от используемой стратегии, количества процессоров в машине, нагрузки на машину, операционной системы и политики планирования и т.д.

Опять же, определение чистой функции состоит в том, что только информация, которая входит в функцию через ее аргументы (и ее определение), должна влиять на ее результат. В случае функций не IO информация, влияющая на то, какое исключение выбрасывается, не входит в функцию через свои аргументы или определение, поэтому она не может влиять на результат. Но вычисления в монаде IO неявно могут зависеть от любых деталей всего юниверса, поэтому ловить такие исключения там хорошо.


Что касается вашей второй точки точки, нет, другие монады не будут работать для исключения исключений. Все те же аргументы применимы; расчеты, производящие Maybe x или [y], не должны зависеть от чего-либо вне их аргументов, и ловить любое исключение "утешает" всевозможные детали о вещах, которые не включены в эти аргументы функций.

Помните, что ничего особенного в монадах нет. Они не работают иначе, чем другие части Haskell. Класс monad определен в обычном коде Haskell, как и почти во всех реализациях монады. Все правила, которые применяются к обычным кодам Haskell, применяются ко всем монадам. Он IO сам, что особенный, а не тот факт, что это монада.


Что касается того, как другие чистые языки обрабатывают захват исключений, единственным другим языком с принудительной чистотой, с которым я сталкиваюсь, является Mercury. [2] Mercury делает это немного иначе, чем Haskell, и вы можете поймать исключения в чистом коде.

Меркурий - это язык логического программирования, поэтому он не строится на функциях. Программы Mercury построены из предикатов; вызов предиката может иметь ноль, один или несколько решений (если вы знакомы с программированием в монаде списка, чтобы получить недетерминизм, это немного похоже на то, что весь язык находится в списке монады). Оперативное выполнение Mercury использует обратное отслеживание для рекурсивного перечисления всех возможных решений для предиката, но семантика недетерминированного предиката состоит в том, что он просто имеет набор решений для каждого набора своих входных аргументов, в отличие от функции Haskell, которая вычисляет один значение результата для каждого набора входных аргументов. Как и Haskell, Mercury является чистым (включая ввод-вывод, хотя он использует несколько иной механизм), поэтому каждый вызов предиката должен однозначно определять один набор решений, который зависит только от аргументов и определения предиката.

Меркурий отслеживает "детерминизм" каждого предиката. Предикаты, которые всегда приводят к одному решению, называются det (короткими для детерминированных). Те, которые генерируют хотя бы одно решение, называются multi. Есть еще несколько классов детерминизма, но они здесь не актуальны.

Захват исключения с блоком try (или путем явного вызова предикатов более высокого порядка, которые его реализуют) имеет детерминизм cc_multi. Cc означает "преданный выбор". Это означает, что "это вычисление имеет по крайней мере одно решение, и оперативно программа будет получать только один из них". Это связано с тем, что выполнение подвычисления и наблюдение за тем, создало ли оно исключение, имеет набор решений, который является объединением "обычных" решений подсчета, а также множество всех возможных исключений, которые он может использовать. Поскольку "все возможные исключения" включает все возможные сбои во время выполнения, большинство из которых никогда не произойдет, этот набор решений не может быть полностью реализован. Невозможно, чтобы механизм выполнения мог действительно вернуться к любому возможному решению блока try, поэтому вместо этого он просто дает вам a решение (либо нормальное, либо указание на то, что все возможности были изучены и не было никакого решения или исключения, или первое исключение, которое возникло).

Поскольку компилятор отслеживает детерминизм, он не позволит вам вызывать try в контексте, где имеет место набор решений. Вы не можете использовать его для генерации всех решений, которые не сталкиваются с каким-либо исключением, например, потому что компилятор будет жаловаться на то, что ему нужны все решения для вызова cc_multi, который только собирается его создать. Однако вы также не можете вызывать его из предиката det, потому что компилятор будет жаловаться, что предикат det (который должен иметь ровно одно решение) делает вызов cc_multi, который будет иметь несколько решений ( мы просто узнаем только, что это такое).

Так как же это полезно? Ну, вы можете иметь main (и другие вещи, которые он вызывает, если это полезно), объявленные как cc_multi, и они могут вызвать try без проблем. Это означает, что вся программа имеет множество "решений" в теории, но ее запуск будет генерировать решение a. Это позволяет вам писать программу, которая ведет себя по-разному, когда в какой-то момент времени заканчивается нехватка памяти. Но это не испортит декларативную семантику, потому что "реальный" результат, который он вычислил бы с большей доступной памятью, все еще находится в наборе решений (так же, как исключение из-за-памяти все еще находится в решении, установленном, когда программа действительно делает вычислите значение), это просто то, что мы получаем только одно произвольное решение.

Важно, чтобы det (существует ровно одно решение) обрабатывается иначе, чем cc_multi (существует несколько решений, но вы можете иметь только один из них). Подобно рассуждениям об улавливании исключений в Haskell, исключение прилова не может быть разрешено в контексте не "фиксированного выбора", или вы можете получить чистые предикаты, производящие разные наборы решений, в зависимости от информации из реального мира, t иметь доступ. Детерминизм cc_multi try позволяет нам писать программы так, как если бы они создавали бесконечный набор решений (в основном полный второстепенных вариантов маловероятных исключений) и не позволяли нам писать программы, которые действительно нуждались в более чем одном решении из набора. [3]


[1] Если оценка не встречается сначала с недетерминированной ошибкой. Реальная жизнь - боль.

[2] Языки, которые просто поощряют программиста использовать чистоту, не применяя его (например, Scala), как правило, позволяют вам исключать исключения, где бы вы ни хотели, так же, как они позволяют делать ввод-вывод везде, где вы хотите.

[3] Обратите внимание, что концепция "преданного выбора" - это не то, как Меркурий обрабатывает чистый ввод-вывод. Для этого Mercury использует уникальные типы, которые ортогональны классу детерминизма "преданного выбора".

Ответ 2

paper delnan упоминает в комментариях, а ответы на этот предыдущий вопрос, безусловно, дают достаточные основания только для исключения исключений в IO.

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

Позвольте мне проиллюстрировать, в чем именно заключается этот ужас.

Сначала мы определяем тип исключения для использования и версию catch, которая может использоваться в чистом коде:

newtype Exit a = Exit { getExit :: a } deriving (Typeable)
instance Show (Exit a) where show _ = "EXIT"

instance (Typeable a) => Exception (Exit a)

unsafeCatch :: (Exception e) => a -> (e -> a) -> a
unsafeCatch x f = unsafePerformIO $ catch (seq x $ return x) (return . f)

Это позволит нам выкинуть любое значение Typeable, а затем поймать его из какой-либо внешней области без согласия любых промежуточных выражений. Например, мы могли бы скрыть бросок Exit внутри того, что мы передаем функции более высокого порядка, чтобы сбежать с некоторым промежуточным значением, генерируемым его оценкой. Уверенные читатели, возможно, уже поняли, где это происходит:

callCC :: (Typeable a) => ((a -> b) -> a) -> a
callCC f = unsafeCatch (f (throw . Exit)) (\(Exit e) -> e)

Да, это действительно работает с предостережением о том, что он требует какого-либо использования продолжения, которое будет оцениваться всякий раз, когда все выражение. Имейте это в виду, если вы попробуете это, или просто используйте deepseq, если nuking с орбиты - это больше ваша скорость.

Вот:

-- This will clearly never terminate, no matter what k is
foo k = fix (\f x -> if x > 100 then f (k x) else f (x + 1)) 0

Но:

∀x. x ⊢ callCC foo
101

Выход изнутри a map:

seqs :: [a] -> [a]
seqs xs = foldr (\h t -> h `seq` t `seq` (h:t)) [] xs

bar n k = map (\x -> if x > 10 then k [x] else x) [0..n]

Обратите внимание на необходимость принудительной оценки.

∀x. x ⊢ callCC (seqs . bar 9)
[0,1,2,3,4,5,6,7,8,9]
∀x. x ⊢ callCC (seqs . bar 11)
[11]

... тьфу.

Теперь, мы никогда не будем говорить об этом снова.