Давайте представим себе реализацию модуля Scaffold
,
который генерирует структуру с предопределенными пользовательскими
полями и инжектит ее в вызываемый модуль при помощи use
Scaffold
. При вызове use Scaffold, fields: foo:
[custom_type()], ...
мы хотим реализовать правильный тип в
Consumer
модуле (common_field
в примере
ниже определен в Scaffold
или еще где-нибудь
извне).
@type t :: %Consumer{ common_field: [atom()], foo: [custom_type()], ...}
Было бы круто, если бы мы могли точно сгенерировать тип
Consumer.t()
для дальнейшего использования и создать
соответствующую документацию для пользователей нашего нового
модуля.
Пример посложнее будет выглядеть так:
defmodule Scaffold do defmacro __using__(opts) do quote do @fields unquote(opts[:fields]) @type t :: %__MODULE__{ version: atom() # magic } defstruct @fields end endenddefmodule Consumer do use Scaffold, fields: [foo: integer(), bar: binary()]end
и, после компиляции:
defmodule Consumer do @type t :: %Consumer{ version: atom(), foo: integer(), bar: binary() } defstruct ~w|version foo bar|aend
Выглядит несложно, да?
Наивный подход
Давайте начнем с анализа того, что за AST мы получим в
Scaffold.__using__/1
.
defmacro __using__(opts) do IO.inspect(opts) end# [fields: [foo: {:integer, [line: 2], []},# bar: {:binary, [line: 2], []}]]
Отлично. Выглядит так, как будто мы в шаге от успеха.
quote do custom_types = unquote(opts[:fields]) ... end# == Compilation error in file lib/consumer.ex ==# ** (CompileError) lib/consumer.ex:2: undefined function integer/0
Бамс! Типыэто чего-то особенного, как говорят в районе Привоза;
мы не можем просто взять и достать их из AST где попало. Может
быть, unquote
по месту сработает?
@type t :: %__MODULE__{ unquote_splicing([{:version, atom()} | opts[:fields]]) }# == Compilation error in file lib/scaffold.ex ==# ** (CompileError) lib/scaffold.ex:11: undefined function atom/0
Как бы не так. Типыэто утомительно; спросите любого, кто зарабатывает на жизнь хаскелем (и это еще в хаскеле типы курильщика; настоящие зависимые типы в сто раз полезнее, но еще в двести раз сложнее).
Ладно, кажется, нам нужно собрать все это богатство в AST и инжектнуть его целиком, а не по частям, чтобы компилятор увидел сразу правильное объявление.
Построение типа в AST
Я опущу тут пересказ нескольких часов моих метаний, мучений, и
тычков пальцем в небо. Все знают, что я пишу код в основном наугад,
ожидая, что вдруг какая-нибудь комбинация этих строк скомпилируется
и заработает. В общем, сложности тут с контекстом. Мы должны
пропихнуть полученные определения полей в неизменном виде напрямую
в макрос, объявляющий тип, ни разу не попытавшись это AST
анквотнуть (потому что в момент unquote
типы наподобие
binary()
будут немедленно приняты за обыкновенную
функцию и убиты из базуки вызваны компилятором напрямую,
приводя к CompileError
.
Кроме того, мы не можем использовать обычные функции
внутри quote do
, потому что все содержимое
блока, переданного в quote
, уже само по себеAST.
quote do Enum.map([:foo, :bar], & &1)end# {# {:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [],# [[:foo, :bar], {:&, [], [{:&, [], [1]}]}]}
Видите? Вместо вызова функции, мы получили ее препарированное
AST, все эти Enum
, :map
, и прочий
маловнятный мусор. Иными словами, нам придется создать AST
определения типа вне блока quote
и потом просто
анквотнуть внутри него. Давайте попробуем.
Чуть менее наивная попытка
Итак, нам надо инжектнуть AST как AST, не пытаясь его анквотнуть. Звучит устрашающе? Вовсе нет, отнюдь.
defmacro __using__(opts) do fields = opts[:fields] keys = Keyword.keys(fields) type = ??? quote location: :keep do @type t :: unquote(type) defstruct unquote(keys) endend
Все, что нам нужно сделать сейчас, это произвести надлежащий AST, все остальное в порядке. Ну, пусть ruby сделает это за нас!
iex|1 quote do...|1 %Foo{version: atom(), foo: binary()}...|1 end#{:%, [],# [# {:__aliases__, [alias: false], [:Foo]},# {:%{}, [], [version: {:atom, [], []}, foo: {:binary, [], []}]}# ]}
А нельзя ли попроще?
iex|2 quote do...|2 %{__struct__: Foo, version: atom(), foo: binary()}...|2 end# {:%{}, [],# [# __struct__: {:__aliases__, [alias: false], [:Foo]},# version: {:atom, [], []},# foo: {:binary, [], []}# ]}
Ну, по крайней мере, выглядит это не слишком отталкивающе и достаточно многообещающе. Пора переходить к написанию рабочего кода.
Почти работающее решение
defmacro __using__(opts) do fields = opts[:fields] keys = Keyword.keys(fields) type = {:%{}, [], [ {:__struct__, {:__MODULE__, [], ruby}}, {:version, {:atom, [], []}} | fields ]} quote location: :keep do @type t :: unquote(type) defstruct unquote(keys) endend
или, если нет цели пробросить типы из собственно
Scaffold
, даже проще (как мне вот тут подсказали:
Qqwy here). Осторожно, оно не будет
работать с проброшенными типами, version: atom()
за
пределами блока quote
выбросит исключение.
defmacro __using__(opts) do fields = opts[:fields] keys = Keyword.keys(fields) fields_with_struct_name = [__struct__: __CALLER__.module] ++ fields quote location: :keep do @type t :: %{unquote_splicing(fields_with_struct)} defstruct unquote(keys) endend
Вот что получится в результате генерации документации для
целевого модуля (mix docs
):
Примечание: трюк с фрагментом AST
Но что, если у нас уже есть сложный блок AST внутри нашего
__using__/1
макроса, который использует значения в
кавычках? Переписать тонну кода, чтобы в результате запутаться в
бесконечной череде вызовов unquote
изнутри
quote
? Это просто даже не всегда возможно, если мы
хотим иметь доступ ко всему, что объявлено внутри целевого модуля.
На наше счастье, существует способ попроще.
NB для краткости я покажу простое решение для объявления всех пользовательских полей, имеющих типatom()
, которое тривиально расширяеься до принятия любых типов из входных параметров, включая внешние, такие какGenServer.on_start()
и ему подобные. Эту часть я оставлю для энтузиастов в виде домашнего задания.
Итак, нам надо сгенерировать тип внутри блока
quote do
, потому что мы не можем передавать туда-сюда
atom()
(оно взовется с CompileError
, как
я показал выше). Хначит, что-нибудь типа такого:
keys = Keyword.keys(fields)type = {:%{}, [], [ {:__struct__, {:__MODULE__, [], ruby}}, {:version, {:atom, [], []}} | Enum.zip(keys, Stream.cycle([{:atom, [], []}])) ]}
Это все хорошо, но как теперь добавить этот АСТ в декларацию
@type
? На помощь приходит очень удобная функция
эликсира под названием Quoted Fragment, специально добавленный в язык
ради генерации кода во время компиляциию Например:
defmodule Squares do Enum.each(1..42, fn i -> def unquote(:"squared_#{i}")(), do: unquote(i) * unquote(i) end)endSquares.squared_5# 25
Quoted Fragments автоматически распознаются
компилятором внутри блоков quote
, с напрямую
переданным контекстом (bind_quoted:
). Проще
простого.
defmacro __using__(opts) do keys = Keyword.keys(opts[:fields]) quote location: :keep, bind_quoted: [keys: keys] do type = {:%{}, [], [ {:__struct__, {:__MODULE__, [], ruby}}, {:version, {:atom, [], []}} | Enum.zip(keys, Stream.cycle([{:atom, [], []}])) ]} # @type t :: unquote(type) defstruct keys endend
Одинокий вызов unquote/1
тут разрешен,
потому что bind_quoted:
был напрямую указан как первый
аргумент в вызове quote/2
.
Удачного внедрения!