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 и 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)

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

Источник: https://habr.com/ru/articles/888650/


Интересные статьи

Интересные статьи

2 сентября 2024 года Softline сообщила, что Microsoft вводит ограничения с 12 сентября 2024 года на использование в РФ корпоративными клиентами некоторых продуктов, сервисов и служб Azure.
Методология тестирования на проникновение предполагает разделение поиска уязвимостей на несколько этапов. Один из первых этапов заключается в поиске легаси с известными уязвимостями, популярных мискон...
Общение с клиентом в переписке сильно отличается от общения вживую или по телефону. Одно из главных отличий — не всегда можно понять и отследить, «услышал» ли нас человек или сообщение ушло впустую.&n...
Привет, меня зовут Максим Кульгин, моя компания xmldatafeed занимается парсингом много лет. Встал вопрос поиска в команду менеджера по проектам, который будет обрабатывать входящие заявки на...
Компания “Волжское пароходство” —  это больше 3 тысяч сотрудников, часть из которых используют Битрикс24 во время перевозок. По пути следования судна интернет не везде стабильный, поэтому нужен б...