Часть 1 Грокаем функторы
Часть 2 Грокаем монады
Часть 3 Грокаем монады императивно
Часть 4 Грокаем аппликативные функторы
Аппликативный функтор
, возможно, наименее известный брат монады, но он столь же важен и решает похожую проблему. В этом посте мы постараемся разобраться, что он из себя представляет.
Краткое введение в F#
Дополнительно к тому, что мы узнали в прошлых частях, нам понадобиться следующее:
В F# определен тип
Result<T, E>
. Он представляет результат вычисления, который может закончится ошибкой. У него есть 2 конструктора,OK
- задает значение типаT
, иError
содержит значение типаE
в случае ошибки.Паттерн-матчинг для типа
Result
выглядит так:
match aResult with
| Ok x -> // выражение для верного значения x
| Error e -> // выражение для знаечния e, представляющего ошибку
Тестовый сценарий
Предположим, нас попросили написать функцию валидации кредитной карты. Модель будет выглядеть следующим образом:
type CreditCard =
{ Number: string
Expiry: string
Cvv: string }
А функция, которую нам надо реализовать, так:
let validateCreditCard (creditCard: CreditCard): Result<CreditCard, string>
К счастью, кто-то уже написал несколько функций для валидации номера карты, срока ее действия и кода CVV:
let validateNumber number: Result<string, string> =
if String.length number > 16 then
Error "A credit card number must be less than 16 characters"
else
Ok number
let validateExpiry expiry: Result<string, string> =
// какая-то проверка срока действия
let validateCvv cvv: Result<string, string> =
// какая-то проверка CVV
Сейчас нас не интересуют подробности проверки валидна ли карта. Я просто привел самый простой пример проверки номера карты, но, в действительности, он может быть куда сложнее. Главное, на что следует обратить внимание - каждая функция принимает непроверенную строку, а возвращает тип Result<string, string>
, который указывает, является ли значение допустимым. Если значение недопустимое, функция вернет строку с сообщением об ошибке.
Наша первая реализация
Нам повезло! Все, что нужно сделать - каким-то образом скомпоновать эти функции для проверки экземпляра кредитной карты. Давайте попробуем!
let validateCreditCard card =
let validatedNumber = validateNumber card.Number
let validatedExpiry = validateExpiry card.Expiry
let validatedCvv = validateCvv card.Cvv
{ Number = validatedNumber
Expiry = validatedExpiry
Cvv = validatedCvv }
Хммм, этот код не компилируется. Проблема в том, что мы пытаемся записать значения типа Result<string, string>
в поля объекта CreditCard
, но все его поля имеют тип string
.
Кажется, я вижу что-то на букву M
Будучи знакомыми с Монадой, мы можем попытаться решить проблему с ее помощью. Каждая функция проверки принимает string
и преобразует ее в Result<string, string>
, и мы, по-видимому, хотим объединить несколько таких функций в цепочку. Мы знаем, что для такого типа композиции можно использовать функцию bind
(по-другому flatMap
). Например, bind
для монады Result
будет выглядеть так:
let bind f result =
match result with
| Ok x -> f x
| Error e -> Error e
Перепишем функцию validateCreditCard
используя bind
let validateCreditCard card =
validateNumber card.Number
|> bind (validateExpiry card.Expiry)
|> bind (validateCvv card.Cvv)
|> fun number expiry cvv ->
{ Number = number
Expiry = expiry
Cvv = cvv }
Выглядит аккуратно, но все равно не компилируется!
Вызов bind
ожидает функцию, которая принимает проверенное значение из предыдущего шага вычислений. В первом случае это будет валидный номер карты, который передается на вход функции validateExpiry
. Однако функция validateExpiry
ожидает не номер карты, который мы уже проверили и обернули в Result
, она принимает непроверенный срок действия. И при этом нам еще надо как-то сохранить номер до конца вычислений, чтобы мы могли создать корректный экземпляр CreditCard
.
Мы можем добиться этого, накапливая промежуточные результаты проверки по мере продвижения.
let validateCreditCard card =
validateNumber card.Number
|> bind
(fun number ->
validateExpiry card.Expiry
|> Result.map (fun expiry -> number, expiry))
|> bind
(fun (number, expiry) ->
validateCvv card.Cvv
|> Result.map
(fun cvv ->
{ Number = number
Expiry = expiry
Cvv = cvv }))
Ой! Это определенно более запутанный код, чем нам хотелось бы. На каждом этапе нам приходится писать лямбда-выражение, которое принимает верное значение с предыдущего шага, проверяет еще один фрагмент данных и складывает все в кортеж, пока мы, наконец, не получим все части для создания CreditCard
.
Наша и без того упрощенная задача валидации утонула в море лямбда-выражений и промежуточных кортежей. Представьте тот беспорядок, что мы получили бы, будь у CreditCard
больше полей, которые требуют проверки. Нужно решение, которое избавит нас от необходимости во всем этом утилитарном коде.
Аппликативный функтор спешит на помощь
Другой способ аккумулировать значения — частичное применение. Мы можем взять функцию от n
аргументов и вернуть функцию от n - 1
аргументов. Для примера напишем функцию createCreditCard
, которая работает с простыми строковыми значениями:
let createCreditCard number expiry cvv =
{ Number = number
Expiry = expiry
Cvv = cvv }
Мы можем постепенно аккумулировать значения, применяя их к функции.
let number = "1234"
let numberApplied = createCreditCard number
numberApplied
это функция с сигнатурой
string -> string -> CreditCard
или, если проименовать параметры
expiry -> cvv -> CreditCard
Таким образом мы можем сохранить номер карты на будущее без необходимости создавать кортеж.
Давайте изобретем функцию apply
, которая позволяет использовать частичное применение для значений, заключенных в некую структуру, такую как Result
, и поместим ее перед каждым аргументом
let validateCreditCard card: Result<CreditCard, string> =
Ok (createCreditCard)
|> apply (validateNumber card.Number)
|> apply (validateExpiry card.Expiry)
|> apply (validateCvv card.Cvv)
Вам может быть интересно, зачем оборачивать createCreditCard
в Ok
. Функция validateCreditCard
возвращает тип Result<CreditCard, string>
, значит метод apply
должен так же работать над типом Result
. Соответственно первый вызов apply
тоже должен принимать тип Result
, чтобы запустилась вся цепочка вычислений.
Еще одна интересная деталь - Result
содержащий функцию, а не значение. Именно в эту функцию при помощи частичного применения мы будем добавлять все результаты вызовов apply
, выполняя на каждом шаге проверку нового аргумента.
Как всегда при написании функции будем отталкиваться от сигнатуры и позволим типам вести нас. При каждом вызове apply
нам нужно 2 аргумента: Result<(T -> V), E>
и Result<T, E>
. Мы попытаемся получить функцию типа T -> V
и значение типа T
и применить значение к функции, если оба контейнера содержат Ok
. Может показаться, что тип T -> V
это функция от одного аргумента, однако, никто не мешает нам трактовать сам тип V
как еще одну функцию. Таким образом мы можем работать с функцией от любого количества аргументов, при условии что первый аргумент соответствует типу содержащемуся в Result
, который мы пытаемся применить.
Сигнатура метода apply
должна выглядеть так:
Result<T, E> -> Result<(T -> V), E> -> Result<V, E>
И даже столь абстрактной информации достаточно, чтобы ее реализовать
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
В итоге мы просто сопоставляем с образцом и функцию и значение и проверяем все 4 возможных варианта. Если оба контейнера содержат Ok
, мы применяем функцию к значению, как и хотели, и результат опять помещаем в Ok
. Во всех остальных случаях у нас есть по крайней мере одна ошибка, поэтому мы возвращаем ее. Последний случай интересен тем, что мы получаем 2 ошибки. В этой конкретной реализации мы просто возвращаем первую.
Тесты
Напишем тесты в интерактивном сеансе F#, чтобы убедится, что функция apply
работает корректно, и, заодно, лучше понять, что же тут происходит.
> Ok (createCreditCard)
|> apply ((Ok "1234"): Result<string, string>)
|> apply (Ok "08/19")
|> apply (Ok "123")
val it : Result<CreditCard, string> = Ok { Number = "1234"
Expiry = "08/19"
Cvv = "123" }
Выглядит неплохо. Если все входные значения валидны, мы получаем корректный объект CreditCard
. Посмотрим что произойдет, если один из параметров будет неверным.
> Ok (createCreditCard)
|> apply ((Ok "1234"): Result<string, string>)
|> apply (Ok "08/19")
|> apply (Error "Invalid CVV")
val it : Result<CreditCard, string> = Error "Invalid CVV"
Превосходно! Как мы и ожидали. Последнее, что если на вход подать несколько неверных значений.
> Ok (createCreditCard)
|> apply ((Error "Invalid card number"): Result<string, string>)
|> apply (Ok "08/19")
|> apply (Error "Invalid CVV")
val it : Result<CreditCard, string> = Error "Invalid card number"
Мы спроектировали функцию таким образом намеренно. Функция выдает ошибку уже при первом неправильном значении. Вы можете обоснованно усомниться, правильно ли это? Может лучше было бы возвращать сразу все ошибки валидации? В следующем посте мы увидим, как можно этого добиться.
(по традиции вариант с тестами от меня:
let values: obj[] list =
[ [| Ok "1234"
Ok "08/19"
Ok "123"
Ok
{ Number = "1234"
Expiry = "08/19"
Cvv = "123" } |]
[| Ok "1234"
Ok "08/19"
Result<string, obj>.Error "Invalid CVV"
Result<CreditCard, obj>.Error "Invalid CVV" |]
[| Result<string, obj>.Error "Invalid card number"
Ok "08/19"
Result<string, obj>.Error "Invalid CVV"
Result<CreditCard, obj>.Error "Invalid card number" |] ]
[<Theory>]
[<MemberData(nameof(values))>]
let test_applicative number expiry cvv expected =
test <@ validateCreditCard number expiry cvv = expected @>
прим. переводчика)
Мы только что изобрели аппликативный функтор
Функция apply
определенная на объекте, это то, что делает его аппликативным функтором. Надеюсь, наглядная демонстрация решения проблемы помогла вам понять его суть лучше, нежели простое созерцание сигнатуры функции или чтение о законах аппликативных функторов.
Аппликативный функтор похож на монаду тем, что позволяет нам определенным образом комбинировать функции. Но он полезен в том случае, если результаты вычисления этих функций независимы, тогда как монада берет результат вычисления одной функции и использует его как аргумент для другой.
Немного причешем наш код
Нам пришлось обернуть функцию createCreditCard
в Ok
в начале нашей цепочки вычислений, если вас это раздражает, мы можем избавиться от этой операции. Если вы уже грокнули функторы, вы знаете, что функция map
определенная для типа Result
делает его функтором. Мы знаем, что map
принимает функцию и вызывает ее на экземпляре Result
, если он содержит Ok
. Мы можем использовать это, чтобы запустить цепочку вычислений.
let validateCreditCard card =
(validateNumber card.Number)
|> Result.map createCreditCard
|> apply (validateExpiry card.Expiry)
|> apply (validateCvv card.Cvv)
Этот вариант выглядит неуклюжим, потому что мы нарушили поток вычисления - вызов функции createCreditCard
оказался где-то между ее аргументами. Чтобы исправить это, можно определить для map
инфиксный оператор <!>
, который используется следующим образом (в F# определять свои операторы просто как - let (<!>) f x = Result.map f x
. Главное сильно не увлечься этим) прим. переводчика)
let validateCreditCard card =
createCreditCard
<!> validateNumber card.Number
|> apply (validateExpiry card.Expiry)
|> apply (validateCvv card.Cvv)
И в завершение, часто используют оператор <*>
для функции apply
(таким же образом: let (<*>) f a = apply a f
прим. переводчика)
let validateCreditCard card =
createCreditCard
<!> validateNumber card.Number
<*> validateExpiry card.Expiry
<*> validateCvv card.Cvv
Не пугайтесь, если вам кажется, что стало только хуже, это просто символы. Грокнуть аппликативный функтор - означает понять, как работает метод apply
и какие реальные проблемы он решает, а не преисполнится эзотерического синтаксиса. Я привел здесь этот пример только потому, что довольно часто можно увидеть как аппликативный функтор используется подобным образом.
Аппликативный функтор в дикой природе
Всякий раз когда у вас появляется необходимость вызвать функцию с несколькими аргументами, при этом аргументы обернуты в нечто вроде типа Result
- аппликативный функтор это как раз то, что вам нужно.
Множество типов помимо Result
могут быть аппликативным функтором, все, что нужно для этого сделать - реализовать подходящую функцию apply
. Например, мы можем написать такую функцию для Option
. Как уже было сказано ранее, может быть несколько способов реализовать такую функцию, поэтому убедитесь, что вы выбрали тот, который соответствует вашим потребностям.
Протестируйте себя
Проверьте, сможете ли вы написать функцию apply
для типа Option
. Ответ спрятан под катом, постарайтесь не подглядывать, сперва не попробовав реализовать все самостоятельно.
решение для Option
let applyOpt a f =
match f, a with
| Some g, Some x -> g x |> Some
| _ -> None
Решение похоже на решение для Result
, но None
не несет никакой дополнительной информации, поэтому во всех случаях, когда по крайней мере одно значение равно None
, нам остается только вернуть None
.
Чему мы научились?
Реализовав функцию apply
мы получили возможность применять аргументы обернутые в Result
к функции, которая принимает обычные строковые аргументы. Это позволило нам использовать частичное применение в качестве средства постепенного накопления данных и, в конечном итоге, писать код так, как будто мы имеем дело с обычными аргументами, а не с типом Result
, который может содержать и ошибку.