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

в 16:15, , рубрики: BEAM, compilers, Elixir, erlang

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)

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

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: nilnnIf 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: nilnnIf 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 не стесняйтесь интересоваться как же оно там внутри и не бойтесь "проваливаться" в код из документации, благо что язык максимально этому способствует!

Автор: Vegris

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js