Help understanding compile-time dependencies when referencing aliases in module attributes

I’m refactoring some parts of a codebase that do some compile-time magic, in order to reduce the number of compile-time dependencies between modules. One thing I don’t fully get is when nominal references to other modules/aliases in module attributes create a compile-time dependency.

I have a minimal example here (compiling with elixir 1.15.7-otp-26)

If I have these two modules:

defmodule Trace.Submodules.Submodule do
end

and

defmodule Trace do
  alias Trace.Submodules

  @attribute Submodules.Submodule
  @attribute Submodules.DoesNotExist
  @attribute [Submodules.Submodule]
  @attribute [Submodules.DoesNotExist]
  @attribute to_string(Submodules.Submodule)
  @attribute to_string(Submodules.DoesNotExist)
  @attribute to_string(Submodules.Submodule) <> to_string(Submodules.DoesNotExist)
  @attribute (& &1).(Submodules.Submodule)

  def attribute, do: @attribute
end

When I run mix xref trace lib/trace.ex --label compile it shows Trace three places where there are compile-time dependencies on Trace.Submodules.Submodule → precisely where there are function calls wrapping Subodules.Submodule, but not a direct reference or just wrapping in a list.

lib/trace.ex:8: alias Trace.Submodules.Submodule (compile)
lib/trace.ex:10: alias Trace.Submodules.Submodule (compile)
lib/trace.ex:11: alias Trace.Submodules.Submodule (compile)

It’s weirder because I do the exact same operations for Submodules.DoesNotExist, which doesn’t exist, and there’s no problem. Is it the case the compiler assumes the alias can be used as a module by the function at compile-time, so it places a compile-time dependency on it?

Asking bc the reason behing the behaviour impacts how I’ll approach refactoring. Any help appreciated!

Unfortunately, I can’t help you, but I am running into the exact same issue.
I have noticed that setting a module attribute with @ behaves differently when it comes to compile-time dependencies than using Macro.expand_literals and Module.put_attribute/3. The latter will add a compile-time dependency, while the former does not.

Imagine the following silly code:

defmodule HavingFun.A do
  alias HavingFun.B
  import HavingFun.C
  set_impl B
  def run(), do: @impl_module2.my_magic_function(42)
end

defmodule HavingFun.C do
  defmacro set_impl(impl) do
    quote do
      @impl_module2 unquote(impl)
    end
  end
end

Doing mix xref trace lib/having_fun/a.ex yields the following:

lib/having_fun/a.ex:4: require HavingFun.C (export)
lib/having_fun/a.ex:6: import HavingFun.C.set_impl/1 (compile)
lib/having_fun/a.ex:8: call HavingFun.B.my_magic_function/1 (runtime)

Which makes perfect sense to me. However, if the macro implementation is changed slightly, we end up having a compile-time dependency on B:

defmodule HavingFun.C do
  defmacro set_impl(impl) do
    impl = Macro.expand_literals(impl, __CALLER__)
    Module.put_attribute(__CALLER__.module, :impl_module2, impl)

    []
  end
end
lib/having_fun/a.ex:4: require HavingFun.C (export)
lib/having_fun/a.ex:6: alias HavingFun.B (compile)
lib/having_fun/a.ex:6: import HavingFun.C.set_impl/1 (compile)
lib/having_fun/a.ex:8: call HavingFun.B.my_magic_function/1 (runtime)

Line 6 is where set_impl is invoked. It seems that expand_literals adds a compile-time dependency, which doesn’t really makes any sense to me, as we are only referencing the module, and nothing specific in it.

So maybe there is a special case somewhere, that ensures that no compile time dependency is added when using @attribute <MODULE> ?

I don’t have the most in depth mental model, but I did spend the better part of a week eliminating a bunch of compile-time dependencies from a mediumish codebase at my old job so learned a thing or two.

To me, it makes sense that the functional call one would cause a dependency. This is because you are calling a function in a module body which means the function will be run at compile time. Because you’re passing an atom as an argument, my somewhat informed assumption is that the compiler now needs to check if that atom references a module or not and compile it if it is. This is because the function receiving it, which again is being called at compile time, might try and call one of its functions.

I think this behaviour of bare modules references inside a module body not causing a compile time dependency might be a more recent improvement. I believe this was a major cause of the problems in the codebase I worked on, but I can’t quite remember as it’s coming up on two years ago that I worked on it. I could be wrong on either count! I do remember some chatter on the forum about this in the past year, specifically around modules in guards (which is essentially the module body) but again, memory is fuzzy.

Sorry if I wasn’t more helpful. I saw this when you first posted it and just responding now since you got no bites.

And sorry I don’t have any insights for you @xpg—those macros are a little too mind-bending for me this morning (even if they are probably simple enough, lol).

That explanation makes sense to me. Good analysis!

Applying your way of thinking (and a bit looking and xref source code) also turned out to explain and solve my situation: Macro.expand_literals/2 will indeed add compile-time dependencies if the provided environment (__CALLER__ in my case) represents the body of a module. But it will only add a runtime dependency, if the environment represents the body of a function.
The documentation of Macro.expand_literals/2 hints that you can just modify the environment passed in, such that the code “thinks” you are in a function body. Unfortunately, I didn’t quite understand the hint for quite some time :slight_smile:

2 Likes

Thanks for the discussion.

In the end, I decided that in my case it would be better to replace direct module references with names (say, :integer instead of MyApp.Type.Integer) and have a registration layer where the association between names and modules is resolved. Besides avoiding compile-time dependencies, it also adds some flexibility as it allows switching implementations in runtime/tests.

Of course having more tricks available is helpful depending on the case!