Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Часть 1 Грокаем монады
Часть 2 Грокаем монады императивно
В предыдущем посте мы переизобрели Монаду на рабочем примере. У нас получился базовый механизм в виде функции andThen
для типа option
, но мы еще не достигли нашей конечной цели. Мы надеялись, что получится написать код, так же как если бы нам не нужно было обрабатывать значения option
. Мы хотели писать в более "императивном" стиле. В этой части мы увидим как достичь этого при помощи технологии computation expressions
языка F#, а также углубим наше понимание Монад.
Краткое повторение
Давайте быстро вспомним модель нашей предметной области
type CreditCard =
{ Number: string
Expiry: string
Cvv: string }
type User =
{ Id: UserId
CreditCard: CreditCard option }
Мы хотели написать функцию chargeUserCard
с сигнатурой
UserId -> TransactionId option
Если бы не option
, мы могли бы написать ее в "императивном стиле"
let chargeUserCardSafe (userId: UserId): TransactionId =
let user = lookupUser userId
let creditCard = u.CreditCard
chargeCard creditCard
Нашей целью было написать функцию, которая выглядела бы подобным образом, даже не смотря на то, что нам приходится иметь дело с типом option
в ходе вычисления.
В процессе рефакторинга мы извлекли функцию
let andThen f x =
match x with
| Some y -> y |> f
| None -> None
используя которую, смогли улучшить функцию chargeUserCard
до такой версии
let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
userId
|> lookupUser
|> andThen getCreditCard
|> andThen (chargeCard amount)
В чем же проблема?
У вас может возникнуть логичный вопрос, в чем же здесь проблема? Наша окончательная реализация chargeUserCard
хорошо читается. Она довольно ясно описывает наши намерения и мы избежали дублирования кода. Неужели дело просто в эстетике?
Чтобы увидеть, почему возможность использовать "императивный стиль", не просто вопрос красоты, давайте введем еще одно требование. Так мы испытаем нашу текущую реализацию и раскроем ее слабые стороны.
Представим, что теперь пользователи должны устанавливать лимиты расходов в своем профиле. Для обратной совместимости поле limit
представлено типом option
. Если лимит задан, мы должны проверить, что расход не превышает его, если же пользователь еще не установил лимит, мы должны прервать вычисление и вернуть None
.
Добавим лимит в нашу модель
type User =
{ Id: UserId
CreditCard: CreditCard option
Limit: double option }
Начнем с реализации функции getLimit
в том же порядке, как мы реализовали getCreditCard
let getLimit (user: User): double option =
user.Limit
Итак, как нам обновить chargeUserCard
, чтобы учесть лимит заданный для аккаунта? Необходимо выполнить следующие шаги:
Найти пользователя по его id
Если пользователь существует, получить его кредитную карту
Если пользователь существует, получить заданный лимит
Если у нас есть и лимит и кредитная карта, списать средства, в размере не превышающем лимит
Давайте опять напишем код, словно у нас нет значений option
let chargeUserCardSafe (amount: double) (userId: UserId) =
let user = lookupUser userId
let card = getCreditCard user
let limit = getLimit user
if amount <= limit then
chargeCard amount card
else
None
А теперь добавим option
и используем оператор конвейера и функцию andThen
let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
userId
|> lookupUser
|> andThen getCreditCard
|> andThen getLimit
|> andThen
(fun limit ->
if amount <= limit then
chargeCard amount ??
else
None)
Этот код не скомпилируется по двум причинам
Мы не можем написать
andThen getLimit
послеgetCreditCard
, потому что в этом месте у нас есть доступ только к объектуCreditCard
, но мы должны передать объектUser
на вход функцииgetLimit
У нас нет доступа к объекту
CreditCard
в месте вызоваchargeCard
Разрывая цепь
Похоже у нас больше нет последовательного потока данных. Это связано с тем, что нам нужно использовать пользователя для получения как кредитной карты, так и лимита, и нам нужны оба эти объекта, чтобы списать с карты деньги.
Немного пораскинув мозгами мы можем найти способ переписать код с использованием конвейера и функции andThen
, но теперь он стал довольно размашистым.
let chargeUserCard (amount: double) (userId: UserId) : TransactionId option =
userId
|> lookupUser
|> andThen
(fun user ->
user
|> getCreditCard
|> andThen
(fun cc ->
user
|> getLimit
|> Option.map (fun limit -> {| Card = cc; Limit = limit |})))
|> andThen
(fun details ->
if amount <= details.Limit then
chargeCard amount details.Card
else
None)
Как быстро растет сложность этого кода!
Не волнуйтесь, если не до конца поняли, что здесь написано. В том-то и дело, что этот код потерял в читаемости и нам надо найти способ сократить его.
Новый сценарий сделал использование конвейера неудобным. Чтобы использовать оператор |>
, мы вынуждены продолжать накапливать состояние, чтобы в конце цепочки вызовов мы могли использовать все объекты. Именно для этого мы создали анонимную запись в наиболее глубоко вложенной части выражения.
Мы могли бы начать сомневаться, настолько ли хорошо функциональное программирование. В старые добрые "императивные" времена, мы просто присваивали значения переменным, а затем ссылались на них, когда они были нужны.
Пытаемся усидеть на двух стульях
К счастью, есть способ разобраться с этим беспорядком.
На этот раз мы собираемся изобрести новый синтаксис, чтобы заставить нашу желанную реализацию работать со значениями option
. Мы собираемся определить оператор let!
. Он очень похож на оператор let
, но вместо того, чтобы просто привязать название к выражению, он привяжет название к значению внутри option
, если оно существует. Если значение не существует, он немедленно прервет вычисления и вернет None
.
С этим новым синтаксисом функция chargeUserCard
упроститься до
let chargeUserCard (amount: double) (userId: UserId) =
let! user = lookupUser userId
let! card = getCreditCard user
let! limit = getLimit user
if amount <= limit then
chargeCard amount card
else
None
Практически никаких отличий от версии без option
. Я слышу, как выговорите: "Это все конечно очень здорово, но ты не можешь просто взять и изобрести новый синтаксис!". К счастью для нас, мы и не должны. F# поддерживает оператор let!
по умолчанию, как часть функционала под названием Computation Expressions
.
Computation expressions != магия
F# это не какое-то волшебство, мы должны объяснить ему, как оператор let!
должен вести себя с конкретной монадой. Нам необходимо определить новый computation expression.
Я не буду подробно останавливаться на этом здесь, документация F# - хорошее место, чтобы начать разбираться в технологии. Все, что для нас сейчас важно - F# требует создать тип с методом Bind
. Мы уже знаем как написать этот метод, потому что мы открыли его в прошлой части и назвали andThen
. Конструктор computation expression для option
в конечном итоге выглядит следующим образом.
let andThen f x =
match x with
| Some y -> y |> f
| None -> None
type OptionBuilder() =
member _.Bind(x, f) = andThen f x
member _.Return(x) = Some x
member _.ReturnFrom(x) = x
Нам нужно также определить метод Return
, который позволит нам создать из обычного значение объект option
, и ReturnFrom
, с помощью которого мы можем получить значение из объекта option
.
Метод ReturnFrom
может показаться излишним, потому что он слишком простой. Но в других computation expression нам может понадобиться более сложное поведение. Сделав его расширяемым создатели F# предоставили нам эту возможность за счет необходимости в некоторых случаях написать такой шаблонный код.
С использованием computation expression наша окончательная реализация chargeUserCard
выглядит так
let chargeUserCard (amount: double) (userId: UserId) =
option {
let! user = lookupUser userId
let! card = getCreditCard user
let! limit = getLimit user
return!
if amount <= limit then
chargeCard amount card
else
None
}
Довольно аккуратно! Все что нам нужно - обернуть тело метода в блок option
и таким образом указать, что мы хотим использовать только что определенный нами computation expression. Нам также придется использовать оператор return!
в последней строке, чтобы выражение вернуло тип option
. (для тех кто хочет проделать все это самостоятельно: писать методы для OptionBuilder
нет необходимости, они есть в модуле Option. Автор пропустил одну важную строку, без которой наш computation expression не заработает - let option = OptionBuilder()
прим. переводчика)
Тестируем computation expression
Чтобы лучше понять, как работает computation expression, который мы только что определили, и доказать, что все работает как ожидается, давайте запустим несколько тестов в F# repl. (read-eval-print loop, интерактивный сеанс, где мы можем править код и сразу видеть результат наших правок. Это либо F# Interactive в консоли (dotnet fsi) или IDE, либо онлайн-сервис, которых сегодня очень много. Могу привести как пример один из самых популярных https://replit.com или интересный вариант для .NET https://sharplab.io прим. переводчика)
> option {
- let! x = None
- let! y = None
- return x + y
- };;
val it : int option = None
Когда оба значения x
и y
содержат None
результат будет None
. Что если только одно из значений будет равно None
?
> option {
- let! x = Some 1
- let! y = None
- return x + y
- };;
val it : int option = None
> option {
- let! x = None
- let! y = Some 2
- return x + y
- };;
val it : int option = None
3 из 3! Нам осталось убедиться, что выражение вернет сумму обернутую в option
если x
и y
оба содержат значение.
> option {
- let! x = Some 1
- let! y = Some 2
- return x + y
- };;
val it : int option = Some 3
Полный комплект!
(тесты в repl это конечно круто, но от себя хочу предложить вариант Unit-тестов с xUnit и Unquote
let sum x y =
option {
let! x' = x
let! y' = y
return x' + y'
}
let values : obj[] list =
[
[| None; None; None |]
[| 1; None; None |]
[| None; 2; None |]
[| 1; 2; 3 |]
]
[<Theory>]
[<MemberData(nameof(values))>]
let test_option_ce x y expected =
test <@ sum x y = expected @>
прим. переводчика)
Прокачиваем интуитивное понимание монад
Этот "императивный" стиль может показаться вам знакомым. Если бы это было асинхронное (async) выражение, мы бы просто использовали await
вместо let!
. Причина, по которой людям нравится async/await
, особенно тем из нас, кто помнит ад глубоко вложенных обратных вызовов, в том, что этот прием позволяет писать асинхронный код, как если бы он был синхронным. Он позволяет нам избавится от всех этих подробностей, связанных с отложенным вычислением и возможностью ошибки в асинхронной функции.
Сomputation expressions в F# позволяют нам работать подобным образом с любыми монадами, не только с асинхронными. Преимущество этого подхода в том, что мы можем продолжать писать код в простом для понимания "императивном" стиле, но без изменяемого состояния и прочих побочных эффектов настоящего императивного программирования.
Должен ли я реализовать все самостоятельно?
Сборка FSharp.Core включает в себя несколько предопределенных computation expressions для последовательностей, асинхронных вычислений и LINQ. Много полезных computation expressions реализовано в библиотеках с открытым исходным кодом. Создатели FSharpPlus пошли еще дальше создав единый computation expression для работы со многими монадическими типами.
Чему мы научились?
Мы узнали, что, хотя функция andThen
является основным механизмом композиции монадических вычислений, использование ее напрямую в случае, когда последовательность операций не линейна, может легко привести к запутанному коду. При помощи computation expressions в F# мы можем скрыть эти сложные вычисления и писать код так, словно работаем с обычными функциями, а не монадами. Подобным образом работает async/await
с той лишь разницей, что асинхронные вычисления ограничены типами Task
или Promise
. Так что, если вы грокнули async/await
вы уже на пути к пониманию монад и computation expressions.