Обработка исключений, возникших в чистом коде

В стандарте языка Haskell 98 года присутствует механизм обработки исключений ввода-вывода, который в настоящее время считается устаревшим. Согласно современному подходу все исключения, возникшие как при выполнении чистого кода, так и при осуществлении ввода-вывода, должны обрабатываться единообразно. Этой цели служит единая иерархия типов исключений из модуля Control.Exception, в которую легко можно включать собственные типы исключений. Любой тип исключения должен реализовывать экземпляр класса типов Exception. В модуле Control.Exception объявлено несколько конкретных типов исключений, среди которых IOException (исключения ввода-вывода), ArithException (арифметические ошибки, например, деление на ноль), ErrorCall (вызов функции error), PatternMatchFail (не удалось выбрать подходящий образец в определении функции) и другие.

Простейший способ выполнить действие, которое потенциально может вызвать исключение,– воспользоваться функцией try:

try :: Exception e => IO a -> IO (Either e a)

Функция try пытается выполнить переданное ей действие ввода-вывода и возвращает либо Right <результат действия> либо Left <исключение>, например:

ghci> try (print $ 5 `div` 2) :: IO (Either ArithException ())

2

Right ()

ghci> try (print $ 5 `div` 0) :: IO (Either ArithException ())

Left divide by zero

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

> try (print $ 5 `div` 0) :: IO (Either IOException ())

*** Exception: divide by zero

Указание типа SomeException позволяет обнаружить любое исключение:

ghci> try (print $ 5 `div` 0) :: IO (Either SomeException ())

Left divide by zero

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

import Control.Exception

import System.Environment

printQuotients :: Integer -> Integer -> IO ()

printQuotients a b = do

  print $ a `div` b

  print $ b `div` a

params :: [String] -> (Integer, Integer)

params [a,b] = (read a, read b)

main = do

  args <- getArgs

  let (a, b) = params args

  res <- try (printQuotients a b) :: IO (Either ArithException ())

  case res of

    Left e -> putStrLn "Деление на 0!"

    Right () -> putStrLn "OK"

  putStrLn "Конец программы"

Погоняем программу на различных значениях:

$ ./quotients 20 7

2

0

OK

Конец программы

$ ./quotients 0 7

0

Деление на 0!

Конец программы

$ ./quotients 7 0

Деление на 0!

Конец программы

Понятно, что пока эта программа неустойчива к другим видам ошибок. В частности, мы можем «забыть» передать параметры командной строки или передать их не в том количестве:

$ ./quotients

quotients: quotients.hs:10:1-31: Non-exhaustive patterns in function params

$ ./quotients 2 3 4

quotients: quotients.hs:10:1-31: Non-exhaustive patterns in function params

Это исключение генерируется при вызове функции params, если переданный ей список оказывается не двухэлементным. Можно также указать нечисловые параметры:

$ ./quotients a b

quotients: Prelude.read: no parse

Исключение здесь генерируется функцией read, которая не в состоянии преобразовать переданный ей параметр к числовому типу.

Чтобы справиться с любыми возможными исключениями, выделим тело программы в отдельную функцию, оставив в функции main получение параметров командной строки и обработку исключений:

mainAction :: [String] -> IO ()

mainAction args = do

  let (a, b) = params args

  printQuotients a b

main = do

  args <- getArgs

  res <- try (mainAction args) :: IO (Either SomeException ())

  case res of

    Left e -> putStrLn "Ошибка"

    Right () -> putStrLn "OK"

  putStrLn "Конец программы"

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

$ ./quotients a b

Ошибка

Конец программы

$ ./quotients

Ошибка

Конец программы

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

handleArith :: ArithException -> IO ()

handleArith _ = putStrLn "Деление на 0!"

handleArgs :: PatternMatchFail -> IO ()

handleArgs _ = putStrLn "Неверное число параметров командной строки!"

handleOthers :: SomeException -> IO ()

handleOthers e = putStrLn $ "Неизвестное исключение: " ++ show e

К сожалению, чтобы увидеть исключение от функции read, нужно воспользоваться наиболее общим типом SomeException.

Вместо того чтобы вручную вызывать функцию обработчика при анализе результата try, можно применить функцию catch, вот её тип:

ghci> :t catch

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

ПРИМЕЧАНИЕ. Модуль Prelude экспортирует старую версию функции catch, которая способна обрабатывать только исключения ввода-вывода. Чтобы использовать новый вариант её определения, необходимо использовать скрывающий импорт: import Prelude hiding (catch).

Функция catch принимает в качестве параметров действие и обработчик исключения: если при выполнении действия генерируется исключение, то вызывается его обработчик. Тип обработчика определяет, какие именно исключения будут обработаны. Рассмотрим примеры, в которых функция mainAction вызывается непосредственно в GHCi:

ghci> mainAction ["2","0"]

*** Exception: divide by zero

ghci> mainAction ["0","2"] `catch` handleArith

0

Деление на 0!

ghci> mainAction ["2","0"] `catch` handleArgs

*** Exception: divide by zero

ghci> mainAction ["2","0"] `catch` handleOthers

Неизвестное исключение: divide by zero

ghci> mainAction ["a", "b"] `catch` handleArgs

*** Exception: Prelude.read: no parse

ghci> mainAction ["a", "b"] `catch` handleOthers

Неизвестное исключение: Prelude.read: no parse

Если строка, выводимая GHCi, начинается с ***, то соответствующее исключение не было обработано. Обратите внимание на обычный для функции catch инфиксный способ вызова. Заметьте также, что обработчик handleOthers способен обработать любое исключение.

Вернёмся к основной программе. Нам хочется, чтобы возникшее исключение было обработано наиболее подходящим образом: если произошло деление на ноль, то следует выполнить handleArith, при неверном числе параметров командной строки – handleArgs, в остальных случаях – handleOthers. В этом нам поможет функция catches, посмотрим на её тип:

> :t catches

catches :: IO a -> [Handler a] -> IO a

Функция catches принимает в качестве параметров действие и список обработчиков (функций, которые упакованы конструктором данных Handler) и возвращает результат действия. Если в процессе выполнения происходит исключение, то вызывается первый из подходящих по типу исключения обработчиков (поэтому, в частности, обработчик handleOthers должен быть последним). Перепишем функцию main так, чтобы корректно обрабатывались все возможные исключительные ситуации:

main = do

  args <- getArgs

  mainAction args `catches`

                   [Handler handleArith,

                    Handler handleArgs,

                    Handler handleOthers]

  putStrLn "Конец программы"

Посмотрим, как она теперь работает:

$ ./quotients 20 10

2

0

Конец программы

$ ./quotients

Неверное число параметров командной строки!

Конец программы

$ ./quotients 2 0

Деление на 0!

Конец программы

$ ./quotients a b

Неизвестное исключение: Prelude.read: no parse

Конец программы

В этом разделе мы разобрались с работой функций try, catch и catches, позволяющих обработать исключение, в том числе и возникшее в чистом коде. Заметьте ещё раз, что вся обработка выполнялась в рамках действий ввода-вывода. Посмотрим теперь, как работать с исключениями, которые возникают при выполнении операций ввода-вывода.