Монады в Erlang

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/post/466697/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *