Часть 1 Грокаем функторы
Часть 2 Грокаем монады
Часть 3 Грокаем монады императивно
Часть 4 Грокаем аппликативные функторы
Часть 5 Грокаем валидацию при помощи аппликативного функтора
В предыдущем посте мы открыли Аппликативный функтор, а если точнее, изобрели функцию apply
. С ее помощью мы решили проблему валидации полей кредитной карты. Функция apply
позволила легко объединить результаты каждой функции, отдельно проверяющей одно поле - номер карты, срок действия и CVV - в объект типа Result<CreditCard>
, который представляет финальный результат проверки всех данных кредитной карты на корректность. Возможно, вы также помните, что в случае если у нас есть несколько ошибок валидации, мы решили пойти простым путем и просто возвращать первую из них.
Недовольный клиент
Следуя духу Agile мы выпустили нашу первую реализацию, потому что, ну это по крайней мере лучше, чем ничего. Однако, через некоторое время клиенты начали жаловаться. Их претензии сводятся к следующему:
"Я вводил данные карты на вашем сайте и мне потребовалось три попытки, прежде чем карточка была принята. Каждый раз, когда я нажимал
Ok
, я получал новую ошибку. Почему нельзя было выдать все ошибки разом?"
Чтобы разобраться, что происходит, предположим, что клиент вводит следующие данные в формате JSON
{
"number": "a bad number",
"expiry": "invalid expiry",
"cvv": "not a CVV"
}
Первый раз, когда клиент подтверждал ввод, он получал ошибку "'a bad number' is not a valid credit card number"
. Клиент исправлял номер, но получал новую ошибку "'invalid expiry' is not a valid expiry date"
. Клиент исправлял и это, но, отправляя данные в третий раз, все равно получал ошибку "'not a CVV' is not a valid CVV"
. Можно представить себе его раздражение!
Мы должны предоставить более удобный интерфейс и вернуть все ошибки сразу. Ранее было указано, что функции на уровне валидации отдельных полей независимы друг от друга. Следовательно, ничто не мешало нам агрегировать все ошибки всех функций, если таковые ошибки были. Мы просто поленились!
Улучшенная версия валидации
Обновим сигнатуру функции validateCreditCard
, чтобы точнее выразить наше намерение возвращать все ошибки валидации
let validateCreditCard (card: CreditCard): Result<CreditCard, string list>
Единственное изменение, которое мы внесли - вместо одного сообщения об ошибке, мы будем возвращать список сообщений.
Вернемся к функции apply
, которую мы определили ранее, и посмотрим, сможем ли мы изменить ее, чтобы она соответствовала новой сигнатуре. Было бы здорово, если бы мы могли добиться своего поменяв только функцию apply
, не трогая при этом функцию validateCreditCard
.
Давайте быстро вспомним нашу реализацию, возвращающую первую ошибку.
let apply a f =
match f, a with
| Ok g, Ok x -> g x |> Ok
| Error e, Ok _ -> e |> Error
| Ok _, Error e -> e |> Error
| Error e, Error _ -> e |> Error
Если внимательно посмотреть, только последний случай касается обработки нескольких ошибок, соответственно, это единственная строчка, которую нам нужно поменять. Самое простое, что можно сделать - объединить 2 ошибки. Это приведет к созданию списка ошибок при каждом вызове apply
с неверными данными. Выглядеть измененная версия будет так:
let apply a f =
match f, a with
| Ok g, Ok x -> g x |> Ok
| Error e, Ok _ -> e |> Error
| Ok _, Error e -> e |> Error
| Error e1, Error e2 -> (e1 @ e2) |> Error
Это элементарно, мы просто добавили оператор @
для объединения двух ошибок, все остальное осталось без изменений.
Еще раз шаг за шагом пройдем наш пример с неверными данными, которые клиент предоставил ранее. Сначала будет исполнен следующий код
Ok (createCreditCard) |> apply (validateNumber card.Number)
Он совпадает с третьим случаем, потому что функция f
обернута в Ok
, но в качестве аргумента a
будет Error
. Результат примет значение Error [ “Invalid number” ]
, но, если говорить о типах, после первого применения apply
мы получим функцию с сигнатурой
Result<string -> string -> CreditCard, string list>
Далее по конвейеру следующая валидация
|> apply (validateExpiry card.Expiry)
Это уже последний случай, потому что оба значения - и f
и a
- содержат Error
. В этом случае при помощи оператора @
создается список ошибок: Error [ “Invalid expiry”; “Invalid number” ]
. Но результат этого шага все еще функция, теперь с сигнатурой
Result<string -> CreditCard, string list>
Все, что нам осталось - проверить последний аргумент (CVV) и передать его в функцию создания CreditCard
|> apply (validateCvv card.Cvv)
Так же как и на предыдущем шаге, это случай с объединением двух ошибок. Наконец мы получили результат вычисления с типом Result<CreditCard, string list>
и значением Error [ “Invalid CVV”; “Invalid expiry”; “Invalid number” ]
Небольшая ошибка времени компиляции
Мы очень подробно разобрали все этапы валидации, и вы, должно быть, заметили, что сигнатура функции apply
поменялась. Поскольку мы использовали оператор @
, компилятор сам вывел, что тип ошибки теперь - список.
Result<T, E list> -> Result<T -> V, E list> -> Result<V, E list>
Итак, у нас есть обновленная функция apply
над типом Result<T, E list>
. То есть еще раз, она работает для всех объектов Result
, где тип ошибки представлен, как список каких-то значений.
Из этого следует пара интересных моментов:
Это может быть список объектов любого типа, при условии, что все они одного типа.
Все наши функции валидации должны работать со списком ошибок, если мы хотим использовать их с функцией
apply
.
Первый момент полезен, он позволяет использовать более конкретные типы ошибок, не обязательно только строки. Но для простоты для нашего примера мы будем использовать просто строки.
Второй момент, однако, вызывает небольшие трудности. Наши функции валидации полей кредитной карты возвращают тип Result<string, string>
, так что новая версия apply
не сможет с ними работать.
У нас есть два варианта решения. Мы можем оставить наши функции без изменений, и просто заворачивать результат, если это ошибка, в список. Решение может выглядеть так:
let validateCreditCard (card: CreditCard): Result<CreditCard, string list> =
let liftError result =
match result with
| Ok x -> Ok x
| Error e -> Error [ e ]
Ok (createCreditCard)
|> apply (card.Number |> validateNumber |> liftError)
|> apply (card.Expiry |> validateExpiry |> liftError)
|> apply (card.Cvv |> validateCvv |> liftError)
Либо, мы можем поменять наши функции валидации так, чтобы они возвращали тип Result<string, string list>
. Может возникнуть соблазн выбрать первый вариант, более того, это единственное решение, если мы не можем переписать функции валидации (допустим это код из сторонней библиотеки прим. переводчика). С другой стороны, позволив функции валидации возвращать список ошибок, мы закладываем возможность выполнять сложные проверки и указываем на возможное наличие множества различных ошибок.
Например, функция validateNumber
может возвращать как ошибку связанную с неправильной длинной строки, так и ошибку говорящую о наличии недопустимых символов.
let validateNumber number: Result<string, string list> =
let errors =
if String.length number > 16 then
[ "Too long" ]
else
[]
let errors =
if number |> Seq.forall Char.IsDigit then
errors
else
"Invalid characters" :: errors
if errors |> Seq.isEmpty then
Ok number
else
Error errors
Использование типа Result<T, E list>
дает нам более гибкий API, который позволяет добавить возможные ошибки валидации в будущем, если таковые потребуются, без изменения остального кода.
Учитывая что мы можем изменять функции валидации в нашем примере, давайте попробуем применить второй подход.
let validateNumber num: Result<string, string list> =
if String.length num > 16 then
Error [ “Too long” ]
else
Ok num
let validateExpiry expiry: Result<string, string list> =
// проверить срок действия и вернуть все ошибки
let validateCvv cvv: Result<string, string list> =
// проверить cvv и вернуть все ошибки
let validateCreditCard (card: CreditCard): Result<CreditCard, string list> =
Ok (createCreditCard)
|> apply (validateNumber card.Number)
|> apply (validateExpiry card.Expiry)
|> apply (validateCvv card.Cvv)
Прекрасная работа! Как мы и хотели, тело функции validateCreditCard
не изменилось вовсе, а чтобы заработала новая версия apply
, нам потребовалось всего лишь обернуть ошибку возвращаемую функцией validateNumber
(и, возможно, другими) в список.
Так должен ли я использовать списки для возврата ошибок?
Единственное требование, которое мы предъявляем к типу ошибки - для него должен быть определен оператор @
- оператор объединения или, по другому, конкатенации. С академической точки зрения, это называется полугруппа
: множество с заданной на нем операцией сложения. Что же касается непосредственно списка, обычно тут используется NonEmptyList
, поскольку мы можем быть уверены, если результат вычислений - Error
, значит в нем содержится как минимум один объект представляющий ошибку.
Сказка о двух аппликативных функторах
Итак, мы увидели две реализации функции apply
и типа Result
. Можем ли мы использовать обе эти реализации? К сожалению, нет, как минимум, реализация со списком ошибок не определена в модуле FSharp.Core
. Кроме того, на компилятор ложится работа по выводу типа Result
в зависимости от того, поддерживает ли тип ошибки операцию объединения, что может вызывать сложности без явного указания типа. И даже если компилятор вывел правильный тип, мы можем получать неожиданные результаты. Тип string
, например, поддерживает конкатенацию, но эта операция в данном случае построит одну длинную строку из ошибок, а это едва ли то, что требовалось.
Если необходимо выбрать один вариант, как понять, какой правильный? Что ж, мы не должны выбирать. Мы можем определить новый тип Validation
- тип-сумма для значений Success
и Failure
, по аналогии с Ok
и Error
для типа Result
. Разница в том, что для типа Validation
мы можем использовать созданную в этом посте функцию apply
, которая аккумулирует все ошибки, а для типа Result
использовать старую версию apply
, которая прерывает вычисление на первой же ошибке. К нашему счастью, создатели замечательной библиотеки FSharpPlus уже все это сделали.
Чему мы научились?
Мы увидели, что аппликативный функтор отличный инструмент для валидации. Он позволяет нам проверять поля объекта независимо друг от друга, и потом, при помощи композиции, строить функции проверки самого объекта. С другой стороны, несмотря на то, что аппликативный функтор позволяет комбинировать независимые вычисления, никто не гарантирует, что конкретная реализация будет следовать этому правилу. Мы реализовали два варианта обработки ошибок валидации и узнали, что все это уже реализовано в библиотеке FSharpPlus.