Pull to refresh

Грокаем монады

Reading time7 min
Views12K
Original author: Matt Thornton

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

Прим. переводчика: Самый распространенный способ объяснить монаду - зайти через теорию категорий. Знать, что монада - это моноид в категории эндофункторов и увлекательно и полезно для общего развития, но слабо помогает в практическом смысле. Второй, равный по популярности прием - прибегнуть к помощи образов, и вот мы уже складываем значения в коробочки и достаем их оттуда (или, вообще кошмар, катимся по железной дороге). Не спорю, образы - хороший способ посмотреть на явление, но тут мы равно удалились и от теории категорий и от практики. Комментарии под такого рода статьями не перестают полниться вопросами: а что нам это дает, жили же без монады как-то?

Автор, подобно лектору по математике, у которого вылетело из головы доказательство теоремы и он доказал ее прямо у доски, ориентируясь на интуитивное представление, как бы хотелось решить задачу, постепенно пробирается от корявого куска кода к лаконичному.

Даже не зная некоторые паттерны, мы можем сами нечаянно открыть их, потому что они удобны и полезны.


В этом посте мы постараемся разобраться, что такое монада собственноручно переизобретая ее на рабочем примере.

Тестовый сценарий

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

Наша модель данных на F#

type CreditCard =
    { Number: string
      Expiry: string
      Cvv: string }

type User =
    { Id: UserId
      CreditCard: CreditCard option }

Обратите внимание, что поле CreditCard у типа User отмечено как option, потому что карта может быть не указана.

Наша первая реализация

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

let chargeCard (amount: double) (card: CreditCard): TransactionId option =
    // синхронно списывает средства с карты и возвращает
    // некий Id транзакции в случае успеха, иначе возвращает None

let lookupUser (userId: UserId): User option =
    // синхронно ищет пользователя, которого может и не быть

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    let user = lookupUser userId
    match user with
    | Some u ->
        match u.CreditCard with
        | Some cc -> chargeCard amount cc
        | None -> None
    | None -> None

Готово! Но получилось довольно неопрятно. Двойное сопоставление с образцом не самый ясный для чтения код. Да, в этом простом примере мы могли бы оставить и так, но что, если бы у нас был третий или четвертый уровень вложенности?

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

Чего мы действительно хотим, так это иметь возможность сказать: «Если в какой-то момент мы не можем продолжить из-за отсутствия некоторых данных, тогда остановись и верни None».

Наша желанная реализация

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

let chargeUserCardSafe (amount: double) (userId: UserId): TransactionId =
    let user = lookupUser userId
    let creditCard = user.CreditCard
    chargeCard amount creditCard

Обратите внимание, что функция теперь возвращает просто TransactionId вместо option, потому что она не может завершиться ошибкой.

Было бы здорово, если бы мы могли написать код выглядящий точно так же, даже несмотря на то, что данные иногда могут отсутствовать. Чтобы это сработало, мы должны поместить что-то между этими строчками кода, чтобы совпали типы и таким образом соединить их.

Рефакторинг для более чистой реализации

Как этот соединяющий элемент должен работать? Он должен завершать вычисление, если на предыдущем шаге мы получили None, в противном случае он должен извлечь значение из Some и передать его в следующую строку. По сути, это сопоставление с образцом, о котором мы написали выше.

Что ж, посмотрим, сможем ли мы его извлечь. Для начала перепишем нашу функцию, которая всегда исполняется корректно, в виде конвейера, чтобы позже мы могли легко внедрить нашу новую функцию между шагами вычисления.

// Эта вспомогательная функция нужна лишь для того, 
// чтобы нам было проще объединить все шаги
let getCreditCard (user: User): CreditCard option =
    user.CreditCard

let chargeUserCardSafe (amount: double) (userId: UserId): TransactionId =
    userId
    |> lookupUser
    |> getCreditCard
    |> chargeCard amount

Все что мы сделали - превратили наше вычисление в конвейер при помощи специального оператора. Теперь, если функции lookupUser и chargeCard будут снова возвращать option, этот пример больше не скомпилируется. Проблема в том, что мы не можем написать

userId |> lookupUser |> getCreditCard

потому что lookupUser возвращает тип User option, а мы пытаемся передать этот результат на вход функции, которая принимает User.

Итак, у нас есть два способа исправить эту ошибку.

  1. Написать функцию типа User option -> User, которая развернет значение option, чтобы его можно было передать далее по конвейеру. Однако, игнорируя значение None, мы теряем информацию о возможном отсутствии данных. В императивном программировании это решается выбрасыванием исключения. Но, предполагается, что функциональное программирование обеспечивает нам безопасность, поэтому мы не будем так делать.

  2. Вместо этого мы можем поменять функцию с правой стороны от оператора, чтобы она могла принимать тип User option, а не просто User. Нам нужно что-то, что принимает функцию в качестве входных данных и преобразует ее в другую функцию.

Мы знаем, что эта функция высшего порядка должна иметь тип

(User -> CreditCard option) -> (User option -> CreditCard option)

На пороге открытия

Прим. переводчика: Далее в оригинальном посте идет практически полное повторение фокуса с функтором. Я решил изменить эту часть.

Возможно пытливый читатель уже догадался, к чему ведет автор. Чтобы решить нашу проблему, мы можем использовать функтор - функцию lift, или как мы ее назвали - map. Это функция высшего порядка, которая позволяет нам взять функцию работающую с обычными значениями и применить ее к значению обернутому в контейнер, а потом строить композицию из таких функций. Но есть одно маленькое, но важное отличие. Если помните, в прошлом посте, наш функтор принимал функцию User -> CreditCard, но сейчас у нас другая сигнатура - User -> CreditCard option.

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

Функтор трактует выходное значение функции, как обычный тип, если мы применим его в этом случае, получится

(User -> CreditCard option) -> (User option -> CreditCard option option)

Какая-то бессмыслица: значение, которое может отсутствовать, содержащее другое значение, которое, в свою очередь, тоже может отсутствовать. Более того, если мы продолжим применять map, вложенность типов option будет увеличиваться. То, что нам нужно - это функция flatMap. Ее реализация для option элементарна, просто не нужно оборачивать результат функции f в 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
    |> flatMap getCreditCard
    |> flatMap (chargeCard amount)

Теперь это действительно похоже на версию без типов option. Однако давайте сделаем последний штрих и переименуем flatMap в andThen, потому что интуитивно мы можем думать об этой функции как о продолжении вычислений при наличии данных. Таким образом, мы можем сказать: «Сделай что-нибудь, а затем, если получится, займись чем-то другим».

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

Поздравляю! Вы только что открыли Монаду

Функция flatMap / andThen, которую мы написали, это то, что делает значения option Монадой. Обычно, когда речь идет о монадах, такая функция называется связыванием (bind), но это не так важно для понимания монад. Важно то, что вы видите, почему мы написали эту функцию, и как она работает. По сути монада это класс вещей с определенной "then-able" функциональностью. (я не подобрал аналогичного короткого термина на русском, автор имеет в виду, что над монадой можно выполнять некую операцию продолжения, которая учитывает результат предыдущего шага. По-умному, монада - это абстракция линейной цепочки связанных вычислений. прим. переводчика)

Эй, я узнаю тебя!

Есть еще одна причина, почему я переименовал flatMap в andThen. Если вы разрабатываете на JavaScript, все что мы делали, может показаться вам похожим на Promise с методом then. В этом случае вы, вероятно, уже разобрались с монадами. Promise это тоже монада. Точно так же как и у option, у него есть метод then, который принимает другую функцию на вход и вызывает ее на результате экземпляра Promise, если он завершился успешно.

Монады - это всего лишь "then-able" контейнеры

Еще один хороший способ интуитивно понять Монады — думать о них как о контейнерах значений(так же как и в случае с функторами. прим. переводчика). option это контейнер, который либо содержит значение, либо пуст. Promise это контейнер, который "обещает" хранить значение некоего асинхронного вычисления, если оно завершится успешно.

Мы уже обсудили другие контейнеры, такие как List (который содержит значения многих вычислений) и Result (который содержит значение, если вычисление завершилось успешно или ошибку, если нет). Для каждого из этих контейнеров мы можем определить функцию andThen, которая определяет как применить функцию, принимающую объект внутри контейнера, к объекту, завернутому в контейнер.

Монады в дикой природе

Если вам когда-либо приходилось работать с функциями, которые принимают какие-то простые входные данные, такие как int, string или User, и которые выполняют какой-либо побочный эффект, тем самым возвращая что-то вроде option, Promise или Result, то, вероятно, где-то неподалеку скрывается монада. Особенно, если у вас есть несколько таких функций, которые вы хотите вызывать последовательно в цепочке.

Чему мы научились?

Мы узнали, что Монады это просто типы-контейнеры с "then-able" функцией, которая обычно называется bind. Мы можем использовать эту функцию, чтобы составить цепочку вычислений, которые принимают на вход простые значения, а возвращают обернутые значения другого типа.

Монады полезны, потому что применимы к большому числу задач. Существует много типов, при работе с которыми мы можем избавиться от шаблонного кода, определив такой метод bind. монада - это просто имя, данное такому типу, а, как сказал Ричард Фейнман, имена не составляют знания.

В следующей серии

Если вы помните, изначальной целью, что мы поставили перед собой, когда начинали наш рефакторинг, было написать код подобный такому

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    let user = lookupUser userId
    let creditCard = u.CreditCard
    chargeCard amount creditCard

Но нам все еще приходится разбираться со значениями option. То есть мы не достигли своей цели полностью. В следующем посте мы увидим, как можно использовать computation expressions в F#, чтобы добиться более императивного стиля даже при работе с монадами.

Tags:
Hubs:
Total votes 28: ↑23 and ↓5+18
Comments23

Articles