Elixir: алхимия кодогенерации

от автора

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 и falsefalsy, а все остальные — 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) 

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

With

Kernel.SpecialForms.with/1 это на мой взгляд самое ценное новшество, что Elixir привнёс в мир, исполняющийся на BEAM. Настолько ценное, что Erlang скопировал его практически как есть, лишь назвав по-другому — maybe.
Можно было бы надеяться, что через пару версий компилятора with и будет транслироваться в maybe, если б не одно различие между ними — maybe не поддерживает guard’ы в своих сопоставлениях. Поэтому и сейчас, и в обозримом будущем Elixir’у придётся транслировать with вручную:

То что вы видите ниже называется анти-паттерн, но нам нужен именно такой код, чтобы посмотреть на with с else

def with_else(map) do   with {_, {:ok, data}} <- {:map, Map.fetch(map, "data")},        {_, {int, ""}} <- {:int, Integer.parse(data)} do     int   else     {:map, :error} -> {:error, :no_data}     {:int, _} -> {:error, :not_an_int}   end end 
with_else(_map@1) ->   _@2 = fun ({map, error}) -> {error, no_data};             ({int, _}) -> {error, not_an_int};             (_@1) -> erlang:error({else_clause, _@1})         end,   case {map, maps:find(<<"data">>, _map@1)} of     {_, {ok, _data@1}} ->       case {int, 'Elixir.Integer':parse(_data@1)} of         {_, {_int@1, <<>>}} -> _int@1;         _@3 -> _@2(_@3)       end;     _@3 -> _@2(_@3)   end. 

Здесь компилятор выносит плечи из else в отдельную лямбду, в которую передаются значения из case в случае несоответствия паттерну. Любопытно что ещё пару версий назад Elixir v1.16.3 генерировал более развесистный код:

with_else(_map@1) ->   case {map, maps:find(<<"data">>, _map@1)} of     {_, {ok, _data@1}} ->       case {int, 'Elixir.Integer':parse(_data@1)} of         {_, {_int@1, <<>>}} -> _int@1;         _@1 ->           case _@1 of             {map, error} -> {error, no_data};             {int, _} -> {error, not_an_int};             _@2 -> erlang:error({else_clause, _@2})           end         end;     _@1 ->       case _@1 of         {map, error} -> {error, no_data};         {int, _} -> {error, not_an_int};         _@2 -> erlang:error({else_clause, _@2})       end   end. 

Тогда компилятор повторял все варианты из else для каждого ветвления. Компилятор Erlang’а, которому Elixir отдавал такой код, был конечно способен в случаях подобных этому разобраться что к чему и удалить неиспользуемые варианты, но не всегда и не везде такой анализ срабатывал.

Кстати вариант без else превращается в простой и прямолинейный код без каких либо нюансов:

def without_else(map) do   with {:ok, data} <- fetch_data(map),        {:ok, int} <- parse_int(data) do     int   end end 
without_else(_map@1) ->   case fetch_data(_map@1) of     {ok, _data@1} ->       case parse_int(_data@1) of         {ok, _int@1} -> _int@1;         _@1 -> _@1       end;     _@1 -> _@1   end. 

For

Kernel.SpecialForms.for/1 это наверное самый замороченный синтаксический сахар в Elixir. Лично я стараюсь избегать его использования, но возможно кому-то нравится.

Самый простой for раскрывается в Enum.map:

def basic do   for i <- 1..5 do     i * 1   end end 
basic() ->   'Elixir.Enum':map(     #{'__struct__' => 'Elixir.Range', first => 1, last => 5, step => 1},     fun (_i@1) -> _i@1 * 1 end). 

Вариант с фильтром уже раскрывается в reduce. Стоит отметить, что результат «разворачивается» с помощью lists:reverse/1, а не через обёртку Enum.reverse/1, что экономит один вызов функции.

def filter do   for i <- 1..10, div(i, 2) == 0 do     i * 1   end end 
filter() ->   lists:reverse(     'Elixir.Enum':reduce(       #{'__struct__' => 'Elixir.Range', first => 1, last => 10, step => 1},       [],       fun (_i@1, _@1) ->          case _i@1 div 2 == 0 of            true -> [_i@1 * 1 | _@1];           false -> _@1         end       end)). 

Вариант с сопоставлением с образцом (pattern matching) переносит его в голову функции. Хотя guard почему-то раскрывается во вложенный case. Странно, учитывая что на этот guard налагаются такие же ограничения как и на остальные.

def match do   users = [user: "john", admin: "meg", guest: "barbara"]    for {type, name} when type != :guest <- users do     String.upcase(name)   end end 
match() ->   _users@1 = [{user, <<"john">>},               {admin, <<"meg">>},               {guest, <<"barbara">>}],   lists:reverse(     'Elixir.Enum':reduce(       _users@1,       [],       fun         ({_type@1, _name@1}, _@1) ->           case _type@1 /= guest of             true -> ['Elixir.String':upcase(_name@1) | _@1];             false -> _@1 end;         (_, _@1) -> _@1       end)). 

Оптимизация, о которой полезно знать: если компилятор видит что значение for никуда не сохраняется, то он генерирует код, который не собирает результат. То есть если в первом примере for раскрывался в map, то точно такой же for, но без сохранения значения раскроется в reduce с nil вместо аккумулятора:

def no_collect do   for i <- 1..5 do     i   end    :ok end 
no_collect() ->     'Elixir.Enum':reduce(#{'__struct__' => 'Elixir.Range',                            first => 1, last => 5, step => 1},                          [],                          fun (_i@1, _@1) -> begin _i@1, nil end end),     ok. 

Напоследок, если вы используете uniq: true, то компилятор дополнительно сохраняет значения в MapSet для проверки на уникальность:

def unique do   for i <- 1..10, uniq: true do     i   end end 

Разбиение на промежуточные переменные моё, в оригинале всё в одну строчку

unique() ->   Range = #{'__struct__' => 'Elixir.Range', first => 1, last => 10, step => 1},    Function = fun (Elem, Acc) ->     {List, MapSet} = Acc,     Key = Elem,     case MapSet of        #{Key := true} -> {List, MapSet};       #{} -> {[Key | List], MapSet#{Key => true}}     end   end,      Result = 'Elixir.Enum':reduce(Range, {[], #{}}, Function),   lists:reverse(erlang:element(1, Result)). 

Протоколы

Для рассмотрения протоколов возьмём пример из документации:

defmodule ElixirJourney.Protocols do   defprotocol Size do     def size(data)   end    defimpl Size, for: BitString do     def size(binary), do: byte_size(binary)   end    defimpl Size, for: Map do     def size(map), do: map_size(map)   end    defimpl Size, for: Tuple do     def size(tuple), do: tuple_size(tuple)   end    def protocol(value) do     Size.size(value)   end end 

Вызов реализации протокола никак по особенному не представляется, это просто вызов функции из модуля протокола (defprotocol и каждый defimpl генерируют отдельные модули):

-module('Elixir.ElixirJourney.Protocols').  ...  protocol(_value@1) ->   'Elixir.ElixirJourney.Protocols.Size':size(_value@1). 

Модули с реализациями тоже транслируются в ожидаемый вид, только с добавлением ссылки на behaviour протокола и мета-функцией __impl__, с помощью которой можно получить информацию о том, чья это реализация и для чего:

-module('Elixir.ElixirJourney.Protocols.Size.Map').  ...  -behaviour('Elixir.ElixirJourney.Protocols.Size').  ...  '__impl__'(for) -> 'Elixir.Map'; '__impl__'(protocol) ->     'Elixir.ElixirJourney.Protocols.Size'.  size(_map@1) -> erlang:map_size(_map@1). 

Вся мякотка же содержится в модуле самого протокола.

Реализация будет различаться в зависимости от того, консолидированы ли протоколы или нет (флаг :consolidate_protocols в Mix проекте). Посмотрим сначала на консолидированный вариант:

-module('Elixir.ElixirJourney.Protocols.Size').  -behaviour('Elixir.Protocol').  -export_type([t/0]). -type t() :: term().  -callback size(t()) -> term(). 

Во-первых, модуль определяет себя как behaviour, а протокольные функции как его callback‘и. По умолчанию спецификации callback‘ов генерируется с использованием term(), но мы можем уточнить их в defprotocol:

defprotocol Size do   @type t :: bitstring() | map() | tuple()    @spec size(t()) :: non_neg_integer()   def size(data) end 
-export_type([t/0]). -type t() :: bitstring() | map() | tuple().  -callback size(t()) -> non_neg_integer(). 

Для модуля также генерируется своя мета-функция __protocol__, с помощью которой можно в runtime получить информацию о деталях протокола:

'__protocol__'(module) -> 'Elixir.ElixirJourney.Protocols.Size'; '__protocol__'(functions) -> [{size, 1}]; '__protocol__'('consolidated?') -> true; '__protocol__'(impls) -> {consolidated, ['Elixir.BitString', 'Elixir.Map', 'Elixir.Tuple']}. 

Сам вызов функции протокола выглядит как

size(_@1) -> ('impl_for!'(_@1)):size(_@1). 

где impl_for! нужен чтобы бросить исключение о неопределённом протоколе:

'impl_for!'(_@1) ->   case impl_for(_@1) of     _@2 when _@2 =:= false orelse _@2 =:= nil ->       erlang:error(         'Elixir.Protocol.UndefinedError':exception(           [             {protocol, 'Elixir.ElixirJourney.Protocols.Size'},             {value, _@1},             {description, <<>>}           ]));     _@3 -> _@3   end. 

а непосредственно выбор реализации проходит в impl_for:

impl_for(#{'__struct__' := _@1})     when erlang:is_atom(_@1) ->     struct_impl_for(_@1); impl_for(_@1) when erlang:is_tuple(_@1) ->     'Elixir.ElixirJourney.Protocols.Size.Tuple'; impl_for(_@1) when erlang:is_map(_@1) ->     'Elixir.ElixirJourney.Protocols.Size.Map'; impl_for(_@1) when erlang:is_bitstring(_@1) ->     'Elixir.ElixirJourney.Protocols.Size.BitString'; impl_for(_) -> nil.  struct_impl_for(_) -> nil. 

если бы у нашего протокола были реализации над структурами, эти структуры перечислялись бы в struct_impl_for

Вот собственно и всё. Компилятор смотрит какие реализации протокола есть во всём проекте и собирает из них impl_for, по которому происходит переход в нужную реализацию. Это и есть консолидация протоколов.

Если отключить консолидацию, то компилятору придётся сгенерировать вариант impl_for для каждого возможного типа, а доступность реализации проверить в runtime:

impl_for(#{'__struct__' := _@2 = _@1})   when erlang:is_atom(_@2) ->   struct_impl_for(_@1); impl_for(_@1) when erlang:is_tuple(_@1) ->   case 'Elixir.Code':ensure_compiled('Elixir.ElixirJourney.Protocols.Size.Tuple') of     {module, _@2} -> _@2;     {error, _} -> nil   end; impl_for(_@1) when erlang:is_atom(_@1) ->   case 'Elixir.Code':ensure_compiled('Elixir.ElixirJourney.Protocols.Size.Atom') of     {module, _@2} -> _@2;     {error, _} -> nil   end; impl_for(_@1) when erlang:is_list(_@1) ->   case 'Elixir.Code':ensure_compiled('Elixir.ElixirJourney.Protocols.Size.List') of     {module, _@2} -> _@2;     {error, _} -> nil   end;  ...  struct_impl_for(_@1) ->   case 'Elixir.Code':ensure_compiled('Elixir.Module':concat('Elixir.ElixirJourney.Protocols.Size', _@1)) of     {module, _@2} -> _@2;     {error, _} -> nil   end. 

Для struct_impl_for придётся дополнительно составить в runtime имя модуля с возможной реализацией.

Так как проверка доступности реализации теперь производится в runtime, мы можем динамически добавлять новые реализации, и виртуальная машина будет их подхватывать. Но и пользоваться такими протоколами станет дороже.

Строковая интерполяция

Кратко взглянем на строковую интерполяцию:

def interpolation do   "This #{:will} #{"be"} #{[97]} #{"str" <> "ing"}" end 
interpolation() ->   <<"This ",     case will of       _@1 when erlang:is_binary(_@1) -> _@1;       _@1 -> 'Elixir.String.Chars':to_string(_@1)     end/binary,     " ", "be", " ",     case [97] of       _@2 when erlang:is_binary(_@2) -> _@2;       _@2 -> 'Elixir.String.Chars':to_string(_@2)     end/binary,     " ",     case <<"str", "ing">> of       _@3 when erlang:is_binary(_@3) -> _@3;       _@3 -> 'Elixir.String.Chars':to_string(_@3)     end/binary>>. 

Каждое значение в фигурных скобках превращается в вызов протокола String.Chars, причём интересный момент: Elixir не полностью полагается на протокол, а генерирует отдельный case с быстрым возвратом для случая когда значение уже строка.

Вероятно здесь Elixir рассчитывает, что уже компилятор Erlang сможет в некоторых случаях убрать весь case, если увидит что значение точно будет строкой.

Вообще-то Elixir и сам способен на такое, но только в самом-самом простом варианте — когда передаётся строковый литерал, как со строкой "be" в нашем примере.

Вычисления на этапе компиляции

А здесь нас ждёт свежий (2024 года выпуска) набор оптимизаций, на него я натолкнулся совершенно случайно. В Elixir мы привыкли, что compiletime вычисления для нас так же доступны как и runtime. Хочешь чтобы что-то посчиталось при компиляции — вытащи это из функции в тело модуля и прилепи результат к @attribute.

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

Для Elixir v1.18.2 список выглядит так:

inline_pure_function('Elixir.Duration', 'new!') -> true; inline_pure_function('Elixir.MapSet', new) -> true; inline_pure_function('Elixir.String', length) -> true; inline_pure_function('Elixir.String', graphemes) -> true; inline_pure_function('Elixir.String', codepoints) -> true; inline_pure_function('Elixir.String', split) -> true; inline_pure_function('Elixir.Kernel', to_timeout) -> true; inline_pure_function('Elixir.URI', new) -> true; inline_pure_function('Elixir.URI', 'new!') -> true; inline_pure_function('Elixir.URI', parse) -> true; inline_pure_function('Elixir.URI', encode_query) -> true; inline_pure_function('Elixir.URI', encode_www_form) -> true; inline_pure_function('Elixir.URI', decode) -> true; inline_pure_function('Elixir.URI', decode_www_for) -> true; inline_pure_function('Elixir.Version', parse) -> true; inline_pure_function('Elixir.Version', 'parse!') -> true; inline_pure_function('Elixir.Version', parse_requirement) -> true; inline_pure_function('Elixir.Version', 'parse_requirement!') -> true; inline_pure_function(_Left, _Right) -> false. 

С помощью него построение нового MapSet может сразу превратиться в готовую структуру:

set = MapSet.new([1, 2, 3]) 
_set@1 = #{'__struct__' => 'Elixir.MapSet', map => #{1 => [], 2 => [], 3 => []}} 

А вместо подсчёта длины строки в модуль запечётся готовое значение:

length = String.length("static string") 
_length@1 = 13 

Эти оптимизации близорукие и рассчитывают что функции будут вызываться с литералами. Например такой вариант:

length = String.length("dynamic" <> " " <> "string") 

уже не оптимизируется

_length@2 = 'Elixir.String':length(<<"dynamic", " ", "string">>), 

В случае если вычисление возвращает ошибку, ошибка честно запекается:

version = Version.parse("static invalid") 
_version@1 = error, 

А вот если вычисление вызывает исключение, то компилятор оставляет код как есть, чтобы исключение бросилось в runtime как и должно:

version = Version.parse!("static invalid") 
_version@2 = 'Elixir.Version':'parse!'(<<"static invalid">>) 

Помимо простого списка чистых функций в компиляторе есть несколько отдельных случаев. Например превращение размера временного сдвига функций shift из модулей Date, DateTime, NaiveDateTime и Time в структуру Duration:

shifted = Date.shift(~D[2025-01-01], day: 1) 
_shifted@1 = 'Elixir.Date':shift(   #{'__struct__' => 'Elixir.Date', calendar => 'Elixir.Calendar.ISO', year => 2025, month => 1, day => 1},   #{     '__struct__' => 'Elixir.Duration', day => 1, hour => 0, microsecond => {0, 0},     minute => 0, month => 0, second => 0, week => 0, year => 0   } ) 

Так как эти оптимизации ориентируются только на имена модулей и функций, мы можем немного похулиганить, подменив встроенный модуль:

defmodule Version do   def parse(version) do     IO.puts(version)     version   end  end  defmodule M do   def run do     Version.parse("This will be printed at compiletime")   end end 

Elixir заругается на подмену, но код послушно исполнит:

❯ elixir script.exs     warning: redefining module Version (current version loaded from /usr/lib/elixir/lib/elixir/ebin/Elixir.Version.beam)     │   1 │ defmodule Version do     │ ~~~~~~~~~~~~~~~~~~~~     │     └─ script.exs:1: Version (module)  This will be printed at compiletime 

Бонус: «чиним» доступ через точку

Как я и обещал, небольшой бонус. Если вы уже подзабыли, о чём идет речь — компилятор вынужден генерировать дополнительный код, в зависимости от значения выбирающий как интерпретировать обращение вида data.field из-за двусмысленности синтаксиса:

def dot(map) when is_map(map) do   map.field end  def dot(module) when is_atom(module) do   module.function() 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; 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. 

Для начала измерим как это отражается на производительности:

Mix.install([:benchee])  # Прячем литерал за runtime вычислением, иначе BEAM способен запечь конкретное значение при использовании map_get map = Enum.random([%{name: "John Doe", age: 30, email: "john.doe@example.com"}])  Benchee.run(   %{     dot: fn ->       name = map.name       age = map.age       email = map.email        {name, age, email}     end,     pattern_match: fn ->       %{name: name, age: age, email: email} = map        {name, age, email}     end,     fetch!: fn ->       name = Map.fetch!(map, :name)       age = Map.fetch!(map, :age)       email = Map.fetch!(map, :email)        {name, age, email}     end   },   measure_function_call_overhead: true ) 

Здесь мы рассмотрим 3 варианта — доступ через точку, вычитывание всех трёх полей с помощью pattern-matching и Map.fetch! (который с помощью совместой работы Elixir и Erlang компиляторов в итоге превращается в :erlang.map_get).

Интуитивно я бы ожидал, что pattern-match будет первым т.к. в условиях динамической типизации одной операции (сопоставлению с образцом) достаточно только один раз проверить что значение — словарь.

Но результаты оказались другими:

Operating System: Linux CPU Information: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz Number of Available Cores: 8 Available memory: 15.31 GB Elixir 1.18.2 Erlang 27.2.1 JIT enabled: true  Benchmark suite executing with the following configuration: warmup: 2 s time: 5 s memory time: 0 ns reduction time: 0 ns parallel: 1 inputs: none specified Estimated total run time: 21 s  Measured function call overhead as: 19 ns Benchmarking dot ... Benchmarking fetch! ... Benchmarking pattern_match ... Calculating statistics... Formatting results...  Name                    ips        average  deviation         median         99th % fetch!              18.55 M       53.90 ns ±56533.65%          15 ns          22 ns pattern_match       18.47 M       54.15 ns ±59289.07%          13 ns          20 ns dot                 18.27 M       54.73 ns ±56682.74%          14 ns          18 ns  Comparison:  fetch!              18.55 M pattern_match       18.47 M - 1.00x slower +0.25 ns dot                 18.27 M - 1.02x slower +0.82 ns 

Такие микробенчмарки всегда нужно воспринимать скептически — слишком малые величины мы пытаемся измерить, и любая флуктуация (аппаратное прерывание, планировщик ядра и много чего ещё) будет вносить гигантские помехи в результат.

Но тем не менее на протяжении нескольких повторных запусков результаты никак не менялись. fetch! и pattern_match всегда очень близки друг к другу, а dot чуть-чуть отстаёт (на единицы процентов). Кажется, что дополнительный код практически не вредит времени исполнения.

И всё же попробуем представить что компилятор не генерирует дополнительный код. Отразится ли это на размере итоговой программы?

Сделать это несложно. Точка, как и все специальные формы, раскрываются в Erlang Abstract Code в файле lib/elixir/src/elixir_erl_pass.erl.

Даже будучи незнакомым с внутренностями компилятора и с подробностями представления в абстрактном коде, можно понять что этот код представляет собой то, что отдаётся Erlang’у как Abstract Code:

TError = {tuple, Ann, [{atom, Ann, badkey}, TRight, TVar]}, {{'case', Generated, TLeft, [   {clause, Generated,     [{map, Ann, [{map_field_exact, Ann, TRight, TVar}]}],     [],     [TVar]},   {clause, Generated,     [TVar],     [],     [{'case', Generated, ?remote(Generated, elixir_erl_pass, no_parens_remote, [TVar, TRight]), [       {clause, Generated,        [{tuple, Generated, [{atom, Generated, ok}, TInnerVar]}], [], [TInnerVar]},       {clause, Generated,        [{var, Generated, '_'}], [], [?remote(Ann, erlang, error, [TError])]}     ]}]} ]}, SV}; 

Эта ветка срабатывает для случая когда вызов через точку выглядит как data.field (без скобок). Для вида data.field() существует другая ветка, но смысл там примерно такой же.

Мы хотим сделать так, чтобы компилятор не генерировал никаких ветвлений, а возвращал конкретные операции. Для data.field() — вызов функции field модуля data, а для data.field:erlang.map_get(field, data). map_get подойдёт ещё и потому что возвращает то же самое KeyError исключение если ключ не найден.

Следующий патч делает именно это:

diff --git a/lib/elixir/src/elixir_erl_pass.erl b/lib/elixir/src/elixir_erl_pass.erl index f1c13ca24..7b3358011 100644 --- a/lib/elixir/src/elixir_erl_pass.erl +++ b/lib/elixir/src/elixir_erl_pass.erl @@ -237,40 +237,12 @@ translate({{'.', _, [Left, Right]}, Meta, []}, _Ann, S)    TRight = {atom, Ann, Right},      Generated = erl_anno:set_generated(true, Ann), -  {InnerVar, SI} = elixir_erl_var:build('_', SL), -  TInnerVar = {var, Generated, InnerVar}, -  {Var, SV} = elixir_erl_var:build('_', SI), -  TVar = {var, Generated, Var},      case proplists:get_value(no_parens, Meta, false) of      true -> -      TError = {tuple, Ann, [{atom, Ann, badkey}, TRight, TVar]}, -      {{'case', Generated, TLeft, [ -        {clause, Generated, -          [{map, Ann, [{map_field_exact, Ann, TRight, TVar}]}], -          [], -          [TVar]}, -        {clause, Generated, -          [TVar], -          [], -          [{'case', Generated, ?remote(Generated, elixir_erl_pass, no_parens_remote, [TVar, TRight]), [ -            {clause, Generated, -             [{tuple, Generated, [{atom, Generated, ok}, TInnerVar]}], [], [TInnerVar]}, -            {clause, Generated, -             [{var, Generated, '_'}], [], [?remote(Ann, erlang, error, [TError])]} -          ]}]} -      ]}, SV}; +      {{call, Generated, {remote, Generated, {atom, Ann, erlang}, {atom, Ann, map_get}}, [TRight, TLeft]}, SL};      false -> -      {{'case', Generated, TLeft, [ -        {clause, Generated, -          [{map, Ann, [{map_field_exact, Ann, TRight, TVar}]}], -          [], -          [?remote(Generated, elixir_erl_pass, parens_map_field, [TRight, TVar])]}, -        {clause, Generated, -          [TVar], -          [], -          [{call, Generated, {remote, Generated, TVar, TRight}, []}]} -      ]}, SV} +      {{call, Generated, {remote, Generated, TLeft, TRight}, []}, SL}      end;    translate({{'.', _, [Left, Right]}, Meta, Args}, _Ann, S) 

Применяем его и собираем компилятор:

❯ git apply dot-to-maps-get.patch ❯ make compile 

Запускаем тесты, чтобы убедиться что ничего не сломали:

❯ make test 

Падает только один тест, который жалуется на несоответствие сообщения в исключении:

  1) test blaming annotates undefined key error with nil hints (ExceptionTest)      test/elixir/exception_test.exs:678      Assertion with == failed      code:  assert blame_message(nil, & &1.foo) ==               "key :foo not found in: nil\n\nIf you are using the dot syntax, " <>                 "such as map.field, make sure the left-hand side of the dot is a map"      left:  "expected a map, got: nil"      right: "key :foo not found in: nil\n\nIf you are using the dot syntax, such as map.field, make sure the left-hand side of the dot is a map"      stacktrace:        test/elixir/exception_test.exs:679: (test) 

Это мы переживём. В остальном всё в порядке.

Добавим папку с собранным компилятором в PATH первой записью:

❯ PATH="path_to_elixir/elixir/bin:$PATH" 

и убедимся, что код новым компилятором генерируется такой как мы хотели:

dot(_map@1) when erlang:is_map(_map@1) ->   erlang:map_get(field, _map@1); dot(_module@1) when erlang:is_atom(_module@1) ->   _module@1:function(). 

Да, действительно всё так как нужно.

На всякий случай замеряем результат:

... Name                    ips        average  deviation         median         99th % pattern_match       19.09 M       52.37 ns ±60540.61%          12 ns          22 ns dot                 18.66 M       53.60 ns ±57166.61%          14 ns          26 ns fetch!              18.08 M       55.30 ns ±56749.55%          14 ns          28 ns  Comparison: pattern_match       19.09 M dot                 18.66 M - 1.02x slower +1.23 ns fetch!              18.08 M - 1.06x slower +2.93 ns 

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

Попробуем оценить вклад в размер скомпилированного кода от этих изменений. Возьмём Phoenix v1.7.20, скомпилируем его двумя вариантами компилятора и сравним совокупный размер полученных BEAM файлов.

❯ git clone git@github.com:phoenixframework/phoenix.git && cd phoenix ... ❯ mix deps.get ... ❯ mix compile && du -s _build/dev ... 5400 ❯ mix clean --deps ❯ PATH="path_to_elixir/elixir/bin:$PATH" mix compile && du -s _build/dev ... 5336 

Как можно видеть, изменения есть, но совсем минимальные. Размер кода уменьшился примерно на 1%.

Таким образом можно с чистой совестью заключить, что дополнительный код, генерируемый для обращений через точку практически не влияет на быстродействие и размер кода 👌

Заключение

Вот такой вот получился экскурс. Как видите, даже работая сугубо на синтаксическом уровне (переводя Elixir AST в Erlang AST), компилятор способен делать множество интересных вещей.

Иногда встречается такое мнение, что пользуясь Elixir вы теряете в производительности, так как Erlang по сравнению с ним более низкоуровневый язык. Формально это действительно так, но на деле эти потери либо минимальны, либо совершенно сглаживаются при компиляции.

Ещё одна мысль, которая вертится у меня в голове уже давно и которая только укрепилась с погружением в компилятор: сложность кода на Erlang/Elixir примерно одинаковая независимо от сложности проекта, в котором он располагается. Всегда это просто функции, которые работают с данными. Данные могут быть структурами, Ecto схемами в web-приложении, а могут представлять синтаксическое дерево в компиляторе — всё равно это будут данные, с которыми работают обычные функции. Это редкое качество. По моему опыту код «внутренностей» самого языка или его основных фреймворков всегда сильно отличается от кода, который пишет обычный программист на этом языке.

Так что пользуясь Elixir не стесняйтесь интересоваться как же оно там внутри и не бойтесь «проваливаться» в код из документации, благо что язык максимально этому способствует!


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


Комментарии

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

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