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