Умная проверка

Перевод с английского - Денис Шевченко

Сегодня мы рассмотрим различные способы обработки ошибок в Haskell. Начнём с хорошо известной монады Either, далее перейдём к менее распространённому аппликативному функтору Validation, а затем повысим эффективность и удобство последнего.

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

Пример

Итак, начнём:

В нашем примере мы будем читать список целых чисел из файла, по одному числу на строку, а затем выведем сумму этих чисел. Вот простейший способ сделать это:

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

Монада Either

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

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

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

  1. Мы используем функцию readMaybe вместо read, что помогает нам сформировать вменяемое сообщение об ошибке. Именно для этого нам нужен номер проблемной строки.
  2. Вместо того, чтобы кидать исключение (с помощью функции error), мы возвращаем чистое Either-значение, а затем комбинируем все эти Either-значения, используя монадическую природу Either.

Два эти отличия независимы друг от друга: мы вполне могли бы использовать функцию error и при этом формировать нормальное сообщение о проблеме. Исключения, порождаемые монадой Either, обладают той же семантикой, что и обыкновенные исключения. Преимущество данного решения в том, что семантика исключений времени выполнения является встроенной, в то время как семантика чистых данных - программируема, и чуть ниже мы увидим, что нам даст этот факт.

Аппликативный функтор Validation

Итак, представьте: вы получаете от вашего бухгалтера большой-пребольшой файл с числами. Бухгалтер очень просит вас посчитать сумму всех этих чисел, потому что программа, которую он обычно использует для этих целей, неожиданно упала в процессе чтения этого здоровенного файла.

Вы, засучив рукава, берётесь за дело, будучи уверенным в вашей Haskell-программе. И вот что она выдаёт вам:

Ага, попалась! Кто-то поставил букву o вместо нуля. Исправим же это: переходим к строке 378, исправляем букву на цифру, сохраняем файл, и перезапускаем нашу программу. Но тут снова неприятность:

Так, ещё одна: вместо единицы стоит буква i. Исправили, сохранили… Стоп. Если бы программа сообщила нам об этих двух ошибках сразу, мы бы исправили их быстрее! В самом деле, почему бы не сделать так, чтобы наша программа проверила каждую строку в файле и сообщила нам обо всех найденных ошибках сразу? Сказано - сделано. Однако в этом случае мы уже не сможем воспользоваться ни экземпляром Monad Either, ни Applicative Either. Нам нужен тип Validation.

Аппликативный функтор Validation совмещает два Either-значения таким образом, что если они оба инициированы Left, эти Left-значения объединяются через моноидную операцию (на самом деле, достаточно и Semigroup). Это позволяет нам собирать ошибки с разных строк нашего файла. Вот как это выглядит:

Следующий пример демонстрирует отличие между стандартным экземпляром Applicative и Validation:

Хорошая реализация подобного аппликативного функтора имеется в пакете transformers. Ross Paterson заметил, что этот функтор может формироваться так:

Подробности здесь - Control.Applicative.Lift. В любом случае, давайте воспользуемся этим для улучшения нашей суммирующей программы:

Теперь нам достаточно единожды запустить программу, чтобы разом увидеть все обнаруженные ею ошибки:

Упражнение. Можем ли мы использовать Writer [String] для сбора всех сообщений об ошибках?

Упражнение. Когда мы склеиваем списки, существует риск прийти к квадратичной сложности. Произойдёт ли это в вышеприведённой функции? И должно ли это произойти в функции, которая использует функтор Validation, основанный на list monoid?

Функтор Validation: ещё умнее

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

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

  1. Удобство. Едва ли пользователь прочтёт более, нежели, скажем, 10 сообщений за раз. И если мы попытаемся отобразить огромное количество сообщений об ошибках на веб-странице, это и работать будет медленно, и выглядеть будет ужасно.
  2. Эффективность. Раз уж мы согласны с тем фактом, что нет смысла выводить более 10 первых сообщений об ошибках, то, получив те самые 10 сообщений, не имеет смысла продолжать обрабатывать наш файл.

Для достижения каждой из этих двух целей нам понадобится свой механизм.

Ограниченные списки

Создадим наш собственный тип BoundedList, похожий на список, который будет хранить первые n элементов и игнорировать всё остальное. Это в первую очередь относится к нашей первой цели, а именно удобству пользователя (хотя достижения второй цели это тоже касается).

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

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

На уровне значений, мы основываем новый тип на difference list, далее DL (Прим. переводчика - речь идёт о Data.DList), во избежании квадратичной сложности, упомянутой ранее:

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

Итак, поняв основные идеи нашего решения (включение ограничения непосредственно в тип, использование DL, хранение текущей длины), взглянем на реализацию:

SmartValidation

Теперь мы создадим аппликативный функтор SmartValidation, который будет прекращать свою работу, как только продолжать собирать ошибки станет бессмысленным. Это нечто среднее между функтором Either, способным хранить одну-единственную ошибку, и функтором Validation, хранящим все ошибки.

Реализация такого функтора не столь тривиальна, какой может показаться на первый взгляд. На самом деле, прежде чем вы прочтёте код ниже, я рекомендую вам выполнить следующее упражнение:

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

Попробовали? У вас получилось? Это не риторический вопрос, мне действительно интересно (поэтому дайте мне знать о ваших результатах). Похожа ли ваша реализация на мою, или, может, она легче? А может, наоборот, сложнее?

Итак, вот моя реалиация:

А вот функции для создания и анализа значений типа SmartValidation:

Упражнение. Подумайте над тем, что делает fmap (.) k в определении <*>.

Упражнение. В определении <*>, должны ли мы проверять, является ли k полным перед вычислением a k'?

Упражнение. Мы разработали два механизма - BoundedList и SmartValidation, которые делают практически одно и то же, но на разных уровнях. Достаточен ли какой-то из этих двух механизмов для достижения обеих целей (удобство пользователя и эффективность) в том случае, если количество отображаемых ошибок будет велико?

Упражнение. Если бы функтор SmartValidation был основан не на DL, а на обыкновенных списках, насколько велика вероятность достижения квадратичной сложности по сравнению с решением, основанным на Validation?

Заключение

Хотя аппликативный функтор Validation известен хаскелистам, необходимость ограничивать количество отображаемых им ошибок редко (если вообще когда-либо) обсуждалась. Реализация аппликативного функтора, ограничивающего такое количество, а также избегающего ненужной работы, довольно сложна. Я очень рад поделиться с вами моим решением, и теперь мне интересно узнать о решениях других людей, столкнувшихся с подобной проблемой.