Скандальная правда об обработке исключений в Haskell
30 октября 2013
Один из наиболее запутанных вопросов при изучении Haskell — это обработка исключений. Многие учебники, в том числе LYH, повествуют об исключениях, описанных в стандарте Haskell 98, создавая тем самым ошибочное впечатление, что в Haskell нельзя объявлять собственные исключения. А в RWH, например, в качестве «современных» функций для работы с исключениями называются throwDyn, catchDyn и прочие. В результате многие хаскелисты не понимают и боятся исключений, а асинхронные исключения так и вовсе считают какой-то черной магией. Благодаря этой небольшой заметке вы узнаете, как же на самом деле в Haskell обрабатываются исключения.
Основы
Проще всего дела обстоят с чистыми функциями. В 99 процентах случаев роль «исключений» в них играют монады Maybe и Either. Также специально для знающих толк месье предусмотрены трансформаторы монад MaybeT, EitherT и ErrorT. В общем, если вы пишите чистую функцию, и хотите как-то сообщить об ошибке, то вам почти наверняка нужен либо Maybe, либо Either. Правда, иногда это неудобно, и приходится все-таки пользоваться исключениями. Представьте на секунду мир, в котором функции head и div возвращают Maybe.
Для грязных функций, а также случаев, когда в чистой функции случилось нечто невозможное, есть исключения. Исключения в Haskell могут бросать как чистые, так и грязные функции, но ловить исключения при этом можно только в грязных функциях. Простейший способ бросить исключение состоит в том, чтобы вызвать всем знакомую функцию error:
error :: [Char] -> a
ghci> error "something impossible just happened"
*** Exception: something impossible just happened
Ловятся исключения при помощи функции catch:
ghci> :t catch
catch :: Exception e => IO a -> (e -> IO a) -> IO a
ghci> catch (error "fail!") $ \e -> print (e :: ErrorCall)
fail!
… или функции handle, отличающейся от catch только порядком аргументов:
Как видите, здесь мы используем модуль Control.Exception. В Haskell нет какого-то специального синтаксиса для обработки исключений, все делается только при помощи функций и типов, сосредоточенных в этом модуле. Это, кстати, является прекрасным примером создания предметно-ориентированного языка при помощи комбинаторов. Если вам попадется туториал, рекомендующий использовать для обработки исключений функции из модулей Prelude и System.IO.Error, знайте, перед вами устаревшая информация. На самом деле, модуль System.IO.Error все еще используется, но только потому что в нем есть функции для работы с исключениями типа IOError (который представляет собой всего лишь синоним типа IOException), например, isDoesNotExistError, isPermissionError и другие. Еще один тонкий момент состоит в том, что в GHC до версии 7.6 модуль Prelude также экспортировал функцию catch, поэтому в коде приходилось писать:
Если вы используете GHC 7.6 или старше, этого больше делать не нужно.
В чистых функциях исключения бросаются с помощью функции throw, а внутри монады IO — с помощью throwIO:
throwIO :: Exception e => e -> IO a
Функция error представляет собой всего лишь обертку над throw:
error s = throw (ErrorCall s)
В отличие от throw функция throwIO гарантирует правильный порядок выполнения операций внутри монады IO. Аналогично при работе с транзакционной памятью вместо throw следует использовать функцию throwSTM.
Вы могли обратить внимание, что при использовании catch в обработчике исключения был указан точный тип исключения, ErrorCall. Если указать другой тип, исключение не будет поймано:
*** Exception: fail!
Возникает закономерный вопрос — а есть ли способ поймать исключение любого типа? Оказывается, что есть. Дело в том, что исключения в Haskell образуют иерархию, корнем которой является тип SomeException. Если указать этот тип, будет поймано любое исключение:
fail!
Заметьте, что этот прием следует использовать только для отладки и освобождения ресурсов перед повторным бросанием исключения. Ловить SomeException в остальных случаях считается code smell.
Создание новых типов исключений
В модуле Control.Exception вы найдете немало различных типов исключений — ArithException, ErrorCall, IOException и другие. Также ничто не мешает объявлять собственные исключения. Но сначала немного теории.
Класс типов Exception представляет собой довольно простую фигню:
toException :: e -> SomeException
fromException :: SomeException -> Maybe e
… а SomeException при этом определяется следующим образом:
data SomeException = forall e . Exception e => SomeException e
deriving Typeable
Если вы раньше не сталкивались с экзистенциальными типами, загляните на Haskell Wiki. Это очень простое расширение GHC, разобраться в котором займет у вас максимум 5 минут. Несмотря на свою простоту, экзистенциальные типы позволяют использовать в Haskell элементы динамический типизации, а также строить иерархии типов (ООП в функциональном языке!), что, собственно, и делается в случае с SomeException.
Typeable также представляет собой совершенно обычный класс типов:
-- какое-то определение
deriving (Eq, Ord, Show)
class Typeable a where
typeOf :: a -> TypeRep
Вот как это примерно работает:
ghci> typeOf True
Bool
ghci> :set -XDeriveDataTypeable
ghci> data X = X deriving Typeable
ghci> typeOf X
X
Пожалуйста — еще один пример рефлексии в Haskell.
Итак, чтобы объявить собственный тип исключения, нам всего лишь нужно ввести новый тип, являющийся экземпляром классов Show, Typeable и Exception. Давайте попробуем:
ghci> instance Exception MyError
ghci> (throw $ MyError "fail!") `catch` \e -> print (e :: MyError)
MyError "fail!"
Надо же, работает!
Функция bracket и компания
Еще три функции, которые следует иметь в виду при работе с исключениями — это bracket, finally и onException:
finally :: IO a -> IO b -> IO a
onException :: IO a -> IO b -> IO a
Допустим, мы работаем с некоторым недешевым ресурсом, например, файловым дескриптором. Что произойдет, если во время работы с ним будет брошено исключение? Будет ли файл закрыт? На самом деле, будет, по крайней мере, при использовании GHC, но только когда до файлового дескриптора доберется сборщик мусора. А произойти это может спустя неопределенное время, за которое мы успеем наоткрывать еще кучу файлов. Налицо утечка ресурсов. Решение проблемы заключается в том, чтобы освободить ресурс сразу после окончания работы с ним, независимо от того, было брошено исключение, или нет.
Для этого и нужна функция bracket. Первым аргументом она принимает функцию, выделяющую ресурс, вторым — функцию, освобождающую ресурс, а третьим — функцию, непосредственно использующую этот ресурс. Ресурс освобождается независимо от того, было брошено при работе с ним исключение, или нет. Если было брошено исключение, bracket бросает его повторно после освобождения ресурса. Вот, например, как выглядит функция withFile из модуля System.IO:
withFile name mode = bracket (openFile name mode) hClose
Функция finally похожа на bracket. Сначала вызывается функция, переданная первым аргументом, затем — вторым. Причем вторая функция будет вызвана всегда, независимо от того, бросит первая исключение, или нет. В отличие от finally функция onException вызывает функцию, переданную вторым аргументом, только в случае, если первая функция бросит исключение. Как onException, так и finally бросают пойманные исключения повторно.
Асинхронные исключения
До сих пор все было не слишком сложно, правда? Сейчас начнется самое интересное. Дело в том, что помимо обычных, синхронных исключений, в Haskell бывают еще и асинхронные исключения. Асинхронные исключения приходят выполняющимся потокам извне и могут выстреливать в любое время. Как было отмечено в заметке Работа с нитями/потоками в Haskell, в некоторых случаях система времени выполнения умеет обнаруживать возникновение дэдлоков и посылать заблокированным нитям исключение BlockedIndefinitelyOnMVar. Аналогично при работе с транзакционной памятью в этом случае посылается исключение BlockedIndefinitelyOnSTM. Еще асинхронные исключения приходят при переполнении стека или кучи, а также при нажатии пользователем Ctr+C:
^Cuser interrupt
На самом деле, любая нить может послать любой другой нити абсолютно любое асинхронное исключение при помощи функции throwTo:
Итак, оказывается, в любое время может быть брошено любое исключение. Понятно, что в некоторых случаях это нежелательно. Например, рассмотрим такую реализацию функции bracket:
bracket before after thing = do
-- 1. выделяем ресурс
a <- before
-- 2. работаем с ресурсом, в случае исключения освобождаем его
-- и повторно бросаем исключение
r <- thing a `onException` after a
-- 3. если все ОК, освобождаем ресурс
_ <- after a
return r
Если асинхронное исключение придет где-то во время перехода от действия 1 к действию 2 или от действия 2 к действию 3, оно не будет перехвачено и ресурс благополучно утечет. Для решения этой проблемы предназначена функция mask:
Эта функция имеет довольно сложный (и, на самом деле, несколько иной) тип, поэтому давайте лучше посмотрим, как она используется. Настоящая функция bracket, экспортируемой модулем Control.Exception, выглядит так:
bracket before after thing =
mask $ \restore -> do
a <- before
r <- restore (thing a) `onException` after a
_ <- after a
return r
Как видите, этот вариант bracket практически не отличается от старого, только его тело «обернуто» в функцию mask и принимает в качестве аргумента некую функцию restore. Семантика всего этого хозяйства следующая. Внутри кода, обернутого в mask, все асинхронные исключения маскируются, то есть, откладываются на потом. Чтобы временно восстановить прежний режим маскировки, находясь внутри mask, код нужно обернуть в переданную в качестве аргумента функцию restore. Обратите внимание, что restore не отключает маскировку, а только возвращает ее к состоянию, имевшему место перед вызовом mask. Таким образом, вызывая mask, мы можем быть уверены, что вложенные функции не отключат маскировку. Потоки, посылающие асинхронные исключения с помощью throwTo потокам с замаскированными асинхронными исключениями, блокируются до тех пор, пока асинхронные исключения в целевом потоке не будут размаскированы. Таким образом, в критических участках кода мы можем либо вовсе игнорировать асинхронные исключения, либо обрабатывать их только там, где мы к этому готовы.
К сожалению, написанное выше является правдой только наполовину, на самом деле все несколько сложнее. В Haskell есть небольшое множество так называемых прерываемых операций (interruptible operations). К ним, например, относятся функции takeMVar, openFile и другие. Во время вызова таких функций поток может получить асинхронное исключение даже если асинхронные исключения замаскированы. Как ни странно, в большинстве случаев нам с вами хотелось бы именно такого поведения. Иначе takeMVar и прочие функции могли бы навсегда заблокировать поток. Если вы уверены, что в вашем случае это никогда не произойдет, то можете совсем запретить асинхронные исключения, используя функцию uninterruptibleMask вместо mask. Но делать так считается грязным хаком и не рекомендуется.
Что еще нужно знать об асинхронных исключениях и их маскировке? Не так уж много. Во-первых, при использовании функций из семейства catch в обработчиках исключений используется установленный ранее режим маскировки. Таким образом, если поток получит два асинхронных исключения, второе не выкинет нас из обработчика исключений. Обратите внимание, что это не относится к функциям семейства try (о них чуть ниже), поскольку фактически они не делают никакой обработки, а только возвращают результат. Во-вторых, нити, создаваемые с помощью forkIO, наследуют режим маскировки от родительской нити. Например, вы можете сказать mask $ \_ -> forkIO
, чтобы асинхронные исключения в дочернем потоке были замаскированы сразу, а не спустя какое-то время после его создания. Если такое поведение forkIO является нежелательным, используйте функцию forkIOUnmasked. Наконец, в-третьих, для отладки удобно использовать функцию getMaskingState, возвращающую текущее состояние маскировки.
Все это, конечно, может звучать немного запутанно, но хорошая новость заключается в том, что напрямую работать с асинхронными исключениями приходится крайне редко. Haskell поощряет написание чистого кода, в котором про асинхронные исключения можно забыть. Большинство функций, всякие там bracket и modifyMVar, а также все функции, работающие с Handle, являются безопасными в плане асинхронных исключений «из коробки». Когда же вы используете STM, все операции выполняются атомарно. При возникновении исключений, в том числе асинхронных, вся транзакция откатывается, благодаря чему данные остаются в непротиворечивом состоянии. Наконец, если по каким-то причинам вы пишите сложный код, работающий с кучей всяких там MVar, ничто не мешает просто обернуть этот код в mask.
Разумеется, хаскелисты придумали все эти заумные асинхронные исключения не просто так. Благодаря асинхронным исключениям у потоков почти всегда есть шанс прибраться за собой. Любой код, даже написанный кем-то другим, всегда прерываем. Но самое главное, с помощью асинхронных исключений можно легко и непринужденно прерывать операции по таймауту при помощи throwTo и делать многие другие полезные вещи. Никого же не смущают сигналы в Erlang’е, а ведь асинхронные исключения — это ничто иное, как их аналог.
Заключение
Нельзя не отметить, что есть много других функций для работы с исключениями: assert, try, tryJust, catchJust, handleJust, catches… Например, последняя позволяет устанавливать сразу несколько обработчиков исключений, а не один. Внутри монады STM исключения следует бросать при помощи функции throwSTM, а ловить — при помощи catchSTM. Но пользы от последней довольно мало, так как все изменения, произведенные в транзакции, откатываются перед вызовом обработчика исключений. Ну и не слишком спешите применять полученные знания на практике, ведь IO (Maybe a)
тоже неплохо работает.
Дополнительные материалы:
- Лучше всего про исключения в Haskell написано в восьмой и девятой главах книги «Parallel and Concurrent Programming in Haskell»;
- Описание многих тонких моментов можно найти в документации к модулю Control.Exception;
- Обратите пристальное внимание на пакет errors, он заслуживает быть использованным в любом более-менее серьезном проекте на Haskell;
- Занятный обзор способов обработки ошибок в Haskell;
- Michael Snoyman о том, как поймать вообще все исключения;
Как обычно, замечания, дополнения и прочего рода комментарии приветствуются!
Дополнение: В GHC 7.8 немного изменилась обработка исключений, появился тип SomeAsyncException.
Метки: Haskell, Функциональное программирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.