Скандальная правда об обработке исключений в 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:

ghci> :t error
error :: [Char] -> a
ghci> error "something impossible just happened"
*** Exception: something impossible just happened

Ловятся исключения при помощи функции catch:

ghci> :m + Control.Exception
ghci> :t catch
catch :: Exception e => IO a -> (e -> IO a) -> IO a
ghci> catch (error "fail!") $ \e -> print (e :: ErrorCall)
fail!

… или функции handle, отличающейся от catch только порядком аргументов:

handle :: Exception e => (e -> IO a) -> IO a -> IO a

Как видите, здесь мы используем модуль Control.Exception. В Haskell нет какого-то специального синтаксиса для обработки исключений, все делается только при помощи функций и типов, сосредоточенных в этом модуле. Это, кстати, является прекрасным примером создания предметно-ориентированного языка при помощи комбинаторов. Если вам попадется туториал, рекомендующий использовать для обработки исключений функции из модулей Prelude и System.IO.Error, знайте, перед вами устаревшая информация. На самом деле, модуль System.IO.Error все еще используется, но только потому что в нем есть функции для работы с исключениями типа IOError (который представляет собой всего лишь синоним типа IOException), например, isDoesNotExistError, isPermissionError и другие. Еще один тонкий момент состоит в том, что в GHC до версии 7.6 модуль Prelude также экспортировал функцию catch, поэтому в коде приходилось писать:

import Prelude hiding (catch)

Если вы используете GHC 7.6 или старше, этого больше делать не нужно.

В чистых функциях исключения бросаются с помощью функции throw, а внутри монады IO — с помощью throwIO:

throw :: Exception e => e -> a
throwIO :: Exception e => e -> IO a

Функция error представляет собой всего лишь обертку над throw:

error :: [Char] -> a
error s = throw (ErrorCall s)

В отличие от throw функция throwIO гарантирует правильный порядок выполнения операций внутри монады IO. Аналогично при работе с транзакционной памятью вместо throw следует использовать функцию throwSTM.

Вы могли обратить внимание, что при использовании catch в обработчике исключения был указан точный тип исключения, ErrorCall. Если указать другой тип, исключение не будет поймано:

ghci> catch (error "fail!") $ \e -> print (e :: IOException)
*** Exception: fail!

Возникает закономерный вопрос — а есть ли способ поймать исключение любого типа? Оказывается, что есть. Дело в том, что исключения в Haskell образуют иерархию, корнем которой является тип SomeException. Если указать этот тип, будет поймано любое исключение:

ghci> catch (error "fail!") $ \e -> print (e :: SomeException)
fail!

Заметьте, что этот прием следует использовать только для отладки и освобождения ресурсов перед повторным бросанием исключения. Ловить SomeException в остальных случаях считается code smell.

Создание новых типов исключений

В модуле Control.Exception вы найдете немало различных типов исключений — ArithException, ErrorCall, IOException и другие. Также ничто не мешает объявлять собственные исключения. Но сначала немного теории.

Класс типов Exception представляет собой довольно простую фигню:

class (Typeable e, Show e) => Exception e where
  toException :: e -> SomeException
  fromException :: SomeException -> Maybe e

… а SomeException при этом определяется следующим образом:

{-# LANGUAGE ExistentialQuantification, DeriveDataTypeable #-}

data SomeException = forall e . Exception e => SomeException e
  deriving Typeable

Если вы раньше не сталкивались с экзистенциальными типами, загляните на Haskell Wiki. Это очень простое расширение GHC, разобраться в котором займет у вас максимум 5 минут. Несмотря на свою простоту, экзистенциальные типы позволяют использовать в Haskell элементы динамический типизации, а также строить иерархии типов (ООП в функциональном языке!), что, собственно, и делается в случае с SomeException.

Typeable также представляет собой совершенно обычный класс типов:

data TypeRep =
  -- какое-то определение
  deriving (Eq, Ord, Show)

class Typeable a where
  typeOf :: a -> TypeRep

Вот как это примерно работает:

ghci> :m + Data.Typeable
ghci> typeOf True
Bool
ghci> :set -XDeriveDataTypeable
ghci> data X = X deriving Typeable
ghci> typeOf X
X

Пожалуйста — еще один пример рефлексии в Haskell.

Итак, чтобы объявить собственный тип исключения, нам всего лишь нужно ввести новый тип, являющийся экземпляром классов Show, Typeable и Exception. Давайте попробуем:

ghci> data MyError = MyError String deriving (Show, Typeable)
ghci> instance Exception MyError
ghci> (throw $ MyError "fail!") `catch` \e -> print (e :: MyError)
MyError "fail!"

Надо же, работает!

Функция bracket и компания

Еще три функции, которые следует иметь ввиду при работе с исключениями — это bracket, finally и onException:

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
finally :: IO a -> IO b -> IO a
onException :: IO a -> IO b -> IO a

Допустим, мы работаем с некоторым недешевым ресурсом, например, файловым дескриптором. Что произойдет, если во время работы с ним будет брошено исключение? Будет ли файл закрыт? На самом деле, будет, по крайней мере, при использовании GHC, но только когда до файлового дескриптора доберется сборщик мусора. А произойти это может спустя неопределенное время, за которое мы успеем наоткрывать еще кучу файлов. Налицо утечка ресурсов. Решение проблемы заключается в том, чтобы освободить ресурс сразу после окончания работы с ним, независимо от того, было брошено исключение, или нет.

Для этого и нужна функция bracket. Первым аргументом она принимает функцию, выделяющую ресурс, вторым — функцию, освобождающую ресурс, а третьим — функцию, непосредственно использующую этот ресурс. Ресурс освобождается независимо от того, было брошено при работе с ним исключение, или нет. Если было брошено исключение, bracket бросает его повторно после освобождения ресурса. Вот, например, как выглядит функция withFile из модуля System.IO:

withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
withFile name mode = bracket (openFile name mode) hClose

Функция finally похожа на bracket. Сначала вызывается функция, переданная первым аргументом, затем — вторым. Причем вторая функция будет вызвана всегда, независимо от того, бросит первая исключение, или нет. В отличие от finally функция onException вызывает функцию, переданную вторым аргументом, только в случае, если первая функция бросит исключение. Как onException, так и finally бросают пойманные исключения повторно.

Асинхронные исключения

До сих пор все было не слишком сложно, правда? Сейчас начнется самое интересное. Дело в том, что помимо обычных, синхронных исключений, в Haskell бывают еще и асинхронные исключения. Асинхронные исключения приходят выполняющимся потокам извне и могут выстреливать в любое время. Как было отмечено в заметке Работа с нитями/потоками в Haskell, в некоторых случаях система времени выполнения умеет обнаруживать возникновение дэдлоков и посылать заблокированным нитям исключение BlockedIndefinitelyOnMVar. Аналогично при работе с транзакционной памятью в этом случае посылается исключение BlockedIndefinitelyOnSTM. Еще асинхронные исключения приходят при переполнении стека или кучи, а также при нажатии пользователем Ctr+C:

ghci> threadDelay 5000000 `catch` \e -> print (e :: AsyncException)
^Cuser interrupt

На самом деле, любая нить может послать любой другой нити абсолютно любое асинхронное исключение при помощи функции throwTo:

throwTo :: Exception e => ThreadId -> e -> IO ()

Итак, оказывается, в любое время может быть брошено любое исключение. Понятно, что в некоторых случаях это нежелательно. Например, рассмотрим такую реализацию функции bracket:

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
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:

mask :: ((IO a -> IO a) -> IO b) -> IO b

Эта функция имеет довольно сложный (и, на самом деле, несколько иной) тип, поэтому давайте лучше посмотрим, как она используется. Настоящая функция bracket, экспортируемой модулем Control.Exception, выглядит так:

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
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.

Кстати, пользуясь случаем, вновь призываю вас поддержать нашу кампанию на BoomStarter по сбору средств на запись второго сезона EaxCast. Поддержать можно как рублем, так и рассказав о кампании друзьям. Помогите нам сделать хороший, годный программерский подкаст!

Метки: , .

Подпишитесь на блог с помощью RSS, E-Mail, Google+ или Twitter.

Понравился пост? Поделитесь с другими: