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

в 12:01, , рубрики: AST, erlang, Erlang/OTP, метки: ,

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.

Автор: stolen

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js