Использование parse_transform

    Disclaimer: Описываемый инструмент имеет спорную репутацию. Я не призываю использовать его где ни попадя, только знакомлю с используемыми понятиями, дабы уменьшить некоторым трепет перед технологией.

    Написанные исходники, а также текстовую копию статьи можно найти на гитхабе.

    Что такое parse_transform


    parse_transform — механизм изменения AST перед компиляцией. Предназначен для изменения значения конструкций (семантики), не выходя за синтаксис Эрланга.

    К сожалению, в Сети мало информации про это, что делает порог вхождения весьма высоким для не-гуру эрланга.

    Что мы будем делать


    В рамках данной статьи я немного рассказажу про AST эрланга, приведу пример простых трансформаций, а так же покажу процесс написания parse_transform для создания stateless gen_server-а (задача имеет не особо много смысла, но в качестве примера использования сгодится), а в конце дам ссылку на набор начинающего транформатора.


    AST в Эрланге


    На всякий случай: определение AST

    Лучше один раз увидеть AST, чем сто раз прочитать его описание. Поэтому мы напишем маленький модуль, чтобы увидеть, как преоразуется каждая строчка.

    Итак, исходный текст astdemo.erl:
    -module(astdemo).
    -export([hello/0, hello/2]).
    
    hello() ->
        hello("world", 1).
    
    hello(_What, 0) ->
        ok;
    hello(What, Count) ->
        io:format("Hello, ~s~n", [What]),
        hello(What, Count - 1).
    


    Чтобы увидеть AST, нужно натравить на этот файл функцию parse_file из модуля epp:
    Eshell V5.8.5  (abort with ^G)
    1> {ok, Forms} = epp:parse_file("astdemo.erl", [], []), io:format("~p~n", [Forms]).
    [{attribute,1,file,{"astdemo.erl",1}},
     {attribute,1,module,astdemo},
     {attribute,2,export,[{hello,0},{hello,2}]},
     {function,4,hello,0,
               [{clause,4,[],[],
                        [{call,5,
                               {atom,5,hello},
                               [{string,5,"world"},{integer,5,1}]}]}]},
     {function,7,hello,2,
               [{clause,7,[{var,7,'_What'},{integer,7,0}],[],[{atom,8,ok}]},
                {clause,9,
                        [{var,9,'What'},{var,9,'Count'}],
                        [],
                        [{call,10,
                               {remote,10,{atom,10,io},{atom,10,format}},
                               [{string,10,"Hello, ~s~n"},
                                {cons,10,{var,10,'What'},{nil,10}}]},
                         {call,11,
                               {atom,11,hello},
                               [{var,11,'What'},
                                {op,11,'-',{var,11,'Count'},{integer,11,1}}]}]}]},
     {eof,12}]
    ok
    


    Видно, что каждое выражение преобразуется в тюпл длины не менее 3, при этом первые два элемента всегда тип и строка, далее идет специфическое для него описание. Если непонятно, что стоит на конкретном месте, документация к вашим услугам.

    Функция parse_transform/2


    Давайте теперь сделаем dummy-parse_transform, чтобы увидеть, с чем придется иметь дело дальше. Для этого создадим модуль, который займется трансформацией, и вместо манипуляций над AST просто распечатаем его.

    Итак, demo_pt.erl:
    -module(demo_pt).
    -export([parse_transform/2]).
    
    parse_transform(Forms, _Options) ->
        io:format("~p~n", [Forms]),
        Forms.
    


    Вставляем в astdemo.erl соответствующую директиву:
    -module(astdemo).
    -compile({parse_transform, demo_pt}).
    -export([hello/0, hello/2]).
    ...........
    


    Компилируем:
    Eshell V5.8.5  (abort with ^G)
    1> c(astdemo).
    [{attribute,1,file,{"./astdemo.erl",1}},
     {attribute,1,module,astdemo},
     {attribute,3,export,[{hello,0},{hello,2}]},
     {function,5,hello,0,
               [{clause,5,[],[],
                        [{call,6,
                               {atom,6,hello},
                               [{string,6,"world"},{integer,6,1}]}]}]},
     {function,8,hello,2,
               [{clause,8,[{var,8,'_What'},{integer,8,0}],[],[{atom,9,ok}]},
                {clause,10,
                        [{var,10,'What'},{var,10,'Count'}],
                        [],
                        [{call,11,
                               {remote,11,{atom,11,io},{atom,11,format}},
                               [{string,11,"Hello, ~s~n"},
                                {cons,11,{var,11,'What'},{nil,11}}]},
                         {call,12,
                               {atom,12,hello},
                               [{var,12,'What'},
                                {op,12,'-',{var,12,'Count'},{integer,12,1}}]}]}]},
     {eof,13}]
    {ok,astdemo}


    Как видно, AST тот же самый (с точностью до смещения строк), но в этот раз он распечатан во время компиляции.
    Следует отметить, что в прибывшем для трансформации AST уже удалены директивы компилятора.

    Что передается в опциях, любознательный читатель, вероятно, узнает самостоятельно. Эта статья об AST.

    Первые трансформации


    Давайте для тренировки сделаем бесполезную на практике вещь — переименуем функцию «hello/0» в «hi/0». Это будет просто сделать, поскольку hello/0 не вызывается изнутри модуля, а имеет только возможность быть вызванной извне. Поэтому достаточно изменить список экспортов и заголовок функции.

    Трансформатор одной формы


    Поскольку AST (биндинг Forms) является списком, каждый элемент которого является формой очень короткого списка типов, логично пропустить все Forms через функцию-мутатор. Поскольку поставленная задача проста, и трансформация каждого выражения не зависит от остального содержимого, нам подойдет lists:map.
    Функция, которая будет изменять экспорты и заголовки функций, будет выглядеть примерно так:
    % hello_to_hi replaces occurences of hello/0 with hi/0
    hello_to_hi({attribute, Line, export, Exports}) ->
        % export attribute. Replace {hello, 0} with {hi, 0}
        HiExports = lists:map(
            fun ({hello, 0}) -> {hi, 0}; 
                (E) -> E 
            end, Exports),
        {attribute, Line, export, HiExports};
    
    hello_to_hi({function, Line, hello, 0, Clauses}) ->
        % Header of hello/0. Just replace hello with hi
        {function, Line, hi, 0, Clauses};
    
    hello_to_hi(Form) ->
        % Default: do not modify form
        Form.
    


    Теперь всё вместе


    Задействуем эту функцию, изменив код функции parse_transform:
    
    parse_transform(Forms, _Options) ->
        HiForms = lists:map(fun hello_to_hi/1, Forms),
        io:format("~p~n", [HiForms]),
        HiForms.
    


    Компилируем demo_pt, удостоверяемся, что не накосячили.

    Проверяем


    Пробуем с новым трансформатором скомпилировать astdemo:

    Eshell V5.8.5  (abort with ^G)
    1> c(astdemo).
    [{attribute,1,file,{"./astdemo.erl",1}},
     {attribute,1,module,astdemo},
     {attribute,3,export,[{hi,0},{hello,2}]},
     {function,5,hi,0,
               [{clause,5,[],[],
                        [{call,6,
                               {atom,6,hello},
                               [{string,6,"world"},{integer,6,1}]}]}]},
     {function,8,hello,2,
               [{clause,8,[{var,8,'_What'},{integer,8,0}],[],[{atom,9,ok}]},
                {clause,10,
                        [{var,10,'What'},{var,10,'Count'}],
                        [],
                        [{call,11,
                               {remote,11,{atom,11,io},{atom,11,format}},
                               [{string,11,"Hello, ~s~n"},
                                {cons,11,{var,11,'What'},{nil,11}}]},
                         {call,12,
                               {atom,12,hello},
                               [{var,12,'What'},
                                {op,12,'-',{var,12,'Count'},{integer,12,1}}]}]}]},
     {eof,13}]
    {ok,astdemo}
    2> astdemo:hi().
    Hello, world
    ok


    Прекрасно! Отработало, как и хотели. Время сделать что-то чуть более полезное.

    Stateless gen_server parse_transform


    Иногда при написании модуля с поведением gen_server нет нужды таскать за собой State, поскольку хранить в нем нечего, а протаскивание State из handle_anything в финальное выражение засоряет код. Давайте сделаем parse_transform, который позволит определять handle_call/2, handle_cast/1, handle_info/1. Или нет. Чтобы сделать статью чуть короче, я покажу только трансформацию handle_call/2 -> handle_call/3, а те, кому интересно, доопределят все остальное.

    Концепция


    Поведение gen_server требует определения handle_call (для простоты) таким образом (документация):
    handle_call(Request, From, State) ->
       .....
       {reply,Reply,NewState}.
    

    Поскольку мы избавляемся от необходимости учитывать State, пусть наш синтаксис будет таким:
    handle_call(Request, From) ->
       .....
       Reply.
    


    План трансформации


    • Найти и изменить в экспортах handle_call/2 на handle_call/3
    • Среди определений функций для handle_call/2 добавить параметр State и финальное выражение в каждой кляузе обрамить в {reply, ..., State}


    Кошка


    На ней мы будем тренироваться. Определена handle_call в нашем синтаксисе и ее аналог в каноническом виде для сравнения и написания трансформатора.
    -module(sl_gs_demo).
    
    -behavior(gen_server).
    -compile({parse_transform, sl_gs}).
    
    -export([handle_call/2, ref_handle_call/3]).
    
    -export([handle_cast/2, handle_info/2]).
    -export([init/1, terminate/2, code_change/3]).
    
    % This will be transformed
    handle_call(Req, From) ->
        {Req, From}.
    
    % That's what handle_call should finally look like
    ref_handle_call(Req, From, State) ->
        {reply, {Req, From}, State}.
    
    % Dummy functions to make gen_server happy
    % Exercise: Try to insert them automatically during transformations :)
    handle_cast(_, State) -> {noreply, State}.
    handle_info(_, State) -> {noreply, State}.
    init(_) -> {ok, none}.
    terminate(_, _) -> ok.
    code_change(_, State, _) -> {ok, State}.
    


    Код


    Все было написано как и в прошлый раз — глядя на вывод epp:parse_file и подгоняя то, что есть, под то, что надо.

    -module(sl_gs).
    -export([parse_transform/2]).
    
    parse_transform(Forms, _Options) ->
        lists:map(fun add_missing_state/1, Forms).
    
    
    add_missing_state({attribute, Line, export, Exports}) ->
        % export attribute. Replace {handle_call, 2} with {handle_call, 3}
        NewExports = lists:map(
            fun ({handle_call, 2}) -> {handle_call, 3}; 
                % You can add more clauses here for other function mutations
                (E) -> E 
            end, Exports),
        {attribute, Line, export, NewExports};
    
    add_missing_state({function, Line, handle_call, 2, Clauses}) ->
        % Mutate clauses
        NewClauses = lists:map(fun change_call_clause/1, Clauses),
        % Finally, change arity in header
        {function, Line, handle_call, 3, NewClauses};
    
    add_missing_state(Form) ->
        % Default
        Form.
    
    change_call_clause({clause, Line, Arguments, Guards, Body}) ->
        % Change arity in clauses. 
        NewArgs = Arguments ++ [{var, Line, 'State'}], % Add State argument
        % Then replace last statement of each clause with corresponding tuple
        NewBody = change_call_body(Body),
        {clause, Line, NewArgs, Guards, NewBody}.
    
    change_call_body([Statement | Rest=[_|_] ]) -> % Rest has to be non-empty list for this
        % Recurse to change only last statement
        [Statement|change_call_body(Rest)];
    
    change_call_body([LastStatement]) ->
        % Put it into tuple. Lines are zero to omit parsing LastStatement
        [{tuple,0, [{atom,0,reply},
                    LastStatement,
                    {var,0,'State'}]
            }].
    


    Проверка на работоспособность


    Eshell V5.8.5  (abort with ^G)
    1> c(sl_gs_demo).
    {ok,sl_gs_demo}
    2> {ok, D} = gen_server:start_link(sl_gs_demo, [], []).
    {ok,<0.39.0>}
    3> gen_server:call(D, hello).
    {hello,{<0.32.0>,#Ref<0.0.0.83>}}
    

    Успех! Осталось дорисовать сову и выложить на гитхаб.

    Итоги


    Заинтересованный читатель, надеюсь, познакомился с AST в эрланге, а так же получил примерное представление о методах его трансформации. Возможно, кто-то впервые узнал о parse_transform.
    В статье собрана информация, которой должно хватить, чтобы приступить к написанию собстенного трансформа. Чуть ниже будет критика и ссылка на полезную для трансформаций библиотеку.

    Критика метода


    Во-первых, использование parse_transform (в том случае, если он в отдельном проекте) добавляет зависимось вашему проекту. В случае с rebar это несмертельно.
    Во-вторых, люди, читающие (и, особенно, редактирующие) такой код, могут не сразу понять концепцию. Поэтому нужна не только хорошая документация, но и заметная ссылка на нее в начале исходника.
    В-третьих, возможности по написанию собственных диалектов сильно ограничены. Прежде, чем AST попадет под ваш скальпель, отрабатывает штатный парсер. Поэтому внесение хитрых ключевых слов и собственных операторов может сломать парсер, сильно усложнив задачу.

    Библиотека parse_trans


    parse_trans — полезная штука для написания parse_transform-ов. Она позволяет делать рекурсивный map на дерево, что крайне полезно при модификации выражений на непостоянной глубине. В примерах есть очень лаконичный способ переписывания оператора «!» на вызов gproc:send.
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 5
    • 0
      Спасибо, интересно и вроде понятно.

      В каких-нибудь известных проектах оно применяется и для чего?
      • 0
        Оно применяется как средство разработки благодаря возможности сильно сократить рутину (генерация кода, в частности).
        Например, exportie, например, позволяет указывать, какие функции экспортированы, прямо в их определениях, что может сказаться на читаемости кода.
        Есть erlando из проекта rabbitmq, предоставляющий удивительные способности по сокращению количества вводимых символов.

        Ну и вообще есть поиск по гитхабу с примерно 15-ю результатами по проектам.
        • 0
          Что-то я не до конца сначала воспринял вопрос.
          Поиск говорит, что parse_transform используется в самом OTP, RabbitMQ, Reia, Neotoma (парсер-генератор, так что косвенно – в rebar, а из-за него – почти везде).
          • 0
            qlc
          • 0
            А я правильно понимаю, что если ошибка происходит в самом парсере эрланга, то дальше ее исправить при помощи parse_transform уже никак нельзя?

            То есть возможности по радикальному изменению синтаксиса очень ограничены?

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