[Proposal] New module attribute managed by Elixir: `@__using__`

100 views
Skip to first unread message

Adi

unread,
Mar 8, 2021, 12:56:10 PM3/8/21
to elixir-lang-core

Hi Elixir Team,

Thanks for all your awesome work!

At the moment, there is no way that I know of, to inspect a module and get information regarding other modules that it uses. It would add more transparency to a module if we could add a persistent module attribute `@__using__` which stores that information. The module attribute would store information about what modules are being used, along with the options being passed to their `__using__/1` macros.

This can be done by updating `Kernel.use/2` macro to do something like this:

```ex
  defmacro use(module, opts \\ []) do
    calls =
      Enum.map(expand_aliases(module, __CALLER__), fn
        expanded when is_atom(expanded) ->
          quote do
            require unquote(expanded)
            unquote(expanded).__using__(unquote(opts))
 
            # new code begins here #
            Module.register_attribute(__MODULE__, :__using__, accumulate: true, persist: true)
            Module.put_attribute(__MODULE__, :__using__, {unquote(expanded), unquote(opts)})
            # new code ends here #
          end

        _otherwise ->
          raise ArgumentError,
                "invalid arguments for use, " <>
                  "expected a compile time atom or alias, got: #{Macro.to_string(module)}"
      end)

    quote(do: (unquote_splicing(calls)))
  end
```

Would love to get more thoughts on this!

Best,
Adi

José Valim

unread,
Mar 8, 2021, 1:03:00 PM3/8/21
to elixir-l...@googlegroups.com
Hi Adi, thanks for the proposal!

Can you please expand on why this information is useful?

Also, if it helps, all used modules are required, so __ENV__.requires gives you a close enough list. :)

--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/elixir-lang-core/5cca271b-5d5b-4174-b317-909240bb0db6n%40googlegroups.com.

Adi

unread,
Mar 8, 2021, 1:50:19 PM3/8/21
to elixir-lang-core
Hi Jose,

Thanks for the quick response!

Having the `require` information in `__ENV__` is definitely useful, but my concerns stem from a module not having the knowledge of what it is expected to do.

For example, let's say we want to test a module A that uses another module B. In order to test A's usage of B, we HAVE to test `B.__using__/1` functionality as well. Let's say modules C, D, E use B as well, we will have to do the same. And, if in the future B's behavior changes, we will have to update the tests for A, C, D and E as well. Having access to the `@__using__` attribute will provide a way to test modules A, C, D and E without having to test `B.__using__/1`, which should be tested independently. I think by adding another abstraction in between module A and B that allows us to know how A uses B is a better design.

Let me know what you think of this!

José Valim

unread,
Mar 8, 2021, 1:55:15 PM3/8/21
to elixir-l...@googlegroups.com
Hi Adi, I am sorry but I could not understand the use case from your description. Can you please provide a more concrete example? Thanks!

Wojtek Mach

unread,
Mar 8, 2021, 2:19:00 PM3/8/21
to elixir-l...@googlegroups.com
I have trouble understanding the use case too but fwiw compilation tracers to the rescue! :D

defmodule MyTracer do
  def trace({:remote_macro, _, M, :__using__, 1}, env) do
    IO.puts "#{inspect(env.module)} called M.__using__/1"
  end

  def trace(_other, _env) do
    :ok
  end
end

Code.put_compiler_option(:tracers, [MyTracer])

Code.compile_string("""
defmodule M do
  defmacro __using__(_) do
    :ok
  end
end

defmodule Foo do
  use M
end

defmodule Bar do
  use M
end
""")

# Outputs:
# Foo called M.__using__/1
# Bar called M.__using__/1

Adi

unread,
Mar 8, 2021, 3:53:25 PM3/8/21
to elixir-lang-core
Hi Jose,

Sorry if I'm not doing a good job explaining it. I'll share some code below.

Hi Wojtek, Great idea using compile-time tracers! Unfortunately, I don't see them capturing information related to the arguments being passed to the macro. If there's a way to do that, it would satisfy my use case.

Either way, I have shared the code snippet below. Let me know what both of your thoughts are.

Best,
Adi

CODE SNIPPET:

```ex
defmodule Behavior do
  defmacro __using__(options) do
    fun = Keyword.get(options, :fun, :fun)

    quote do
      def __some_function__, do: :something
      def __some_other_function__, do: :something
      def unquote(fun)(), do: :fun
    end
  end
end

defmodule TestSubject1 do
  use Behavior, fun: :fun1
end

defmodule TestSubject2 do
  use Behavior, fun: :fun2
end

defmodule TestSubject3 do
  use Behavior
end

defmodule TestSubject4 do
  use Behavior, fun: :fun4
end

## Tests
ExUnit.start()

# This module should sufficiently test Behavior module's functions and use cases
defmodule BehaviorTest do
  use ExUnit.Case

  defmodule TestWithFun do
    use Behavior, fun: :test
  end

  defmodule TestWithoutFun do
    use Behavior
  end

  describe "__using__/1" do
    test "defines static functions along with `fun/0 `is no fun provided" do
      assert {:__some_function__, 0} in TestWithoutFun.__info__(:functions)
      assert {:__some_other_function__, 0} in TestWithoutFun.__info__(:functions)
      assert {:fun, 0} in TestWithoutFun.__info__(:functions)
    end

    test "defines static functions along with `test/0 ` when fun is :test" do
      assert {:__some_function__, 0} in TestWithFun.__info__(:functions)
      assert {:__some_other_function__, 0} in TestWithFun.__info__(:functions)
      assert {:test, 0} in TestWithFun.__info__(:functions)
    end
  end
end

defmodule TestSubject1Test do
  use ExUnit.Case

  # This test is re-testing `Behavior.__using__/1` instead of simply testing
  # its usage.
  describe "uses Behavior" do
    test "defines static functions along with `fun1/0`" do
      assert {:__some_function__, 0} in TestSubject1.__info__(:functions)
      assert {:__some_other_function__, 0} in TestSubject1.__info__(:functions)
      assert {:fun1, 0} in TestSubject1.__info__(:functions)
    end
  end

  # `@__using__` attribute will allow us to do something like this, instead
  # of testing `Behavior.__using__/1` again.
  # describe "use Behavior" do
  #   test "uses Behavior with fun option" do
  #     uses = TestSubject1.__info__(:attributes)[:__using__]
  #
  #     assert {Behavior, [fun: :fun1]} in uses
  #   end
  # end
end

## --- Similar tests for TestSubject2, TestSubject3 and TestSubject4
```


José Valim

unread,
Mar 8, 2021, 3:58:11 PM3/8/21
to elixir-l...@googlegroups.com
Hi Adi,

I don’t think this is a good idea. All of your tests are testing that it defined a function or uses something but it really doesn’t test that any of that *behaves* as you would want.

Since we don’t want to promote such tests, it is not something we see in the language.

Thank you, 

--
You received this message because you are subscribed to the Google Groups "elixir-lang-core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to elixir-lang-co...@googlegroups.com.

Adi

unread,
Mar 8, 2021, 4:30:49 PM3/8/21
to elixir-lang-core
Hi Jose,

Good point! I actually didn't pay much attention to the behavior testing part because the code that was pointing out the use case was the one that was commented out. So, here goes another try. What do you think about the following code?

By the way, I really appreciate you taking out the time to look at the code. I know you have a packed schedule. :)
# This test should sufficiently test Behavior module's functions and use cases

defmodule BehaviorTest do
  use ExUnit.Case

  defmodule TestWithFun do
    use Behavior, fun: :test
  end

  defmodule TestWithoutFun do
    use Behavior
  end

  describe "__using__/1" do
    test "defines static functions along with `fun/0 `is no fun provided" do
      assert TestWithoutFun.__some_function__() == :something
      assert TestWithoutFun.__some_other_function__() == :something
      assert TestWithoutFun.fun() == :fun

    end

    test "defines static functions along with `test/0 ` when fun is :test" do
      assert TestWithFun.__some_function__() == :something
      assert TestWithFun.__some_other_function__() == :something
      assert TestWithFun.test() == :fun
    end
  end
end

defmodule TestSubject1Test do
  use ExUnit.Case

  # This test is re-testing `Behavior.__using__/1` instead of just testing
  # its usage

  describe "uses Behavior" do
    test "defines static functions along with `fun1/0`" do
      assert TestSubject1.__some_function__() == :something
      assert TestSubject1.__some_other_function__() == :something
      assert TestSubject1.fun1() == :fun

    end
  end

  # `@__using__` attribute will allow us to do something like this, instead
  # of testing `Behavior.__using__/1` again.
  # describe "use Behavior" do
  #   test "uses Behavior with fun option" do
  #     uses = TestSubject1.__info__(:attributes)[:__using__]
  #
  #     assert {Behavior, [fun: :fun1]} in uses
  #   end
  # end
end

## --- More tests for TestSubject2, TestSubject3 and TestSubject4
```

Now that we have tested how `Behavior.__using__/1` behaves (by calling the actual functions), testing whether `TestSubject1` has correct attributes set should be sufficient, right?
Reply all
Reply to author
Forward
0 new messages