На Хабре можно найти много публикаций, раскрывающих как теорию монад, так и практику их применения. Большинство этих статей ожидаемо про Haskell. Я не буду в n-й раз пересказывать теорию. Сегодня мы поговорим про некоторые проблемы Erlang, способы их решения с помощью монад, частичного применения функций и синтаксического сахара из erlando – классной библиотеки от команды RabbitMQ.


Введение


В Erlang есть иммутабельность, а монад нет*. Но благодаря наличию в языке функционала parse_transform и реализации erlando, возможность использования монад в Erlang все же есть.


Про иммутабельность в самом начале повествования, я заговорил не случайно. Иммутабельность почти везде и всегда – одна из основных идей Erlang. Иммутабельность и чистота функций позволяет концентрировать свое внимание на разработке конкретной функции и не бояться сайд эффектов. Но новичкам в Erlang, пришедшим, например, из Java или Python, довольно трудно понять и принять идеи Erlang. Особенно если вспомнить про синтаксис Erlang. Кто пытался начать использовать Erlang, наверняка отмечал его необычность и самостийность. Во всяком случае, у меня накопилось много отзывов новичков и “странный” синтаксис лидирует в рейтинге.


Erlando


Erlando – набор расширений Erlang, дающий нам:


  • Частичное применение / каррирование функций с помощью Scheme-подобных cuts
  • Haskell-подобные do-нотации
  • import-as – синтаксический сахар для импорта функций из других модулей.

Замечание: Нижеприведенные примеры кода для иллюстрации фич erlando я взял из выступления Matthew Sackman’a, частично разбавив их своим кодом и объяснениями.


Абстракция Cut


Сразу к делу. Рассмотрим несколько функций из реального проекта:


info_all(VHostPath, Items) ->
map(VHostPath, fun (Q) -> info(Q, Items) end).

backing_queue_timeout(State = #q{ backing_queue = BQ }) ->
run_backing_queue(
BQ, fun (M, BQS) -> M:timeout(BQS) end, State).

reset_msg_expiry_fun(TTL) ->
fun (MsgProps) ->
MsgProps #message_properties{
expiry = calculate_msg_expiry(TTL)}
end.

Все эти функции созданы для подстановки параметров в простые выражения. На самом деле это частичное применение, так как некоторые параметры не будут известны до вызова. Вместе с гибкостью, эти функции привносят шум в наш код. Изменив немного синтаксис – введя cut – можно улучшить ситуацию.


Значение _


  • _ может использоваться в шаблонах
  • Cut позволяет использовать _ вне шаблонов
  • Если находится вне шаблона, то становится параметром для выражения в котором он находится
  • Множественное использование _ в рамках одного выражения приводит к подстановке нескольких параметров в это выражение
  • Cut это не замена замыканий (funs)
  • Аргументы вычисляются до cut функции

Cut использует _ в выражениях для указания, где должна быть применена абстракция. Cut оборачивает только ближайший уровень в выражении, но применение вложенных cut не запрещено.
Например list_to_binary([1, 2, math:pow(2, _)]). развернется в list_to_binary([1, 2, fun (X) -> math:pow(2, X) end]). но не в fun (X) -> list_to_binary([1, 2, math:pow(2, X)]) end..


Звучит слегка непонятно, давайте перепишем примеры выше с использованием cut:


info_all(VHostPath, Items) ->
     map(VHostPath, fun (Q) -> info(Q, Items) end).

info_all(VHostPath, Items) -> map(VHostPath, info(_, Items)).

backing_queue_timeout(State = #q{ backing_queue = BQ }) ->
    run_backing_queue(
     BQ, fun (M, BQS) -> M:timeout(BQS) end, State).

backing_queue_timeout(State = #q{backing_queue = BQ}) ->
    run_backing_queue(BQ, _:timeout(_), State).

reset_msg_expiry_fun(TTL) ->
    fun (MsgProps) ->
        MsgProps #message_properties {
        expiry = calculate_msg_expiry(TTL) }
    end.

reset_msg_expiry_fun(TTL) ->
    _ #message_properties { expiry = calculate_msg_expiry(TTL) }.

Порядок вычисления аргументов


Для иллюстрации порядка вычисления аргументов рассмотрим следующий пример:


f1(_, _) -> io:format("in f1~n").

test() ->
    F = f1(io:format("test line 1~n"), _),
    F(io:format("test line 2~n")).

Так как аргументы вычисляются до cut функции, на экран будет выведено:


test line 2
test line 1
in f1

Абстракция Cut в различных типах и шаблонах кода


  • Tuples
    F = {_, 3},
    {a, 3} = F(a).
  • Lists
    dbl_cons(List) -> [_, _ | List].
    test() ->
    F = dbl_cons([33]),
    [7, 8, 33] = F(7, 8).
  • Records
    -record(vector, { x, y, z }).
    test() ->
    GetZ = _#vector.z,
    7 = GetZ(#vector { z = 7 }),
    SetX = _#vector{x = _},
    V = #vector{ x = 5, y = 4 } = SetX(#vector{ y = 4 }, 5).
  • Cases
    F = case _ of
        N when is_integer(N) -> N + N;
        N -> N
    end,
    10 = F(5),
    ok = F(ok).
  • Maps
    test() ->
    GetZ = maps:get(z, _),
    7    = GetZ(#{ z => 7 }),
    SetX = _#{x => _},
    V    = #{ x := 5, y := 4 } = SetX(#{ y => 4 }, 5).
  • Сопоставление списков и конструирование бинарных данных
    test_cut_comprehensions() ->
    F = << <<(1 + (X*2))>> || _ <- _, X <- _ >>, %% Note, this'll only be a /2 !
    <<"AAA">> = F([a,b,c], [32]),
    F1 = [ {X, Y, Z} || X <- _, Y <- _, Z <- _,
                        math:pow(X,2) + math:pow(Y,2) == math:pow(Z,2) ],
    [{3,4,5}, {4,3,5}, {6,8,10}, {8,6,10}] =
        lists:usort(F1(lists:seq(1,10), lists:seq(1,10), lists:seq(1,10))).

Pros


  • Кода стало меньше, следовательно его легче поддерживать.
  • Код стал проще и опрятнее.
  • Ушел шум от funs.
  • Для новичков в Erlang удобнее писать Get/Set функции.

Cons


  • Повышение порога входа для опытных Erlang разработчиков вместе с одновременным снижением порога входа для новичков. Теперь от команды требуется понимание cut и знание еще одного синтаксиса.

Do-нотация


Программная запятая – конструкция связывания вычислений. Erlang не имеет ленивой модели вычислений. Давайте представим, что было бы, если Erlang был бы ленив как Haskell


my_function() ->
    A = foo(),
    B = bar(A, dog),
    ok.

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


my_function() ->
    A = foo(),
    comma(),
    B = bar(A, dog),
    comma(),
    ok.

Продолжим преобразование:


my_function() ->
   comma(foo(),
         fun (A) -> comma(bar(A, dog),
                          fun (B) -> ok end)).

Исходя из вывода, comma/2 является идиоматической функцией >>=/2. Монада требует только три функции: >>=/2, return/1 и fail/1.
Все бы ничего, но синтаксис просто ужасен. Применим трансформеры синтаксиса из erlando.


do([Monad ||
      A <- foo(),
      B <- bar(A, dog),
      ok]).

Типы монад


Поскольку do-блок параметризован, мы можем использовать монады различного типа. Внутри do-блока вызовы return/1 и fail/1 разворачиваются в Monad:return/1 и Monad:fail/1 соответственно.


  • Identity-monad.
    Тождественная монада – простейшая монада, не меняющая тип значений и не участвующая в управлении процессом вычислений. Применяется с трансформерами. Выполняет связывание выражений – программная запятая, рассмотренная выше.


  • Maybe-monad.
    Монада вычислений с обработкой отсутствующих значений. Связывание параметра с параметризованным вычислением – это передача параметра вычислению, связывание отсутствующего параметра с параметризованным вычислением – отсутствующий результат.
    Рассмотрим пример применения maybe_m:


    if_safe_div_zero(X, Y, Fun) ->
    do([maybe_m ||
        Result <- case Y == 0 of
                      true  -> fail("Cannot divide by zero");
                      false -> return(X / Y)
                  end,
        return(Fun(Result))]).

    Вычисление выражения прекращается, если возвращается nothing.


    {just, 6} = if_safe_div_zero(10, 5, _+4)  ## 10/5 = 2 -> 2+4 -> 6
    nothing = if_safe_div_zero(10, 0, _+4)

  • Error-monad.
    Аналогично maybe_m, только с обработкой ошибок. Иногда принцип let it crash неприменим и ошибки нужно обработать в момент их возникновения. В этом случае в коде часто появляются лесенки из case, например такие:


    write_file(Path, Data, Modes) ->
    Modes1 = [binary, write | (Modes -- [binary, write])],
    case make_binary(Data) of
        Bin when is_binary(Bin) ->
            case file:open(Path, Modes1) of
                {ok, Hdl} ->
                    case file:write(Hdl, Bin) of
                        ok ->
                            case file:sync(Hdl) of
                                ok ->
                                    file:close(Hdl);
                                {error, _} = E ->
                                    file:close(Hdl),
                                    E
                            end;
                        {error, _} = E ->
                            file:close(Hdl),
                            E
                    end;
                {error, _} = E -> E
            end;
        {error, _} = E -> E
    end.

    make_binary(Bin) when is_binary(Bin) ->
    Bin;
    make_binary(List) ->
    try
        iolist_to_binary(List)
    catch error:Reason ->
            {error, Reason}
    end.


Читать такое неприятно, выглядит как лапша callback в JS. На помощь приходит error_m:


write_file(Path, Data, Modes) ->
    Modes1 = [binary, write | (Modes -- [binary, write])],
    do([error_m ||
        Bin <- make_binary(Data),
        Hdl <- file:open(Path, Modes1),
        Result <- return(do([error_m ||
                             file:write(Hdl, Bin),
                             file:sync(Hdl)])),
        file:close(Hdl),
        Result]).

make_binary(Bin) when is_binary(Bin) ->
    error_m:return(Bin);
make_binary(List) ->
    try
        error_m:return(iolist_to_binary(List))
    catch error:Reason ->
            error_m:fail(Reason)
    end.

  • List-monad.
    Значения представляют собой списки, которые можно интерпретировать как несколько возможных результатов одного вычисления. Если одно вычисление зависит от другого, то второе вычисление производится для каждого результата первого, и полученные результаты (второго вычисления) собираются в список.
    Рассмотрим пример с классическими Пифагоровыми тройками. Вычислим их без монад:
    P = [{X, Y, Z} || Z <- lists:seq(1,20),
                      X <- lists:seq(1,Z),
                      Y <- lists:seq(X,Z),
                      math:pow(X,2) + math:pow(Y,2) == math:pow(Z,2)].

То же самое только с list_m:


P = do([list_m || Z <- lists:seq(1,20),
                  X <- lists:seq(1,Z),
                  Y <- lists:seq(X,Z),
                  monad_plus:guard(list_m, math:pow(X,2) + math:pow(Y,2) == math:pow(Z,2)),
                  return({X,Y,Z})]).

  • State-monad.
    Монада вычислений с изменяемым состоянием.
    В самом начале статьи мы говорили про трудности новичков при работе с изменяемым состоянием. Часто код выглядит как-то так:
    State1 = init(Dimensions),
    State2 = plant_seeds(SeedCount, State1),
    {DidFlood, State3} = pour_on_water(WaterVolume, State2),
    State4 = apply_sunlight(Time, State3),
    {DidFlood2, State5} = pour_on_water(WaterVolume, State4),
    {Crop, State6} = harvest(State5),
    ...

С помощью трансформатора и cut-нотации этот код можно переписать в более компактном и читаемом виде:


StateT = state_t:new(identity_m),
SM = StateT:modify(_),
SMR = StateT:modify_and_return(_),
StateT:exec(
  do([StateT ||
      StateT:put(init(Dimensions)),
      SM(plant_seeds(SeedCount, _)),
      DidFlood <- SMR(pour_on_water(WaterVolume, _)),
      SM(apply_sunlight(Time, _)),
      DidFlood2 <- SMR(pour_on_water(WaterVolume, _)),
      Crop <- SMR(harvest(_)),
      ...
      ]), undefined).

  • Omega-monad.
    Аналогична монаде list_m. Однако проход совершается диагонально.

Скрытая обработка ошибок


Наверное, одна из моих любимых фич монады error_m. Не важно, в каком месте произойдет ошибка, монада всегда вернет либо {ok, Result} либо {error, Reason}. Пример, иллюстрирующий поведение:


do([error_m ||
    Hdl <- file:open(Path, Modes),
    Data <- file:read(Hdl, BytesToRead),
    file:write(Hdl, DataToWrite),
    file:sync(Hdl),
    file:close(Hdl),
    file:rename(Path, Path2),
    file:delete(Path),
    return(Data)]).

Import_as


На закуску у нас синтаксический сахар import_as. Стандартный синтаксис атрибута -import/2 позволяет импортировать в локальный модуль функции из других. Однако этот синтаксис не позволяет присвоить альтернативное название импортированной функции. Import_as решает эту проблему:


-import_as({my_mod, [{size/1, m_size}]})
-import_as({my_other_mod, [{size/1, o_size}]})

Эти выражения разворачиваются в настоящие локальные функции соответственно:


m_size(A) -> my_mod:size(A).
o_size(A) -> my_other_mod:size(A).

Заключение


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


* — на самом деле в Erlang монады существуют и без erlando. Запятая, разделяющая выражения – это конструкция линеаризации и связывания вычислений.


P.S. Недавно библиотека erlando была помечена авторами, как архивная. Данную статью я написал больше года назад. Тогда, впрочем, как и сейчас, на Хабре не было информации по монадам в Erlang. Чтобы исправить эту ситуацию, я публикую, хоть и с опозданием, данную статью.
Для использования erlando в erlang >= 22 необходимо исправить проблему с deprecated erlang:get_stacktrace/0. Пример фикса можно найти в моем форке: https://github.com/Vonmo/erlando/commit/52e23ecedd2b8c13707a11c7f0f14496b5a191c2


Спасибо за ваше время!

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


  1. begemot_sun
    09.09.2019 21:00
    +2

    Всё хорошо, но это не стандартный синтаксис. А значит новый программист в команде не обязан это знать, а значит на это уйдет время. Если это опен сурс, то множество контрибьюторов уменьшается пропорционально сложности. Плюс т.к. это не стандартный синтаксис, и нет возможности как-то задокументировать где и как описано это поведение, то в случае «археологических раскопок древнего кода написанного на Erlang c применением parse_transform» будет непонятно что-и-где меняет поведение компилятора, и почему мы должны писать именно так а никак не иначе.
    Поэтому считаю, что parse_transform не прижились в мире Erlang именно по этой причине. Elixir конечно стандартизировал это, и ввел более лаконичное описание, то несомненный плюс для языка Elixir. Но и гораздо больше возможностей выстрелить себе в ногу за счет мета-программирования.

    Голосую за то, чтобы в Erlang ввести |> из Elixir.


    1. mr_elzor Автор
      09.09.2019 21:12

      Erlang не хватает пайпов из Elixir, это точно


    1. ip1981
      10.09.2019 08:39

      Есть подозрения, что большое количество "контрибьютеров" вредно для здоровья => https://singaporedatacompany.com/blog/more-developers-more-problems


      1. begemot_sun
        10.09.2019 09:14

        Безусловно. Это вообще относиться к любому количеству разработчиков продукта, будь то проприетарный или свободный.
        Чем больше оных, тем меньше ответственность и труднее донести до разработчика все внутренние интерфейсы продукта, их изменения.


  1. helions8
    09.09.2019 21:42

    Все бы хорошо, несмотря на «неродной» синтаксис и парстрансформы, но Erlando загнулся 4 года назад. Плюсую коммент выше — пайп был бы очень удобен.