Обработка исключений

В Haskell существует два метода обработки ошибок. С первым мы уже встречались, когда говорили о монадах Either и Maybe и трансформаторе ExceptT. Этот метод хорошо работает в чистом коде, когда нужно обрабатывать строго определённое ограниченное множество исключительных ситуаций.

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

Кроме того, чистый код тоже может генерировать исключения, в частности, исключения могут возникать из:

  • Частичных функций
  • Неопровержимых шаблонов
  • Функции error

Такие исключения тоже может быть необходимо обрабатывать.

Встроенный механизм исключений Haskell поддерживает следующие типы исключений:

  • Неточные исключения – исключения, генерируемые чистым кодом
  • Асинхронные исключения – исключения, бросаемые потоком управления, которые должны быть обработаны в другом потоке управления
  • Исключения в IO – исключения, бросаемые кодом, который работает с “внешним миром”.

Основной модуль, в котором определены примитивы для работы с исключениями – это модуль Control.Exception.

Основное, что нужно помнить о модели исключений – что они могут быть потенциально брошены откуда угодно, но обработаны – только в IO.

Следует отметить, что отдельные монады могут предоставлять собственные способы бросания и обработки исключений, которые никак не связаны с основным механизмом исключений Haskell. Существует пакет exceptions, который стремится объединить различные способы бросания/обработки исключений в единый интерфейс.

Класс Exception

Сами исключения – это обычные типы и значения. Однако типы, которые представляют исключения, должны принадлежать классу Exception:

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

Этот класс определяет, во-первых, описание ошибки (в функции displayException), во-вторых, преобразования в/из экзистенциальный тип SomeException.

Список встроенных типов, которые принадлежат классу Exception довольно длинный:

IOException, Deadlock, BlockedIndefinitelyOnSTM, AsyncException, AssertionFailed и т.д. Мы рассмотрим для примера тип ArithException, который отражает исключения в арифметике:

data ArithException
  = Overflow
  | Underflow
  | LossOfPrecision
  | DivideByZero
  | Denormal
  | RatioZeroDenominator
  deriving (Eq, Ord, Show)

Экзистенциальная квантификация

Рассмотрим, что из себя представляют функции

toException :: e -> SomeException
fromException :: SomeException -> Maybe e

и тип SomeException.

Начнём с типа:

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

Такое объявление требует включение расширения ExistentialQuantification. Это расширение позволяет определить экзистенциально-полиморфный конструктор типа. Следует отметить, что это не то же самое, что полиморфный тип.

Тип

data Polymorphic a = Polymorphic a

не позволяет использовать значения Polymorphic 1 и Polymorphic "1" в одном контексте, поскольку это значения разных типов: Polymorphic Int и Polymorphic String. В каждом конкретном случае значение типа a будет инстанцировано конкретным типом.

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

Ключевой момент здесь в том, что поскольку значение типа может содержать что угодно, мы не можем сделать со значением в экзистенциальном типе ничего, что не можем сделать с любым значением, которое там может быть. Таким образом, мы не можем применить к значению в SomeException никаких функций, кроме определённых для класса Exception.

Рассмотрим простой пример:

data AnyNumber = forall a. (Show a, Num a) => AnyNumber a

instance Show AnyNumber where
  showsPrec d (AnyNumber x)
    = showParen (d > app_prec)
    $ showString "AnyNumber "
    . showsPrec (app_prec+1) x
    where app_prec = 10

int = AnyNumber (1 :: Integer)
float = AnyNumber (1.0 :: Double)
ratio = AnyNumber (1.0 :: Rational)

negateAnyNumber :: AnyNumber -> AnyNumber
negateAnyNumber (AnyNumber x) = AnyNumber (negate x)
negateAnyNumber int == AnyNumber (-1)
negateAnyNumber float == AnyNumber (-1.0)
negateAnyNumber ratio == AnyNumber ((-1) % 1)

Здесь следует отметить, что мы не можем определить функцию sumAnyNumbers :: AnyNumber -> AnyNumber -> AnyNumber, поскольку аргументы могут содержать разные типы, и нет доступного способа привести их к заведомо одному и тому же (поскольку класс Num не имеет таких функций)

Typeable

Класс Typeable определён в модуле Data.Typeable. Это класс, который позволяет производить проверку типов во время выполнения, т.е. динамически. Он позволяет узнать тип значения во время выполнения и сравнить типы разных значений. Он автоматически выводится для всех типов в GHC.

Если мы добавим в экзистенциальный тип ограничение Typeable, то мы сможем определить для приведённого выше AnyNumber бинарные операции:

import Data.Typeable

data AnyNumber
  = forall a. (Typeable a, Show a, Num a)
      => AnyNumber a

-- snip

sumAnyNumbers ::
  AnyNumber -> AnyNumber -> Maybe AnyNumber
sumAnyNumbers (AnyNumber x) (AnyNumber y) =
  AnyNumber . (y +) <$> cast x

cast, определённый для Typeable типов, возвращает Just x, если типы x и y эквивалентны:

sumAnyNumbers float float == Just (AnyNumber 2.0)
sumAnyNumbers int ratio == Nothing

Аналогично работает функция fromException, обычно реализуемая в терминах cast.

В процессе выполнения, когда кидается исключение, ищется catch выше по стеку вызовов. Затем проверяется тип исключения, которое ловит этот catch. Если catch ловит SomeException, то он будет совпадать со всеми исключениями. Если же используется более конкретный тип, то fromException вернёт Nothing и поиск продолжится.

Обработка исключений

В Control.Exception определена функция catch :: Exception e => IO a -> (e -> IO a) -> IO a. Посмотрим, как можно её использовать:

{-# LANGUAGE ScopedTypeVariables #-}
import Control.Exception

main :: IO ()
main = do
  let someValue = 2 `div` 0
  print someValue
    `catch` \(e :: ArithException)
      -> putStrLn
         $ "Got arithmetic error: "
         <> displayException e

Как и говорилось выше, даже если исключение брошено из чистого кода, поймать его можно только в IO.

Здесь следует отметить, что catch в такой записи относится только к последней строчке. Если исключение произойдёт раньше, оно не будет поймано:

main :: IO ()
main =   let someValue = 2 `div` 0
  in someValue `seq` (print someValue
    `catch` \(e :: ArithException)
      -> putStrLn
       $ "Got arithmetic error: "
       <> displayException e)

не поймает исключение, поскольку оно бросается до вычисления print someValue.

Одна особенность касается отступов: если catch (или любой другой инфиксный оператор) записан на верхнем уровне do-блока, то он относится ко всему do-блоку:

main :: IO ()
main = do
  let someValue = 2 `div` 0
  someValue `seq` print someValue
  `catch` \(e :: ArithException)
    -> putStrLn
       $ "Got arithmetic error: "
       <> displayException e

означает

main :: IO ()
main = (do
  let someValue = 2 `div` 0
  someValue `seq` print someValue
  ) `catch` \(e :: ArithException)
    -> putStrLn
       $ "Got arithmetic error: "
       <> displayException e

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

main :: IO ()
main = do
  let someValue = 2 `div` 0
  error "Some error"
  print someValue
  `catch` (\(e :: ArithException)
    -> putStrLn
       $ "Got arithmetic error: "
       <> displayException e)
  `catch` (\(e :: SomeException)
    -> putStrLn
       $ "Got other error: "
       <> displayException e)

правда, лямбда-функции придётся взять в скобки.

Обычно вместо лямбда-функций пишутся отдельные функции-обработчики, а вместо вложенных catch используется функция catches:

main :: IO ()
main = do
  let someValue = 2 `div` 0
  print someValue
  `catches` [ Handler arithmetic
            , Handler otherError ]
  where
  arithmetic :: ArithException -> IO ()
  arithmetic e = putStrLn
       $ "Got arithmetic error: "
       <> displayException e
  otherError :: SomeException -> IO ()
  otherError e = putStrLn
      $ "Got other error: "
      <> displayException e

Handler – это экзистенциальный тип, оборачивающий теперь уже обработчик исключений.

try

Иногда удобно преобразовывать исключения в явные значения типа Either. Для этого есть функция try :: Exception e => IO a -> IO (Either e a)

Рассмотрим пример:

import Control.Exception
import System.Environment

testDiv :: String -> IO ()
testDiv d = do
  result <- try $ print $ 5 `div` read d
  case result of
    Left e -> print (e :: SomeException)
    Right x -> return ()

main :: IO ()
main = getArgs >>= mapM_ testDiv

Выполнение программы с аргументами командной строки (в GHCi это можно сделать командой :main) даст вывод:

:main 1 a 2 0 4
5
Prelude.read: no parse
2
divide by zero
1

Выбрасывание исключений

Выбрасывание исключений в IO производится функцией throwIO :: Exception e => e -> IO a:

{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module Test where

import Control.Exception
import Data.IORef
import System.Random (randomRIO)
import Control.Monad (forever)

randomException :: IO ()
randomException = do
  i <- randomRIO (1, 10 :: Int)
  if i `elem` [1..9]
    then throwIO DivideByZero
    else throwIO StackOverflow

main :: IO ()
main = do
  ref <- newIORef 0
  forever (do
    _ <- try @ArithException randomException
    modifyIORef ref (+1))
    `catch` \(e :: SomeException) -> do
      n <- readIORef ref
      putStrLn $ "Ran " <> show n <> " times"

Вообще говоря, есть функция throw, которая бросает исключения без IO:

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

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

Объявление собственного типа исключений

Экземпляры Exception самовыводящиеся, т.е. досаточно написать instance Exception MyErrorType. Само собой, можно переопределить функции экземпляра:

{-# LANGUAGE ScopedTypeVariables #-}
import Control.Exception
import Data.Char (isAscii)
import Control.Monad (unless)

data NotValidString = NotValidString
  deriving (Eq, Show)

instance Exception NotValidString

isStringOk :: String -> IO ()
isStringOk str =
  unless (all isAscii str) $
    throwIO NotValidString

main :: IO ()
main = lines <$> getContents
  >>= mapM_ (\v ->
    isStringOk v
    `catch` (\(e :: NotValidString) -> print e))

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

Взаимодействие с ⊥

Ловить исключения, связанные с ⊥ – в общем неблагодарное занятие. В силу того, что вычисления нестрогие, значение может быть форсировано до или после обработчика, как следствие программа не обработает ошибку.

Например,

main = print =<< try @SomeException (undefined :: IO Int)

и

main = print =<< try @SomeException (return undefined :: IO Int)

имеют различное поведение. “Оборачивание” ⊥ в return позволяет ему выйти за пределы try, поскольку try не форсирует результат вычисления.

Аналогично с осторожностью следует относиться ко всем исключениям, которые кидаются из чистого кода – исключение будет брошено в том месте, где значение форсируется, что не обязательно то же место, где значение ловится. Это ещё одна причина почему не стоит использовать throw и error в случаях, когда предполагается продолжать работу программы.

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

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

Асинхронные исключения – достаточно редкое явление в языках программирования. Смысл в том, что исключение может быть брошено между потоками: один поток бросает исключение в другой. Это в основном актуально, понятно, для многопоточного кода, но следует иметь ввиду, что асинхронные исключения могут быть брошены в любой момент ввиду переполнения стека, кучи, если ОС по какой-то причине пытается “убить” поток и т.п. При работе с критичными данными, следует это учитывать.

Для работы с асинхронными исключениями используются функции:

bracket ::
 IO a -> (a -> IO b) -> (a -> IO c) -> IO c
mask :: ((forall a. IO a -> IO a) -> IO b) -> IO b
throwTo :: Exception e => ThreadId -> e -> IO ()

mask – низкоуровневый примитив, который используется для временного “отключения” асинхронных исключений. В основном, не следует его использовать напрямую.

bracket – это паттерн, абстрагирующий работу с ресурсом. Первый аргумент получает ресурс, второй – освобождает, третий – производит операцию с ресурсом. При этом асинхронные исключения “замаскированы” с момента получения до момента освобождения. Например, работу с файлом можно реализовать таким образом:

import Control.Exception
import System.IO
appendToFile :: FilePath -> String -> IO ()
appendToFile fp text = bracket
  (openFile fp AppendMode)
  hClose $ \h -> hPutStrLn h text

(вообще то же самое делает функция withFile):

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

throwTo кидает асинхронное исключение в поток. В качестве примера,

import Control.Concurrent (forkIO, threadDelay)
import Control.Exception

main = do
  thread <- forkIO $ interact id
  threadDelay $ 5*1000000
  throwTo thread ThreadKilled

Отдельно следует отметить, что обработчики в catch замаскированы, но try – не маскируется. Поэтому обрабатывать асинхронные исключения желательно с помощью catch, иначе возможно возникновение асинхронного исключения в процессе обработки асинхронного исключения, что приведёт, опять же, к краху обработчика.

Общая рекомендация при работе с асинхронными исключениями: использовать bracket и не пытаться поймать все возможные исключения. Критические инфраструктурные процессы проще перезапустить, чем пытаться восстановиться после, скажем, переполнения кучи.