Compile-time dependencies for @behaviour

Upon updating a project from Elixir 1.10.4/OTP 23 to Elixir 1.13.1/OTP 23, I stumbled across some warnings regarding undefined behaviour modules.

Long-story short, it seems that the module A, which defines the callbacks, is being compiled after module A.Domain, which has the @behaviour A statement.

Is this the expected behavior, or should @behaviour module attributes for a compile-time dependency?

3 Likes

Do you have a sample project that reproduces it?
I would like to look into it.

1 Like

I tried to isolate the problem, but I couldn’t reproduce it on another project.
Furthermore, compiling on a separate machine worked properly.

I’ll keep this post open a while longer just to see if I can discover what causes the problem on that specific machine, and I’ll get back here with more updates.

1 Like

I recently ran into something like this on a codebase (granted, not the exact same warning you are seeing) and the culprit was circular dependencies. Is there any chance you’ve hardcoded the modules that implement the behaviour in the behaviour module itself? I could see different file systems handling the order of compilation differently (though that is a guess—I’m unfamiliar with these types of details in practice).

eg:

defmodule FooThing do
  @callback foo_the_thing :: nil

  def foo_things, do: [SomeFooThing, ...]
end

defmodule SomeFooThing do
  @behaviour FooThing

  @impl FooThing
  def foo_the_thing, do: nil
end
2 Likes

It was exactly that. The reason I wasn’t able to reproduce was twofold:

  1. I forgot we had added a require A right before each @behaviour A statement to force the compile-time dep
  2. My colleague had replaced all %Struct{} calls in typespecs with Struct.t() after I had looked into the repo.

So there isn’t a direct cyclic dependency happening, but one of the struct modules must somehow depend on the module which has @behaviour A. Replacing the typespecs breaks the cycle :slight_smile:

Thank you both for the insights!

4 Likes

Awesome, but I didn’t do anything :laughing:
All the kudos go to @sodapopcan

1 Like

Oh, I also forgot to reply to this specifically:

Is there any chance you’ve hardcoded the modules that implement the behaviour in the behaviour module itself? I could see different file systems handling the order of compilation differently (though that is a guess—I’m unfamiliar with these types of details in practice).

Hardcoded modules by themselves don’t introduce compile-time dependencies. This can be checked with the following snippet:

iex(1)> defmodule T do
...(1)> 
...(1)> def f, do: F.g()
...(1)> end
{:module, T,
 <<70, 79, 82, 49, 0, 0, 4, 196, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 140,
   0, 0, 0, 16, 8, 69, 108, 105, 120, 105, 114, 46, 84, 8, 95, 95, 105, 110,
   102, 111, 95, 95, 10, 97, 116, 116, 114, ...>>, {:f, 0}}
iex(2)> T.f()
** (UndefinedFunctionError) function F.g/0 is undefined (module F is not available)
    F.g()

For anyone that might be interested, here’s what I know about compile-time deps:

Compile-time dependencies are almost always added by calling the hardcoded %struct{} form, by using import or require, which are required for using macros. Note that use Module, opts is just syntactical sugar for require Module; Module.__using__(opts), so this also introduces compile-time dependency.

Also, from what I learned today, @behaviour X also introduces a compile-time dependency. And, finally, @external_resource filename can be used to introduce a compile-time dependency to a raw file (e.g. a .md file you use to render the docstring).

What confused me most is that I’m almost sure we had replaced the %Struct{} calls with Struct.t() before… We surely missed the one that was the culprit haha

1 Like

Right, I was certainly not suggesting that hardcoding modules themselves causes a compile dependency (that would render much of how we write Elixir useless) but that having A reference B (in any way—function call, alias, import, require, etc) and B reference A is BadNews. This is, in general, as high up on the list as it gets as far as code smells go.

To extend on your learnings, referencing any module in another module’s body (ie, outside of a function or alias) will create a compile-time dependency. This is why @behaviour X creates one, but actually any module attribute definition containing a module will create one, eg: @foo X or @foo %{x: X}. A module referenced in a guard will create one too! def foo(x) when x in X.foos, do: nil.

Otherwise, so long as you have nice uni-directional dependencies, referencing modules inside other modules’ functions (or alias) is going to be a runtime depedency.

As for imports that don’t require marcos, I’m pretty sure that is runtime, but I haven’t proven that to myself yet. Can anyone confirm?

Anyway, I’m super happy I could be the catalyst to you figuring out your problem! I was pulling my hair out for a few days figuring out my problem—I’m new to these compilation rules myself.

1 Like