Pull to refresh

Грокаем монады императивно

Reading time 7 min
Views 5.5K
Original author: Matt Thornton

Часть 1 Грокаем функторы
Часть 2 Грокаем монады
Часть 3 Грокаем монады императивно
Часть 4 Грокаем аппликативные функторы
Часть 5 Грокаем валидацию при помощи аппликативного функтора
Часть 6 Грокаем Traversable

В предыдущем посте мы переизобрели Монаду на рабочем примере. У нас получился базовый механизм в виде функции 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 flatMap 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, чтобы учесть лимит заданный для аккаунта? Необходимо выполнить следующие шаги:

  1. Найти пользователя по его id;

  2. Если пользователь существует, получить его кредитную карту;

  3. Если пользователь существует, получить заданный лимит;

  4. Если у нас есть и лимит и кредитная карта, списать средства, в размере не превышающем лимит.

Давайте опять напишем код, словно у нас нет значений 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)

Этот код не скомпилируется по двум причинам:

  1. Мы не можем написать andThen getLimit после getCreditCard, потому что в этом месте у нас есть доступ только к объекту CreditCard, но мы должны передать объект User на вход функции getLimit.

  2. У нас нет доступа к объекту 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.

Tags:
Hubs:
+8
Comments 8
Comments Comments 8

Articles