Монады в Erlang

uu-wxmn1samfdcrveqdv27zpvvm.jpeg

На Хабре можно найти много публикаций, раскрывающих как теорию монад, так и практику их применения. Большинство этих статей ожидаемо про 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

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

© Habrahabr.ru