Как устроены протоколы в Elixir

от автора

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

Elixir – это функциональный язык программирования общего назначения, который работает на виртуальной машине BeamVM. От Erlang отличается синтаксисом, более похожим на Ruby, и расширенными возможностями метапрограммирования.

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

Тогда как же они устроены внутри? Какой overhead дает код с использованием протоколов? Попробуем разобраться.

Есть два способа понять, что происходит внутри:

– разобраться с тем, как Elixir Compiler генерирует код для BeamVM,
– декомпилировать beam-файлы и посмотреть, что же в итоге получилось.

Второй способ намного проще, воспользуемся им.

Для начала создадим новый проект.

mix new proto cd proto

Теперь отредактируем файл lib/proto.ex на довольно простой пример.

defprotocol Double do   def double(input) end  defimpl Double, for: Integer do   def double(int) do     int * 2   end end  defimpl Double, for: List do   def double(list) do     list ++ list   end end

Тут мы объявили новый протокол Double с интерфейсом double/1 и две реализации этого протокола для Integer и List.

Проверим работоспособность:

iex(1)> Double.double(2) 4  iex(2)> Double.double([1,2,3]) [1, 2, 3, 1, 2, 3]  iex(3)> Double.double(:atom)   ** (Protocol.UndefinedError) protocol Double not implemented for :atom     (proto) lib/proto.ex:1: Double.impl_for!/1     (proto) lib/proto.ex:2: Double.double/1 

Теперь посмотрим на структуру скомпилированных файлов.

$ tree _build/dev/ _build/dev/ ├── consolidated │   ├── Elixir.Collectable.beam │   ├── Elixir.Double.beam │   ├── Elixir.Enumerable.beam │   ├── Elixir.IEx.Info.beam │   ├── Elixir.Inspect.beam │   ├── Elixir.List.Chars.beam │   └── Elixir.String.Chars.beam └── lib     └── proto         └── ebin             ├── Elixir.Double.beam             ├── Elixir.Double.Integer.beam             ├── Elixir.Double.List.beam             └── proto.app

Первое, что бросается в глаза – наличие модулей с одинаковыми именами в consolidated- и lib/proto/ebin-директориях. Рассмотрим их содержимое.

Для начала beam-файлы нужно декомпилировать. Для этого создадим escript-файл beam_to_erl

#!/usr/bin/env escript  main([BeamFile]) ->     {ok,{_,[{abstract_code,{_,AC}}]}} = beam_lib:chunks(BeamFile,[abstract_code]),     io:fwrite("~s~n", [erl_prettypr:format(erl_syntax:form_list(AC))]).

и пробежимся им по всем beam-файлам.

$ for f in $(find _build/ -name "*.beam"); do ./beam_to_erl $f > "${f%.beam}.erl"; done

$ tree _build/dev/ | grep -v ".beam" _build/dev/ ├── consolidated │   ├── Elixir.Collectable.erl │   ├── Elixir.Double.erl │   ├── Elixir.Enumerable.erl │   ├── Elixir.IEx.Info.erl │   ├── Elixir.Inspect.erl │   ├── Elixir.List.Chars.erl │   └── Elixir.String.Chars.erl └── lib     └── proto         └── ebin             ├── Elixir.Double.erl             ├── Elixir.Double.Integer.erl             ├── Elixir.Double.List.erl             └── proto.app

Рассмотрим содержимое файла lib/proto/ebin/Elixir.Double.erl.

-compile(no_auto_import).  -file("lib/proto.ex", 1).  -module('Elixir.Double').  -compile(debug_info).  -compile({inline,       [{any_impl_for, 0}, {struct_impl_for, 1},        {'impl_for?', 1}]}).  -protocol([{fallback_to_any, false}]).  -export_type([t/0]).  -type t() :: term().  -spec '__protocol__'('consolidated?') -> boolean();             (functions) -> [{double, 1}, ...];             (module) -> 'Elixir.Double'.  -spec impl_for(term()) -> atom() | nil.  -spec 'impl_for!'(term()) -> atom() | no_return().  -callback double(t()) -> term().  -export(['__info__'/1, '__protocol__'/1, double/1,      impl_for/1, 'impl_for!'/1]).  -spec '__info__'(attributes | compile | exports |          functions | macros | md5 | module |          native_addresses) -> atom() |                       [{atom(), any()} |                        {atom(), byte(), integer()}].  '__info__'(functions) ->     [{'__protocol__', 1}, {double, 1}, {impl_for, 1},      {'impl_for!', 1}]; '__info__'(macros) -> []; '__info__'(info) ->     erlang:get_module_info('Elixir.Double', info).  '__protocol__'(module) -> 'Elixir.Double'; '__protocol__'(functions) -> [{double, 1}]; '__protocol__'('consolidated?') -> false.  any_impl_for() -> nil.  double(_@1) -> ('impl_for!'(_@1)):double(_@1).  impl_for(#{'__struct__' := _@1})     when erlang:is_atom(_@1) ->     struct_impl_for(_@1); impl_for(_@1) when erlang:is_tuple(_@1) ->     case 'impl_for?'('Elixir.Double.Tuple') of       true -> 'Elixir.Double.Tuple':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_atom(_@1) ->     case 'impl_for?'('Elixir.Double.Atom') of       true -> 'Elixir.Double.Atom':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_list(_@1) ->     case 'impl_for?'('Elixir.Double.List') of       true -> 'Elixir.Double.List':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_map(_@1) ->     case 'impl_for?'('Elixir.Double.Map') of       true -> 'Elixir.Double.Map':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_bitstring(_@1) ->     case 'impl_for?'('Elixir.Double.BitString') of       true -> 'Elixir.Double.BitString':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_integer(_@1) ->     case 'impl_for?'('Elixir.Double.Integer') of       true -> 'Elixir.Double.Integer':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_float(_@1) ->     case 'impl_for?'('Elixir.Double.Float') of       true -> 'Elixir.Double.Float':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_function(_@1) ->     case 'impl_for?'('Elixir.Double.Function') of       true -> 'Elixir.Double.Function':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_pid(_@1) ->     case 'impl_for?'('Elixir.Double.PID') of       true -> 'Elixir.Double.PID':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_port(_@1) ->     case 'impl_for?'('Elixir.Double.Port') of       true -> 'Elixir.Double.Port':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_@1) when erlang:is_reference(_@1) ->     case 'impl_for?'('Elixir.Double.Reference') of       true -> 'Elixir.Double.Reference':'__impl__'(target);       false -> any_impl_for()     end; impl_for(_) -> any_impl_for().  'impl_for!'(_@1) ->     case impl_for(_@1) of       _@2 when (_@2 =:= nil) or (_@2 =:= false) ->       erlang:error('Elixir.Protocol.UndefinedError':exception([{protocol,                                     'Elixir.Double'},                                    {value,                                     _@1}]));       _@3 -> _@3     end.  'impl_for?'(_@1) ->     case 'Elixir.Code':'ensure_compiled?'(_@1) of       true ->       'Elixir.Kernel':'function_exported?'(_@1, '__impl__',                            1);       false -> false;       _@2 -> erlang:error({badbool, 'and', _@2})     end.  struct_impl_for(_@1) ->     _@2 = 'Elixir.Module':concat('Elixir.Double', _@1),     case 'impl_for?'(_@2) of       true -> _@2:'__impl__'(target);       false -> any_impl_for()     end.

А вот и вся магия. Давайте взглянем на функцию double/1.

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

Она ищет модуль, который подходит для передаваемого аргумента, через impl_for/1 и вызывает его реализацию.

А как найти модуль для аргумента? Очень просто:

– если это примитив или bif-тип, то просто ищем модуль с именем ‘Elixir.{ProtocolName}.{TypeName}’, где ProtocolName – имя протокола, TypeName – имя типа. Подгружем его, если еще не загружен, через 'Elixir.Code':'ensure_compiled?'/1. Проверяем, является ли модуль реализацией протокола через наличие функции '__impl__'/1, и получаем модуль реализации '__impl__'(target),
– если это структура, то смотрим на служебное поле __struct__ и таким же образом ищем модуль ‘Elixir.{ProtocolName}.{StructName}’,
– если реализация не найдена, проверяем наличие реализации по умолчанию для any-типа или возвращаем ошибку.

Реализация протокола же остается практически в неизменном виде. Добавляется лишь несколько системных функций. Например: 'Elixir.Double.Integer'.

-compile(no_auto_import).  -file("lib/proto.ex", 5).  -module('Elixir.Double.Integer').  -behaviour('Elixir.Double').  -impl([{protocol, 'Elixir.Double'},        {for, 'Elixir.Integer'}]).  -spec '__impl__'(protocol) -> 'Elixir.Double';         (target) -> 'Elixir.Double.Integer';         (for) -> 'Elixir.Integer'.  -export(['__impl__'/1, '__info__'/1, double/1]).  -spec '__info__'(attributes | compile | exports |          functions | macros | md5 | module |          native_addresses) -> atom() |                       [{atom(), any()} |                        {atom(), byte(), integer()}].  '__info__'(functions) -> [{'__impl__', 1}, {double, 1}]; '__info__'(macros) -> []; '__info__'(info) ->     erlang:get_module_info('Elixir.Double.Integer', info).  '__impl__'(for) -> 'Elixir.Integer'; '__impl__'(target) -> 'Elixir.Double.Integer'; '__impl__'(protocol) -> 'Elixir.Double'.  double(int@1) -> int@1 * 2.

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

Overhead при этом оказывается не таким уж и маленьким, особенно для высоконагруженных систем. Дело в постоянной проверке наличия модуля в runtime.

Для устранения этого недостатка была добавлена возможность «зашить» роутинг для известных на этапе компиляции реализаций протокола непосредственно в функцию диспетчеризации impl_for/1
Эта функция компилятора называется consolidated protocols и с Elixir v1.2 осуществляется автоматически во время сборки релиза через mix.

Взглянем на consolidated/Elixir.Double.erl.

-compile(no_auto_import).  -file("lib/proto.ex", 1).  -module('Elixir.Double').  -compile(debug_info).  -compile({inline,       [{any_impl_for, 0}, {struct_impl_for, 1},        {'impl_for?', 1}]}).  -protocol([{fallback_to_any, false}]).  -export_type([t/0]).  -type t() :: term().  -spec '__protocol__'('consolidated?') -> boolean();             (functions) -> [{double, 1}, ...];             (module) -> 'Elixir.Double'.  -spec impl_for(term()) -> atom() | nil.  -spec 'impl_for!'(term()) -> atom() | no_return().  -callback double(t()) -> term().  -export(['__info__'/1, '__protocol__'/1, double/1,      impl_for/1, 'impl_for!'/1]).  -spec '__info__'(attributes | compile | exports |          functions | macros | md5 | module |          native_addresses) -> atom() |                       [{atom(), any()} |                        {atom(), byte(), integer()}].  '__info__'(functions) ->     [{'__protocol__', 1}, {double, 1}, {impl_for, 1},      {'impl_for!', 1}]; '__info__'(macros) -> []; '__info__'(info) ->     erlang:get_module_info('Elixir.Double', info).  '__protocol__'(module) -> 'Elixir.Double'; '__protocol__'(functions) -> [{double, 1}]; '__protocol__'('consolidated?') -> true.  any_impl_for() -> nil.  double(_@1) -> ('impl_for!'(_@1)):double(_@1).  impl_for(#{'__struct__' := x}) when erlang:is_atom(x) ->     struct_impl_for(x); impl_for(x) when erlang:is_list(x) ->     'Elixir.Double.List'; impl_for(x) when erlang:is_integer(x) ->     'Elixir.Double.Integer'; impl_for(_) -> nil.  'impl_for!'(_@1) ->     case impl_for(_@1) of       _@2 when (_@2 =:= nil) or (_@2 =:= false) ->       erlang:error('Elixir.Protocol.UndefinedError':exception([{protocol,                                     'Elixir.Double'},                                    {value,                                     _@1}]));       _@3 -> _@3     end.  'impl_for?'(_@1) ->     case 'Elixir.Code':'ensure_compiled?'(_@1) of       true ->       'Elixir.Kernel':'function_exported?'(_@1, '__impl__',                            1);       false -> false;       _@2 -> erlang:error({badbool, 'and', _@2})     end.  struct_impl_for(_) -> nil.

Код модуля существенно меньше оригинала и, что немаловажно, impl_for отрабатывает в один шаг без проверки наличия модуля.

Итого

Иногда полезно взглянуть изнутри на то, как работает инструмент. Это дает нам возможность лучше понять его преимущества и недостатки.

Реализация протоколов же довольно проста и при использовании consolidated protocols дает незначительный overhead, предоставляя при этом хорошую абстракцию над структурами данных. Тем не менее аналогичный механизм можно легко добавить и в Erlang, но это потребует ручного написания функции динамической диспетчеризации.

Использовать Elixir или нет – выбор за вами. Но мы пока остаемся на Erlang.

ссылка на оригинал статьи https://habrahabr.ru/post/328528/


Комментарии

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

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