На Хабре можно найти много публикаций, раскрывающих как теорию монад, так и практику их применения. Большинство этих статей ожидаемо про 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)
helions8
09.09.2019 21:42Все бы хорошо, несмотря на «неродной» синтаксис и парстрансформы, но Erlando загнулся 4 года назад. Плюсую коммент выше — пайп был бы очень удобен.
begemot_sun
Всё хорошо, но это не стандартный синтаксис. А значит новый программист в команде не обязан это знать, а значит на это уйдет время. Если это опен сурс, то множество контрибьюторов уменьшается пропорционально сложности. Плюс т.к. это не стандартный синтаксис, и нет возможности как-то задокументировать где и как описано это поведение, то в случае «археологических раскопок древнего кода написанного на Erlang c применением parse_transform» будет непонятно что-и-где меняет поведение компилятора, и почему мы должны писать именно так а никак не иначе.
Поэтому считаю, что parse_transform не прижились в мире Erlang именно по этой причине. Elixir конечно стандартизировал это, и ввел более лаконичное описание, то несомненный плюс для языка Elixir. Но и гораздо больше возможностей выстрелить себе в ногу за счет мета-программирования.
Голосую за то, чтобы в Erlang ввести |> из Elixir.
mr_elzor Автор
Erlang не хватает пайпов из Elixir, это точно
ip1981
Есть подозрения, что большое количество "контрибьютеров" вредно для здоровья => https://singaporedatacompany.com/blog/more-developers-more-problems
begemot_sun
Безусловно. Это вообще относиться к любому количеству разработчиков продукта, будь то проприетарный или свободный.
Чем больше оных, тем меньше ответственность и труднее донести до разработчика все внутренние интерфейсы продукта, их изменения.