В 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
= AnyNumber (1 :: Integer)
int = AnyNumber (1.0 :: Double)
float = AnyNumber (1.0 :: Rational)
ratio
negateAnyNumber :: AnyNumber -> AnyNumber
AnyNumber x) = AnyNumber (negate x) negateAnyNumber (
== AnyNumber (-1)
negateAnyNumber int == AnyNumber (-1.0)
negateAnyNumber float == AnyNumber ((-1) % 1) negateAnyNumber ratio
Здесь следует отметить, что мы не можем определить функцию 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
AnyNumber x) (AnyNumber y) =
sumAnyNumbers (AnyNumber . (y +) <$> cast x
cast
, определённый для Typeable
типов, возвращает Just x
, если типы x
и y
эквивалентны:
== Just (AnyNumber 2.0)
sumAnyNumbers float float == Nothing sumAnyNumbers int ratio
Аналогично работает функция 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 ()
= do
main let someValue = 2 `div` 0
print someValue
`catch` \(e :: ArithException)
-> putStrLn
$ "Got arithmetic error: "
<> displayException e
Как и говорилось выше, даже если исключение брошено из чистого кода, поймать его можно только в IO
.
Здесь следует отметить, что catch
в такой записи относится только к последней строчке. Если исключение произойдёт раньше, оно не будет поймано:
main :: IO ()
= let someValue = 2 `div` 0
main in someValue `seq` (print someValue
`catch` \(e :: ArithException)
-> putStrLn
$ "Got arithmetic error: "
<> displayException e)
не поймает исключение, поскольку оно бросается до вычисления print someValue
.
Одна особенность касается отступов: если catch
(или любой другой инфиксный оператор) записан на верхнем уровне do-блока, то он относится ко всему do-блоку:
main :: IO ()
= do
main let someValue = 2 `div` 0
`seq` print someValue
someValue `catch` \(e :: ArithException)
-> putStrLn
$ "Got arithmetic error: "
<> displayException e
означает
main :: IO ()
= (do
main let someValue = 2 `div` 0
`seq` print someValue
someValue `catch` \(e :: ArithException)
) -> putStrLn
$ "Got arithmetic error: "
<> displayException e
Вызовы catch
можно комбинировать, чтобы ловить различные исключения:
main :: IO ()
= do
main 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 ()
= do
main let someValue = 2 `div` 0
print someValue
`catches` [ Handler arithmetic
Handler otherError ]
, where
arithmetic :: ArithException -> IO ()
= putStrLn
arithmetic e $ "Got arithmetic error: "
<> displayException e
otherError :: SomeException -> IO ()
= putStrLn
otherError e $ "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 ()
= do
testDiv d <- try $ print $ 5 `div` read d
result case result of
Left e -> print (e :: SomeException)
Right x -> return ()
main :: IO ()
= getArgs >>= mapM_ testDiv main
Выполнение программы с аргументами командной строки (в GHCi это можно сделать командой :main
) даст вывод:
:main 1 a 2 0 4
5
: no parse
Prelude.read2
divide by zero1
Выбрасывание исключений
Выбрасывание исключений в 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 ()
= do
randomException <- randomRIO (1, 10 :: Int)
i if i `elem` [1..9]
then throwIO DivideByZero
else throwIO StackOverflow
main :: IO ()
= do
main <- newIORef 0
ref do
forever (<- try @ArithException randomException
_ +1))
modifyIORef ref (`catch` \(e :: SomeException) -> do
<- readIORef ref
n 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 all isAscii str) $
unless (NotValidString
throwIO
main :: IO ()
= lines <$> getContents
main >>= mapM_ (\v ->
isStringOk v`catch` (\(e :: NotValidString) -> print e))
Понятно, что тип ошибки может быть типом-суммой, и включать дополнительные аргументы в конструкторах.
Взаимодействие с ⊥
Ловить исключения, связанные с ⊥ – в общем неблагодарное занятие. В силу того, что вычисления нестрогие, значение может быть форсировано до или после обработчика, как следствие программа не обработает ошибку.
Например,
= print =<< try @SomeException (undefined :: IO Int) main
и
= print =<< try @SomeException (return undefined :: IO Int) main
имеют различное поведение. “Оборачивание” ⊥ в 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 ()
= bracket
appendToFile fp text AppendMode)
(openFile fp $ \h -> hPutStrLn h text hClose
(вообще то же самое делает функция withFile
):
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
= bracket (openFile name mode) hClose withFile name mode
throwTo
кидает асинхронное исключение в поток. В качестве примера,
import Control.Concurrent (forkIO, threadDelay)
import Control.Exception
= do
main <- forkIO $ interact id
thread $ 5*1000000
threadDelay ThreadKilled throwTo thread
Отдельно следует отметить, что обработчики в catch
замаскированы, но try
– не маскируется. Поэтому обрабатывать асинхронные исключения желательно с помощью catch
, иначе возможно возникновение асинхронного исключения в процессе обработки асинхронного исключения, что приведёт, опять же, к краху обработчика.
Общая рекомендация при работе с асинхронными исключениями: использовать bracket
и не пытаться поймать все возможные исключения. Критические инфраструктурные процессы проще перезапустить, чем пытаться восстановиться после, скажем, переполнения кучи.