Как устроены протоколы в 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.

    EXANTE
    36,00
    Инвестиционная компания нового поколения
    Поделиться публикацией

    Комментарии 12

      0

      Классный разбор внутренностей Elixir'a, спасибо!
      А вы не могли бы подробнее описать, почему решили остановиться на Erlang'е? Явно ведь не из-за протоколов — статья подводит к тому, что они удобны, а оверхед у них небольшой.

        +1
        Тому есть несколько причин.

        1. Erlang в нашей компании стал использоваться еще до появления Elixir. Соответственно у нас есть множество библиотек и устоявшихся практик как писать код на Erlang быстро и красиво. Взаимодействие Elixir -> Erlang писать достаточно легко, а вот Erlang -> Elixir значительно сложнее. К тому же сам по себе код на языке Erlang, хоть и выглядит поначалу непривычно, из-за своей простоты (малого количества синтаксических конструкций) довольно легко как читать, так и писать (особенно, после того как добавились мапы).
        А конструкции как Protocols или pipe оператор вполне можно «повторить».

        2. Erlang у нас используется не только для Web API но и для написания внутренних БД (например есть проект riak-core) и очень часто. Elixir же развивается в основном вокруг framework-a Phoenix и в таких проектах не дает ощутимых преимуществ.

        3. Erlang стабильнее Elixir и «детские болезни» прошел уже давно. А для нас важна стабильность.
          0
          А можно подробнее про повторение пайп оператора? А то оно все или смотрится чужеродно, или превращается в лисп.
            +2

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


            ... 
            Output = pipe(Input, [
                fetch_users(),
                update_users(),
                store_users_in_database(DbConnection)
            ]),
            ...

            где pipe/2 — простая свертка, например.


            pipe(Data, Funs) ->
                lists:foldl(fun(F, D) -> F(D) end, Data, Funs).

            плюс в том что на таких pipe-ах можно построить нечто похожее на монады.


            pipe(_Bind, Data, []) -> 
                Data;
            pipe(Bind, Data, [H|T]) -> 
                Bind(Data, fun(D) -> pipe(Bind, H(D), T) end).
            
            maybe(F, {just, Data}) -> F(Data);
            maybe(F, nothing) -> nothing.

            Usage


            1> m:pipe(fun m:maybe/2, {just, 11}, [
                fun(A) -> 
                    case A > 10 of 
                        true -> {just, A}; 
                        false -> nothing 
                    end 
                end, 
                fun(A) ->  {just, A*2} end
            ]).
            
            {just, 22}.

            или, если причесать через каррирование,


            filter_gt(A) -> fun(B) ->
                case A > B of
                    true -> {just, A}:
                    false -> nothing
                end
            end.
            
            do_mult(A) -> fun(B) -> {just, A*B} end.

            то будет просто


            1> m:pipe(fun m:maybe/2, {just, 11}, [
                m:filter_gt(10),
                m:mult(2)
            ]).
            
            {just, 22}

            но чаще используется pipe не на столько абстрактный, а под конкретный случай, например ok/error.


            pipe(D, []) -> D;
            pipe({ok, D}, [H|T]) -> pipe(H(D), T);
            pipe({error, _}=Err, _) -> Err.

            Конечно не сравнится с do нотацией Haskell или for в Scala, но жить можно :)


            Очень хорошо такой подход себя показал в нашей библиотеке для работы с Postgres


            1> repo:all(m_weather, [
                  q:where(fun([#{city := City}]) -> pg_sql:in(City, [<<"Kraków">>, <<"Moscow">>]) end),
                  q:order_by(fun([#{temp_lo := T}]) -> [{T, asc}] end),
                  q:limit(10)
               ]).

            Но также у него есть один минус Dialyzer не сможет провести детальную проверку типов, если State, который передается сквозь функции, меняет тип.


            К слову, я экспериментировал с добавлением pipe оператора в нативный синтаксис Erlang и это оказалось проще чем я думал :)

              0
              А как решение на foldl'е по производительности, не замеряли? Я имею в виду, если тоже самое переписать просто в последовательный вызов функций. Мы используем Erlando, но там парс-трансформы и проект, видимо, уже заброшен.
                0

                Если боитесь за производительность foldl можно использовать -compile(inline_list_funcs).


                Erlando я тоже использую, но только в собственных pet project-ах.

        0
        Особенно Александр Дюма понравился на картинке.
          0
          Айнстайн же…
            0
            У Айнстайна же лицо овальное, а тут явно круглое.
              0
              Набрал за зиму чуть-чуть.
          0

          Действительно, "декомпилировать beam-файлы и посмотреть, что же в итоге получилось", зачастую является лучшим способом понять, что же делает компилятор Эликсира.

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

            А вам нужна такая возможность?

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое