Умная проверка
date = fromGregorian 2015 may 04
category = "Теория"
tags = ["обработка ошибок"]
Перевод с английского - Денис Шевченко
Сегодня мы рассмотрим различные способы обработки ошибок в Haskell. Начнём с хорошо известной монады Either
, далее перейдём к менее распространённому аппликативному функтору Validation
, а затем повысим эффективность и удобство последнего.
В данной статье вы найдёте ряд упражнений, выполнение которых, надеюсь, поможет вам лучше понять поднятые здесь вопросы.
Пример
Итак, начнём:
{-# LANGUAGE GeneralizedNewtypeDeriving, KindSignatures, DataKinds,
ScopedTypeVariables, RankNTypes, DeriveFunctor #-}
import Text.Printf
import Text.Read
import Control.Monad
import Control.Applicative
import Control.Applicative.Lift (Lift)
import Control.Arrow (left)
import Data.Functor.Constant (Constant)
import Data.Monoid
import Data.Traversable (sequenceA)
import Data.List (intercalate, genericTake, genericLength)
import Data.Proxy
import System.Exit
import System.IO
import GHC.TypeLits
В нашем примере мы будем читать список целых чисел из файла, по одному числу на строку, а затем выведем сумму этих чисел. Вот простейший способ сделать это:
printSum1 :: FilePath -> IO ()
= print . sum . map read . lines =<< readFile path printSum1 path
Этот код работает именно так, как мы ожидаем, однако есть одна проблема: если строка будет содержать не число, а что-нибудь другое, мы получим невнятную ошибку вида:
Prelude.read: no parse
Монада Either
Перепишем нашу функцию, чтобы возможные ошибки не прошли мимо нас:
parseNum :: Int -- Номер строки (для отчёта об ошибке)
-> String -- Содержимое строки
-> Either String Integer
-- Здесь будет либо число, извлечённое из строки, либо сообщение об ошибке
=
parseNum ln str case readMaybe str of
Just num -> Right num
Nothing -> Left $
"Bad number on line %d: %s" ln str
printf
-- Печатаем сообщение и выходим
die :: String -> IO ()
= do
die msg
hPutStrLn stderr msg
exitFailure
printSum2 :: FilePath -> IO ()
=
printSum2 path either die print .
sum .
liftM sequence . zipWith parseNum [1..] .
lines =<< readFile path
Теперь, если на одной из строк нашего файла окажется какой-нибудь хлам, мы увидим нечто подобное:
Bad number on line 2: foo
Это стандартный способ использования монады Either
, так что в детали вдаваться я не хочу. Уточню лишь, что данный код имеет два отличия от кода предыдущего:
- Мы используем функцию
readMaybe
вместоread
, что помогает нам сформировать вменяемое сообщение об ошибке. Именно для этого нам нужен номер проблемной строки. - Вместо того, чтобы кидать исключение (с помощью функции
error
), мы возвращаем чистоеEither
-значение, а затем комбинируем все этиEither
-значения, используя монадическую природуEither
.
Два эти отличия независимы друг от друга: мы вполне могли бы использовать функцию error
и при этом формировать нормальное сообщение о проблеме. Исключения, порождаемые монадой Either
, обладают той же семантикой, что и обыкновенные исключения. Преимущество данного решения в том, что семантика исключений времени выполнения является встроенной, в то время как семантика чистых данных - программируема, и чуть ниже мы увидим, что нам даст этот факт.
Аппликативный функтор Validation
Итак, представьте: вы получаете от вашего бухгалтера большой-пребольшой файл с числами. Бухгалтер очень просит вас посчитать сумму всех этих чисел, потому что программа, которую он обычно использует для этих целей, неожиданно упала в процессе чтения этого здоровенного файла.
Вы, засучив рукава, берётесь за дело, будучи уверенным в вашей Haskell-программе. И вот что она выдаёт вам:
Bad number on line 378: 12o0
Ага, попалась! Кто-то поставил букву o
вместо нуля. Исправим же это: переходим к строке 378, исправляем букву на цифру, сохраняем файл, и перезапускаем нашу программу. Но тут снова неприятность:
Bad number on line 380: 11i3
Так, ещё одна: вместо единицы стоит буква i
. Исправили, сохранили… Стоп. Если бы программа сообщила нам об этих двух ошибках сразу, мы бы исправили их быстрее! В самом деле, почему бы не сделать так, чтобы наша программа проверила каждую строку в файле и сообщила нам обо всех найденных ошибках сразу? Сказано - сделано. Однако в этом случае мы уже не сможем воспользоваться ни экземпляром Monad Either
, ни Applicative Either
. Нам нужен тип Validation
.
Аппликативный функтор Validation
совмещает два Either
-значения таким образом, что если они оба инициированы Left
, эти Left
-значения объединяются через моноидную операцию (на самом деле, достаточно и Semigroup
). Это позволяет нам собирать ошибки с разных строк нашего файла. Вот как это выглядит:
newtype Validation e a = Validation { getValidation :: Either e a }
deriving Functor
instance Monoid e => Applicative (Validation e) where
pure = Validation . Right
Validation a <*> Validation b = Validation $
case a of
Right va -> fmap va b
Left ea -> either (Left . mappend ea) (const $ Left ea) b
Следующий пример демонстрирует отличие между стандартным экземпляром Applicative
и Validation
:
> let e1 = Left "error1"; e2 = Left " error2"
> e1 *> e2
Left "error1"
> getValidation $ Validation e1 *> Validation e2
Left "error1 error2"
Хорошая реализация подобного аппликативного функтора имеется в пакете transformers
. Ross Paterson заметил, что этот функтор может формироваться так:
type Errors e = Lift (Constant e)
Подробности здесь - Control.Applicative.Lift. В любом случае, давайте воспользуемся этим для улучшения нашей суммирующей программы:
printSum3 :: FilePath -> IO ()
=
printSum3 path either (die . intercalate "\n") print .
sum .
liftM . sequenceA .
getValidation map (Validation . left (\e -> [e])) .
zipWith parseNum [1..] .
lines =<< readFile path
Теперь нам достаточно единожды запустить программу, чтобы разом увидеть все обнаруженные ею ошибки:
Bad number on line 378: 12o0
Bad number on line 380: 11i3
Упражнение. Можем ли мы использовать Writer [String]
для сбора всех сообщений об ошибках?
Упражнение. Когда мы склеиваем списки, существует риск прийти к квадратичной сложности. Произойдёт ли это в вышеприведённой функции? И должно ли это произойти в функции, которая использует функтор Validation
, основанный на list monoid?
Функтор Validation: ещё умнее
На следующий день бухгалтер присылает нам другой здоровенный файл с той же просьбой: вычислить сумму чисел. Бодро запускаем нашу программу и видим большущий список ошибок следующего вида:
Bad number on line 1: 27297.
Bad number on line 2: 11986.
Bad number on line 3: 18938.
Bad number on line 4: 22820.
...
Проблема налицо: каждое число в файле заканчивается точкой. Подобная проблема очень просто выявляется и исправляется, и нет никакой нужды печатать тысячи однотипных сообщений об ошибках. На самом деле, ограничивать число таких сообщений имеет смысл ради двух целей:
- Удобство. Едва ли пользователь прочтёт более, нежели, скажем, 10 сообщений за раз. И если мы попытаемся отобразить огромное количество сообщений об ошибках на веб-странице, это и работать будет медленно, и выглядеть будет ужасно.
- Эффективность. Раз уж мы согласны с тем фактом, что нет смысла выводить более 10 первых сообщений об ошибках, то, получив те самые 10 сообщений, не имеет смысла продолжать обрабатывать наш файл.
Для достижения каждой из этих двух целей нам понадобится свой механизм.
Ограниченные списки
Создадим наш собственный тип BoundedList
, похожий на список, который будет хранить первые n
элементов и игнорировать всё остальное. Это в первую очередь относится к нашей первой цели, а именно удобству пользователя (хотя достижения второй цели это тоже касается).
Несмотря на то, что для нужной нам валидации мы моглы бы явно задать ограничение в 10 сообщений, будет неплохо создать обобщённый тип с гибким ограничением. Поэтому мы сделаем это самое ограничение частью нашего типа.
Упражнение Подумайте над альтернативными решениями, когда ограничение не является частью типа. Каковы плюсы и минусы подобных решений?
На уровне значений, мы основываем новый тип на difference list, далее DL (Прим. переводчика - речь идёт о Data.DList), во избежании квадратичной сложности, упомянутой ранее:
data BoundedList (n :: Nat) a =
BoundedList
!Integer -- Текущая длина списка
Endo [a]) (
Упражнение. Почему важно хранить текущую длину, вместо того чтобы вычислять её, исходя из DL?
Итак, поняв основные идеи нашего решения (включение ограничения непосредственно в тип, использование DL, хранение текущей длины), взглянем на реализацию:
singleton :: KnownNat n => a -> BoundedList n a
= fromList [a]
singleton a
toList :: BoundedList n a -> [a]
BoundedList _ (Endo f)) = f []
toList (
fromList :: forall a n . KnownNat n => [a] -> BoundedList n a
= BoundedList (min len limit) (Endo (genericTake limit lst ++))
fromList lst where
= natVal (Proxy :: Proxy n)
limit = genericLength lst
len
instance KnownNat n => Monoid (BoundedList n a) where
mempty = BoundedList 0 mempty
mappend b1@(BoundedList l1 f1) (BoundedList l2 f2)
| l1 >= limit = b1
| l1 + l2 <= limit = BoundedList (l1 + l2) (f1 <> f2)
| otherwise = BoundedList limit (f1 <> Endo (genericTake (limit - l1)) <> f2)
where
= natVal (Proxy :: Proxy n)
limit
full :: forall a n . KnownNat n => BoundedList n a -> Bool
BoundedList l _) = l >= natVal (Proxy :: Proxy n)
full (
null :: BoundedList n a -> Bool
null (BoundedList l _) = l <= 0
SmartValidation
Теперь мы создадим аппликативный функтор SmartValidation
, который будет прекращать свою работу, как только продолжать собирать ошибки станет бессмысленным. Это нечто среднее между функтором Either
, способным хранить одну-единственную ошибку, и функтором Validation
, хранящим все ошибки.
Реализация такого функтора не столь тривиальна, какой может показаться на первый взгляд. На самом деле, прежде чем вы прочтёте код ниже, я рекомендую вам выполнить следующее упражнение:
Упражение. Попробуйте реализовать тип и его аппликативный экземпляр, соответствующие вышеозвученному требованию.
Попробовали? У вас получилось? Это не риторический вопрос, мне действительно интересно (поэтому дайте мне знать о ваших результатах). Похожа ли ваша реализация на мою, или, может, она легче? А может, наоборот, сложнее?
Итак, вот моя реалиация:
newtype SmartValidation (n :: Nat) e a = SmartValidation
getSmartValidation :: forall r .
{Either (BoundedList n e) (a -> r) -> Either (BoundedList n e) r }
deriving Functor
instance KnownNat n => Applicative (SmartValidation n e) where
pure x = SmartValidation $ \k -> k <*> Right x
SmartValidation a <*> SmartValidation b = SmartValidation $ \k ->
let k' = fmap (.) k in
case a k' of
Left errs | full errs -> Left errs
-> b r r
А вот функции для создания и анализа значений типа SmartValidation
:
-- Конвертируем SmartValidation в Either
fatal :: SmartValidation n e a -> Either [e] a
= left toList . ($ Right id) . getSmartValidation
fatal
-- Конвертируем Either в SmartValidation
nonFatal :: KnownNat n => Either e a -> SmartValidation n e a
= SmartValidation $ (\k -> k <+> left singleton a)
nonFatal a
-- Похожа на <*>, но склеивает ошибки
<+>)
( :: Monoid e
=> Either e (a -> b)
-> Either e a
-> Either e b
<+> b = case (a,b) of
a Right va, Right vb) -> Right $ va vb
(Left e, Right _) -> Left e
(Right _, Left e) -> Left e
(Left e1, Left e2) -> Left $ e1 <> e2 (
Упражнение. Подумайте над тем, что делает fmap (.) k
в определении <*>
.
Упражнение. В определении <*>
, должны ли мы проверять, является ли k
полным перед вычислением a k'
?
Упражнение. Мы разработали два механизма - BoundedList
и SmartValidation
, которые делают практически одно и то же, но на разных уровнях. Достаточен ли какой-то из этих двух механизмов для достижения обеих целей (удобство пользователя и эффективность) в том случае, если количество отображаемых ошибок будет велико?
Упражнение. Если бы функтор SmartValidation
был основан не на DL, а на обыкновенных списках, насколько велика вероятность достижения квадратичной сложности по сравнению с решением, основанным на Validation
?
Заключение
Хотя аппликативный функтор Validation
известен хаскелистам, необходимость ограничивать количество отображаемых им ошибок редко (если вообще когда-либо) обсуждалась. Реализация аппликативного функтора, ограничивающего такое количество, а также избегающего ненужной работы, довольно сложна. Я очень рад поделиться с вами моим решением, и теперь мне интересно узнать о решениях других людей, столкнувшихся с подобной проблемой.