Search code examples
elixirmetaprogramminghygiene

Elixir var! ... How to read a variable from caller's scope


Sorry if this has already been asked. Searching the forum for var! gives me all the posts with word var. Made it hard to narrow it down.

Struggling with writing a macro that reads a variable out of the caller's context and returns it from a function. Here's the simplest form of the problem I could think of:

defmodule MyUnhygienicMacros do

  defmacro create_get_function do
    quote do
      def get_my_var do
        var!(my_var)
      end
    end
  end

end

defmodule Caller do

  require MyUnhygienicMacros

  my_var = "happy"

  MyUnhygienicMacros.create_get_function()

end

The goal would be to see this when I run an iex session:

$ Caller.get_my_var()
"happy"

But this does not compile. The caller's my_var goes unused too.

The CompileError expected "my_var" to expand to an existing variable or be part of a match.

I've read McCord's metaprogramming book, this blog post (https://www.theerlangelist.com/article/macros_6) and many others. Seems like it should work, but I just can't figure out why it won't..


Solution

  • Kernel.var!/2 macro does not do what you think it does.

    The sole purpose of var!/2 is to mark the variable off the macro hygiene. That means, using var!/2 one might change the value of the variable in the outer (in regard to the current context) scope. In your example, there are two scopes (defmacro[create_get_function] and def[get_my_var]) to bypass, which is why my_var does not get through.

    The whole issue looks like an XY-Problem. It looks like you want to declare kinda compile-time variable and modify it all way through the module code. For this purpose we have module attributes with accumulate: true.

    If you want to simply use this variable in create_get_function/0, just unquote/1 it. If you want to accumulate the value, use module attributes. If you still ultimately want to keep it your way, passing the local compile-time variable through, break hygiene twice, for both scopes.

    defmodule MyUnhygienicMacros do
      defmacro create_get_function do
        quote do
          my_var_inner = var!(my_var)
          def get_my_var, do: var!(my_var_inner) = 42
          var!(my_var) = my_var_inner
        end
      end
    end
    
    defmodule Caller do
      require MyUnhygienicMacros
      my_var = "happy"
      MyUnhygienicMacros.create_get_function()
      IO.inspect(my_var, label: "modified?")
    end
    

    Please note, that unlike you might have expected, the code above still prints modified?: "happy" during compile-time. This happens because var!(my_var_inner) = 42 call would be held until runtime, and bypassing macro hygiene here would be a no-op.