Elixir - язык, вызвавшийся заново открыть Erlang современному миру. Синтаксис без приятных сердцу, но уже архаичных знаков пунктуации; культура разработки с особым вниманием к качеству и удобству инструментов; полноценный набор решений для написания web-сервисов; стандартная библиотека без груза в несколько десятилетий и настоящие макросы.
Если задуматься, то непосредственно в самом языке не так уж и много нового. Действительно, зная и Elixir и Erlang, можно представить как код на одном языке будет выглядеть на другом. Хотя и не всегда - в Elixir имеются выражения, которым нет эквивалента в Erlang. Как же они работают? Очевидно, Elixir раскрывает их в какой-то дополнительный Erlang код на этапе компиляции. Иногда можно интуитивно представить в какой, а иногда (спойлер) компилятор может подкинуть пару сюрпризов.
Эта статья - обзор преобразований, которые проходит код на Elixir прежде чем попасть в компилятор Erlang. Мы посмотрим на условные выражения вроде if и cond, уделим внимание точке, посмотрим на приключения с with и for, приоткроем тайны протоколов и удивимся оптимизациям, которые Elixir умудряется производить.
Так как конечным результатом работы Elixir компилятора является Erlang Abstract Code - синтаксическое дерево Erlang, то из него легко можно восстановить Erlang код. В этом нам поможет следующая функция:
@spec to_abstract(module()) :: String.t()
def to_abstract(module) do
module
|> :code.get_object_code() # Получаем загруженный BEAM код
|> then(fn {_module, beam, _path} -> beam end)
|> :beam_lib.chunks([:abstract_code]) # Достаём из debug секции Abstract Code
|> then(fn result ->
{:ok, {_, [abstract_code: {:raw_abstract_v1, abstract_code}]}} = result
abstract_code
end)
|> :erl_syntax.form_list()
|> :erl_prettypr.format() # Формируем из Abstract Code исходный код на Erlang
|> List.to_string()
end
Не стесняйтесь воспользоваться ею сами, если вдруг не найдёте в статье интересующих вас вещей. Полный код можете взять на GitHub.
Дальше будет много кода на Erlang, так что знание языка пригодится. Но даже если никогда раньше не встречались с Erlang - не беда, сложных синтаксических конструкций там не будет. Для понимания хватит и знакомства с Elixir.
Условные выражения
Начнём с простого. В отличии от Erlang, который оперирует только булевыми значениями, Elixir также включает понятие деления значений на truthy и falsy, согласно которому значения nil и false - falsy, а все остальные - truthy.
Соответственно все выражения, которые оперируют этими понятиями, должны раскрываться в какой-то понятный для BEAM код:
Kernel.if/2
def if_thing(thing) do
if thing, do: :thing, else: :other_thing
end
if_thing(_thing@1) ->
case _thing@1 of
_@1 when _@1 =:= false orelse _@1 =:= nil -> other_thing;
_ -> thing
end.
Kernel.SpecialForms.cond/1
def cond_example do
cond do
:erlang.phash2(1) -> 1
:erlang.phash2(2) -> 2
:otherwise -> :ok
end
end
cond_example() ->
case erlang:phash2(1) of
_@3 when _@3 /= nil andalso _@3 /= false -> 1;
_ ->
case erlang:phash2(2) of
_@2 when _@2 /= nil andalso _@2 /= false -> 2;
_ ->
case otherwise of
_@1 when _@1 /= nil andalso _@1 /= false -> ok;
_ -> erlang:error(cond_clause)
end
end
end.
Kernel.!/1
def negate(thing), do: !thing
negate(_thing@1) ->
case _thing@1 of
_@1 when _@1 =:= false orelse _@1 =:= nil -> true;
_ -> false
end.
И действительно - каждое условное выражение, опирающееся на это деление раскрывается в case, который в отрицательном плече проверяет вхождение значение в категорию falsy.
Но это не всегда так. Например:
def if_bool(thing) do
if is_nil(thing), do: :thing, else: :other_thing
if thing != nil, do: :thing, else: :other_thing
end
if_bool(_thing@1) ->
case _thing@1 == nil of
false -> other_thing;
true -> thing
end,
case _thing@1 /= nil of
false -> other_thing;
true -> thing
end.
Это первая оптимизация из тех, которые нам приготовил компилятор - если Elixir уверен, что деление будет проходить только по булевым значениям, то он генерирует case
case Value of
true -> success;
false -> failure
end
без учёта truthy/falsy как в общем случае
case Value of
Value when Value =:= false orelse Value =:= nil -> failure;
_ -> success
end
Условие оптимизации выглядит так:
case lists:member({optimize_boolean, true}, Meta) andalso elixir_utils:returns_boolean(EExpr) of
true -> rewrite_case_clauses(Opts);
false -> Opts
end,
Как можно видеть, для применения оптимизации компилятором нужно чтобы:
a) выражение было отмечено флагом optimize_boolean. Флаг устанавлен компилятором для выражений if, !, !!, and и or. !! - ещё одна оптимизация, схлопывающая двойное отрицание в проверку на truthiness, а and и or хотя и оперируют исключительно булевыми значениями, но optimize_boolean позволяет не генерировать для них третье плечо в case, которое бросает исключение BadBooleanError.
b) Elixir был способен понять, что результатом операции будет булево значение. В основном это ситуации когда вызывается операция или is_ guard из модуля :erlang, но рассматриваются также и более сложные случаи. Например если компилятор видит, что каждое плечо case или cond возвращает булевы значения, то он способен понять что и выражение целиком тоже будет возвращать только булевы значения.
Все условия можно посмотреть здесь, прикладываю кусочек для наглядности:
returns_boolean(Bool) when is_boolean(Bool) -> true;
returns_boolean({{'.', _, [erlang, Op]}, _, [_]}) when Op == 'not' -> true;
returns_boolean({{'.', _, [erlang, Op]}, _, [_, _]}) when
Op == 'and'; Op == 'or'; Op == 'xor';
Op == '=='; Op == '/='; Op == '=<'; Op == '>=';
Op == '<'; Op == '>'; Op == '=:='; Op == '=/=' -> true;
returns_boolean({{'.', _, [erlang, Op]}, _, [_, Right]}) when
Op == 'andalso'; Op == 'orelse' ->
returns_boolean(Right);
returns_boolean({{'.', _, [erlang, Fun]}, _, [_]}) when
Fun == is_atom; Fun == is_binary; Fun == is_bitstring; Fun == is_boolean;
Fun == is_float; Fun == is_function; Fun == is_integer; Fun == is_list;
Fun == is_number; Fun == is_pid; Fun == is_port; Fun == is_reference;
Fun == is_tuple; Fun == is_map; Fun == is_process_alive -> true;
...
Доступ по ключу
Современное удобство, которого нет в Erlang - отдельный синтаксис для доступа в структуру данных по ключу.
Есть доступ через квадратные скобки [], который просто трансформируется в вызов Access.get/3:
def brackets(data) do
data[:field]
end
brackets(_data@1) ->
'Elixir.Access':get(_data@1, field).
А есть доступ через точку, который работает только для словарей (maps) и только с ключами-атомами, и с которым всё чуточку интереснее:
def dot(map) when is_map(map) do
map.field
end
dot(_map@1) when erlang:is_map(_map@1) ->
case _map@1 of
#{field := _@2} -> _@2;
_@2 ->
case elixir_erl_pass:no_parens_remote(_@2, field) of
{ok, _@1} -> _@1;
_ -> erlang:error({badkey, field, _@2})
end
end;
Вместо того, чтобы скомпилироваться например в erlang:map_get/2, такая запись раскрывается в два вложенных case.
Первое плечо - это возвращение значения по ключу из словаря, а вложенный case - это расплата за ошибки молодости.
Дело в том, что Elixir позволяет не писать скобки при вызове функции:
iex(1)> DateTime.utc_now
~U[2025-02-17 12:35:39.575764Z]
Это же справедливо и если модуль определяется в runtime:
iex(2)> mod = DateTime
DateTime
iex(3)> mod.utc_now
warning: using map.field notation (without parentheses) to invoke function DateTime.utc_now() is deprecated, you must add parentheses instead: remote.function()
(elixir 1.18.2) src/elixir.erl:386: :elixir.eval_external_handler/3
(stdlib 6.2) erl_eval.erl:919: :erl_eval.do_apply/7
(stdlib 6.2) erl_eval.erl:479: :erl_eval.expr/6
(elixir 1.18.2) src/elixir.erl:364: :elixir.eval_forms/4
(elixir 1.18.2) lib/module/parallel_checker.ex:120: Module.ParallelChecker.verify/1
~U[2025-02-17 12:36:23.248233Z]
Легко заметить, что по записи mod.utc_now непонятно, вызов ли это функции или доступ по ключу. Следовательно, Elixir вынужден к каждому обращению через точку генерировать код, который в runtime проверяет чем является значение, к которому происходит обращение.
Начиная с этого коммита теперь показывается предупреждение, но код всё ещё работает.
Забавно, что обратный случай тоже требует дополнительной логики:
def dot(module) when is_atom(module) do
module.function()
end
dot(_module@1) when erlang:is_atom(_module@1) ->
case _module@1 of
#{function := _@2} ->
elixir_erl_pass:parens_map_field(function, _@2);
_@2 -> _@2:function()
end.
Потому что доступ к словарю по ключу тоже можно завершать скобками:
iex(1)> map = %{field: :value}
%{field: :value}
iex(2)> map.field()
warning: using module.function() notation (with parentheses) to fetch map field :field is deprecated, you must remove the parentheses: map.field
(elixir 1.18.2) src/elixir.erl:386: :elixir.eval_external_handler/3
(stdlib 6.2) erl_eval.erl:919: :erl_eval.do_apply/7
(elixir 1.18.2) src/elixir.erl:364: :elixir.eval_forms/4
(elixir 1.18.2) lib/module/parallel_checker.ex:120: Module.ParallelChecker.verify/1
(iex 1.18.2) lib/iex/evaluator.ex:336: IEx.Evaluator.eval_and_inspect/3
:value
Erlang to the rescue!
Компилятор Erlang более агрессивно оптимизирует код, в том числе учитывая типы значений, если они известны на этапе компиляции. Он может полностью убрать оба case в случае если уверен что перед ним словарь.
Проще всего дать ему эту информацию в заголовке функции:
def function(data) when is_map(data)
или так
def function(%{} = data)
А в конце статьи мы в качестве бонуса попробуем посмотреть, насколько в принципе заметно влияние этой избыточной кодогенерации на итоговую программу. Не переключайтесь
