Прим. переводчика: Это перевод первой статьи из целого цикла постов "Грокаем функциональное программирование" Мэта Торнтона. Да, это очередная статья про монады. Но она отличается от всего, что я читал по этой теме ранее. Поэтому мне захотелось перевести ее, чтобы самому внимательно вчитаться, чтобы поделиться с теми, кому трудно воспринимать такой материал на английском или кому она не попалась на глаза, чтобы, в конце концов, просто сохранить, так как сеть штука ненадежная.

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

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

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


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

Маленький пример на F#

Мы будем использовать язык F#, но даже если вы не использовали его ранее, вам будет нетрудно разобраться. Все, что вам нужно - усвоить следующий минимум:

  • В F# есть тип option. Он представляет либо наличие какого-то значения (Some), либо его отсутствие через значение None. Этот тип обычно используется вместо null, чтобы указать отсутствие значения.

  • Pattern matching (сопоставление с образцом) для типа option выглядит следующим образом:

    match anOptionalValue with
    | Some x -> // выражение на случай, если значение существует
    | None -> // выражение, если значение отсутствует
  • В F# есть оператор конвейера, который записывается так: |> (если быть совсем точным, это оператор прямого конвейера - forward pipe operator прим. переводчика). Это инфиксный оператор, то есть он применяет значение слева от себя к функции справа. Для примера, если функция toLower принимает строку и приводит ее к нижнему регистру, тогда выражение "ABC" |> toLower вернет "abc".

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

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

Наша модель данных на 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 = u.CreditCard
    chargeCard amount creditCard

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

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

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

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

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

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

Давайте напишем ее просто соблюдая типы. Мы назовем ее liftGetCreditCard, потому что она «поднимает» функцию getCreditCard для работы с входными данными типа option.

let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
    match user with
    | Some u -> u |> getCreditCard
    | None -> None

Отлично! Мы приближаемся к нашей идеальной функции chargeUserCard. Теперь наш код выглядит так

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    userId
    |> lookupUser
    |> liftGetCreditCard getCreditCard
    |> chargeCard double

Частично применив getCreditCard к liftGetCreditCard, мы создали функцию с сигнатурой User option -> CreditCard option, чего мы и хотели добиться.

На самом деле не совсем. Теперь у нас та же проблема, только дальше по цепочке вызовов. Функция chargeCard принимает CreditCard, а мы пытаемся передать ей CreditCard option. Не проблема, просто применим тот же трюк еще раз.

let liftGetCreditCard getCreditCard (user: User option): CreditCard option =
    match user with
    | Some u -> u |> getCreditCard
    | None -> None

let liftChargeCard chargeCard (card: CreditCard option): TransactionId option =
    match card with
    | Some cc -> cc |> chargeCard
    | None -> None

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    userId
    |> lookupUser
    |> liftGetCreditCard getCreditCard
    |> liftChargeCard (chargeCard amount)

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

Вы заметили, как похожи эти две функции lift..., как они не очень то зависят от типа первого аргумента? По сути, это просто функция от значения, содержащегося внутри option, к другому option значению. Посмотрим, сможем ли мы написать единую функцию, которая справится со всеми вариантами. Мы можем сделать это, переименовав первый аргумент в f (для функции) и удалив большинство подсказок типа, потому что F# выведет обобщенные типы за нас.

let lift f x =
    match x with
    | Some y -> y |> f
    | None -> None

Тип, который F# вывел для функции lift имеет вид

('a -> 'b option) -> ('a option -> 'b option)

где 'a и 'b - обобщенные типы. Может показаться что эта сигнатура довольно многословна и абстрактна, но давайте поместим ее рядом с более конкретной сигнатурой нашей функции liftGetCreditCard.

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

('a -> 'b option) -> ('a option -> 'b option`)

Конкретный тип User был заменен на обобщенный 'a, а конкретный тип CreditCard на тип 'b. Это произошло, потому что функции lift все равно, что находится внутри контейнера option, она просто говорит: «дайте мне какую-нибудь функцию «f», и я применю ее к значению, содержащемуся в «x», если это значение существует». Единственным ограничением является то, что функция f принимает тип, который находится внутри option.

Хорошо, теперь мы можем еще немного отрефакторить нашу функцию chargeUserCard.

let chargeUserCard (amount: double) (userId: UserId): TransactionId option =
    userId
    |> lookupUser
    |> lift getCreditCard
    |> lift (chargeCard amount)

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

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

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

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

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

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

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

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

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

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

Мы узнали, что Монады это просто типы-контейнеры с "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#, чтобы добиться более императивного стиля даже при работе с Монадами.

Комментарии (21)


  1. sshikov
    13.08.2022 16:46
    +3

    Мэта Тронтона.

    Наверное, все-таки Торнтона?

    Так все-же, и чем это отличается от всего, что вы читали? По мне — так почти все тоже самое, что и во многих других местах — кроме разве что использования F#, и некоторой его специфики. Вы сами заметили, что автор и до контейнеров дошел, и собственно, почему нет, если это очень часто осмысленная абстракция? Тем что тут «типы-контейнеры с „then-able“ функцией»? Так в тех самых статьях с картинками (я думаю мы одни и те же имеем в виду) тоже говорится, что контейнер не просто хранит нечто, а умеет применять функцию к своему содержимому.


    1. Gotcha7770 Автор
      14.08.2022 19:48

      Автора поправил, спасибо!

      Я попытался объяснить свою мотивацию во вступлении, но, возможно, получилось не очень удачно.

      Я не имел ввиду, что теория категорий или аналогии с коробочками - плохо, и такие статьи не нужны. Нужны, это просто другой взгляд на задачу и ее решение. Восприятие субъективно, мне всегда не хватало в дополнение к теории и аналогиям каких-то практических примеров. В данной статье мне нравится первая ее часть, где мы начинаем с разработки API оплаты, но код получается запутанный и мы постепенно придумываем, как избавится от этой запутанности. У нас получается паттерн. Я даже не хотел бы спорить, как этот паттерн надо называть и точно ли это Монада (наверно, напрасным было примечание про Promise), но ведь его надо как-то называть?

      Вторая часть статьи, после "Поздравляю! Вы только что открыли Монаду", лично мне уже ничего для понимания не дает (возможно, как раз после тех постов с картинками), и я не хотел ее переводить. Но мне показалось, что нельзя перевести часть материала или добавлять отсебятину, так что перевел все как есть.

      Это первая статья из цикла. Далее там начинаются всякие интересные эффекты, если у нас коробочка лежит в коробочке. Она подчеркнуто конкретна и довольно сильно разжевана. Поэтому предназначена, скорее для тех, кто только пытается понять, как и где ему монаду применить в реальной практике, чем для программистов на haskell.


  1. napa3um
    13.08.2022 23:16

    Промис, работающий с синхронными функциями, - это полноценная монада, конечно :)


  1. ReadOnlySadUser
    14.08.2022 17:46

    Я не вижу причинно-следственной связи между

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

    и

    Нам нужно что-то, что принимает функцию в качестве входных данных и преобразует ее в другую функцию.


    1. Gotcha7770 Автор
      14.08.2022 23:03

      у нас есть функция
      User -> CreditCard option
      а нужна нам
      User option -> CreditCard option
      Как нам из первой получить вторую? Ну можно взять функцию
      (User -> CreditCard option) -> (User option -> CreditCard option)

      Это функция высшего порядка, потому что оперирует другими функциями


      1. ReadOnlySadUser
        15.08.2022 00:27

        А можно просто переписать первую и не городить бессмысленные конструкции


        1. Gotcha7770 Автор
          15.08.2022 14:07

          Переписать каким образом?


          1. ReadOnlySadUser
            15.08.2022 16:09

            Чтобы эта функция принимала на вход аргупент option? Не, в целом подход с lift функцией прикольный, но обработка ошибок в lift отсутствует полностью, так что для промышленной рвзработки такой код неприемлем.


            1. Gotcha7770 Автор
              15.08.2022 18:50

              И как мне потом передать в такую функцию объект User?

              Что касается обработки ошибок, в этом конкретном примере ошибки - это возможное отсутствие результата lookupUser или chargeCard, их обработкой мы и занимаемся.


              1. ReadOnlySadUser
                16.08.2022 01:16

                И как мне потом передать в такую функцию объект User?

                Если надо, перед передачей упаковать в Option

                ошибки - это возможное отсутствие результата lookupUser или chargeCard, их обработкой мы и занимаемся

                Не занимаемся. Чем этот действительно занимается - так это заметанием ошибок под ковёр.

                Вот положим у меня цепочка вычислений из, ну скажем, 15 преобразований. Вернулся None. Надо понять почему. Как?


                1. Deosis
                  16.08.2022 07:31

                  Если вам надо понять почему, то возвращайте Error, а не None.


        1. AnthonyMikh
          16.08.2022 01:45

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


  1. GospodinKolhoznik
    14.08.2022 19:00
    -1

    Мне котенок в буррито больше нравится.


    1. Gotcha7770 Автор
      14.08.2022 19:53

      Видимо, я пропустил, потому что не знаю о чем речь)

      Это нормально, разным людям понятны разные объяснения. Лично я склоняюсь к тому, что надо и Милевского (или Милевски?) почитать, и инфографику посмотреть и попытаться где-то применить в своей сиюминутной задаче.


  1. ReadOnlySadUser
    14.08.2022 19:27
    +4

    Я считаю, что проблема всех туториалов про монады в том, что все объяснение монад ВСЕГДА заканчивается на Option, ценность которой, по моему мнению, около нуля, т.к. с помощью этой невероятно тупой монады не написать НИЧЕГО полезного. В реальном мире мы всегда хотим знать не только, что "что-то" пошло не так, но ещё и "где".

    Для этого есть другая монада (Result), но с ней начинается другая беда - ошибок в программе может быть больше тысячи. Бросая даже один тип исключения мы всегда получим stacktrace с точным местом ошибки, а для Result придется иметь кучу возможных возвращаемых значений + сложности с обработкой этих ошибок. Кстати, в целом всегда показывают монады в стиле: "смотрите какой у нас классный код линейный", но никогда не показывают код обработки ошибок этого линейного кода.

    Также всегда остаются за рамками обсуждений все вопросы связанные с соединением двух разных монад. Нефига не очевидно, как из одной монады получаются другие, как они между собой взаимодействуют и главное - почему это всё лучше обычного императивного подхода.


  1. ReadOnlySadUser
    14.08.2022 19:43

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


    1. Deosis
      15.08.2022 08:38

      В функциональном мире функция нескольких аргументов переделывается в функцию одного аргумента элементарно.


      1. ReadOnlySadUser
        15.08.2022 08:43
        +1

        Это я в курсе, но покажите мне код (с монадамм) после этого) Потому что это будет нечто невразумительное


        1. Deosis
          15.08.2022 11:14
          +1

          В Хаскеле это будет выглядеть примерно так:

          func <*> getFirstArg <*> getSecondArg <*> getThirdArg


          1. ReadOnlySadUser
            15.08.2022 16:05

            Что ж, в функции getSecondArg что-то пошло не так. Надо это залогировать и послать сообщение юзверю. Как?


            1. Deosis
              16.08.2022 07:37

              Так это не ответственность getSecondArg

              Или вы хотите передавать в функцию StringToInt почту юзверя, чтобы он получил сообщение о том, что не удалось преобразовать строку в число?