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

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.