__using__ macro with option

Hi community!

What is the correct way to implement the __using__ macro with an option that changes the way generated functions behave? Or is that a no-go by itself?

An example:

defmodule UsedModule do
  defmacro __using__(opts) do
    quote do
      if Keyword.get(unquote(opts), :with_extra, false) do
        def extra(), do: "with extra"
      else
        def extra(), do: "no extra"
      end
    end
  end
end
defmodule UsingModuleWithExtra, do: use UsedModule, with_extra: true
defmodule UsingModuleNoExtra, do: use UsedModule
iex> UsingModuleWithExtra.extra()
"with extra"

iex> UsingModuleNoExtra.extra()
"no extra"

My question: Is this the way to go inside the __using__ macro? Or would you define the extra function once and do the if inside like this?

def extra() do
  if Keyword.get(unquote(opts), :with_extra, false), 
    do: "with extra", 
    else: "no extra"
end

Or is there a cleaner way I’m not seeing?

Thanks!

In case you have a static output like in this example then you can simply call unquote(string) and outside quote block set string variable based on option.

defmodule UsedModule do
  defmacro __using__(opts) do
    string = if opts[:with_extra], do: "with extra", else: "no extra"
    quote do
      def extra(), do: unquote(string)
    end
  end
end

In other case I would recommend to simply move if condition outside quote block and simply use 2 quote calls.

defmodule UsedModule do
  defmacro __using__(opts) do
    if opts[:with_extra] do
      quote do
        def extra(), do: "with extra"
      end
    else
      quote do
        def extra(), do: "no extra"
      end
    end
  end
end

Note: If opts[:with_extra] does not exists then you got nil which is falsy value and therefore we do not need a Keyword.get/3 call here. :smiling_imp:

2 Likes

Thanks for your answer. I mean to remember heaving read @josevalim mentioning somewhere that there should be no code (or as little as possible?) outside the quote block. But it might be applicable only in that context which obviously I don’t remember. :roll_eyes:

Anyway this helps, thanks. Also for the note! :smiley:

Yeah, I can say that it may depend on case. For example there is a big difference working on raw data passed to macro and variables passed to macro even if their declaration was static one line above …

some_macro("some value")
# param value in macro: "some value"

value = "some value"
some_macro(value)
# param value in macro: {:value, […], nil}

unquote inside quote block solves the problem. However in some cases you may expect some value to be passed raw instead.

some_value = true

quote do
  if unquote(some_value) do
    :ok
  else
    :error
  end
end

Here since the value is known “outside” the half of code we generate would never be used.

A simple example shows how important it may be:

defmodule MyMacro do
  defmacro __using__(opts) do
    Enum.map(opts[:enabled_features] || [], &apply_feature/1)
  end

  defp apply_feature(:feature_name) do
    quote do
      # …
    end
  end

  # …
end

Look that if we would have only 1/10 features enabled then we would generate only one quote block.

However if we would write it like this:

defmodule MyMacro do
  @all_features [:feature_name, # …]

  defmacro __using__(opts) do
    quote do
      import MyMacro, only: [apply_feature: 1]

      for feature <- unquote(@all_features), feature in unquote(opts[:features] || []) do
        apply_feature(feature)
      end
    end
  end

  defmacro apply_feature(:feature_name) do
    quote do
      # …
    end
  end

  # …
end

then said module with one enabled feature would have generated extra loop with 9/10 skipped elements. Also in example above apply_feature/1 is a public macro for absolutely no reason (comparing to previous example).

Also it would be nice to provide a link for mentioned text as José could talk about other case or just more complex example. It would be good for others to see all sides of this coin. :slight_smile:

1 Like

If I ever find that text again, I will definitely add a link to it here.

But your example with the 10 features only works because a quoted list of atoms is still a list of atoms, right? If for some stupid reason I decide to pass enabled_features: [String.to_atom("my_frature")], it would not work?

If it’s stupid, but it works then it’s no stupid! :smiling_imp:

I have used atom as it’s best for what I called feature_name as those in apply_feature/1 are known at compile-time. You can use strings, integers and every other data that you can write pattern-matching for.

Also take a look at example below:

some_macro(["a", "b", "c"])
# param value in macro
# ["a", "b", "c"]

some_macro(~w[a b c])
# param value in macro
# {:sigil_w, [delimiter: "[", context: Elixir, imports: [{2, Kernel}]],
#  [{:<<>>, [], ["a b c"]}, []]}

some_macro(~w[a b c])
# value of `Macro.expand(param, __CALLER__)`
# ["a b c"]

If you want to call String.to_atom/1 inside macro then:

  1. You can call Enum.map(list, &String.to_atom/1) as long as you require list of atoms passed as raw list or sigil (see above example)
  2. In function definition String.to_atom/1 call needs to be passed inside unquote/1 call.
  3. Inside module and function block you can use String.to_atom/1 call as well inside unquote/1 call as outside unquote/1 call.

Hope that helps.

1 Like

Not maps. Or structs. You can’t expand those to their original value either. You’d have to call Code.eval_quoted() which is discouraged inside macros, no?

I’d like come back to my original question. I have changed the example to take a map. It does not make much sense like this and could be done better, but it is a simple example to show the case with a map as option value:

defmodule UsedModule do
  defmacro __using__(opts) do
    quote do
      if unquote(opts[:with_extra]) do
        def extra(), do: "with extra"
      else
        def extra(), do: "no extra"
      end
    end
  end
end
defmodule UsingModuleWithExtra, do: use UsedModule, config: %{with_extra: true}
defmodule UsingModuleNoExtra, do: use UsedModule
iex> UsingModuleWithExtra.extra()
"with extra"

iex> UsingModuleNoExtra.extra()
"no extra"

See the passed option is expected to be a map. Which cannot be expanded to its original value. You have to unquote them inside a quote block (right?). So my question was, if you would - in this case where the argument requires unquoting - implement the macro like I did above or rather put the if condition inside the generated function like this:

def extra() do
  if Keyword.get(opts, :config, %{})[:with_extra], 
    do: "with extra", 
    else: "no extra"
end

You can’t expand them, because unlike sigil they are special forms. However if you still expect to pass raw map then you can solve that in few ways depends on use case …

defmodule UsedModule do
  defmacro __using__(opts) do
    if get_config!(opts, __CALLER__).with_extra do
      quote do
        def extra(), do: "with extra"
      end
    else
      quote do
        def extra(), do: "no extra"
      end
    end
  end

  defp get_config!(opts, env) do
    case opts[:config] do
      nil ->
        raise ":config option not passed"

      {:%, _, [{:__aliases__, _, [:PatternMatchingConfig]}, {:%{}, _, config}]} ->
        struct(PatternMatchingConfig, config)

      {:%, _, [aliases, {:%{}, _, config}]} ->
        aliases |> Macro.expand_literal(env) |> struct(config)

      {:%{}, _, config} ->
        Map.new(config)

      _config ->
        raise "unexpected config value. Did you passed a raw map?"
    end
  end
end

defmodule ExpandedConfig do
  defstruct with_extra: false
end

defmodule PatternMatchingConfig do
  defstruct with_extra: false
end

defmodule Examples do
  defmodule ExpandedStruct do
    use UsedModule, config: %ExpandedConfig{}
  end

  defmodule PatternMatchingMap do
    use UsedModule, config: %{with_extra: true}
  end

  defmodule PatternMatchingStruct do
    use UsedModule, config: %PatternMatchingConfig{}
  end
end

If you do not want to mix your code with handling special forms then all you need to do is write something like:

defmodule Example do
  def sample(ast, env) do
    if Macro.quoted_literal?(ast) do
      Macro.postwalk(ast, fn
        {:__aliases__, _, _} = alias -> Macro.expand(alias, env)
        {:%{}, [], opts} -> Map.new(opts)
        {:%, [], [module, opts]} -> struct(module, opts)
        other -> other
      end)
    else
      ast
    end
  end
end

iex> map_ast = quote do: %{day: 1, month: 1, year: 2022}
{:%{}, [], [day: 1, month: 1, year: 2022]}

iex> Example.sample(map_ast, __ENV__)
%{day: 1, month: 1, year: 2022}

iex> struct_ast = quote do: %Date{day: 1, month: 1, year: 2022}
{:%, [],
 [
   {:__aliases__, [alias: false], [:Date]},
   {:%{}, [], [day: 1, month: 1, year: 2022]}
 ]}

iex> Example.sample(struct_ast, __ENV__)
~D[2022-01-01]