elixirdialyzer

How to spec a callback with variable number of arguments in Elixir


I have a behaviour that wraps any function.

defmodule MyBehaviour do
  @callback do_run( ? ) :: ? #the ? means I don't know what goes here
  defmacro __using__(_) do
    quote location: :keep do
      @behaviour MyBehaviour
      def run, do: MyBehaviour.run(__MODULE__, [])
      def run(do_run_args), do: MyBehaviour.run(__MODULE__, do_run_args)
    end
  end

  def run(module, do_run_args) do
    do_run_fn = fn ->
      apply(module, :do_run, do_run_args)
    end

    # execute do_run in a transaction, with some other goodies
  end
end

defmodule Implementation do
  use MyBehaviour

  def do_run(arg1), do: :ok
end

Implemenation.run([:arg1])

The idea is that by implementing MyBehaviour, the module Implementation will have run([:arg1]) function that will call do_run(:arg1).

How do I write @callback specification for a function with a variable number of arguments?

I thought that @callback do_run(...) :: any() would work, but Dialyzer gives me an error that Undefined callback function do_run/1, so I assume ... means any argument but not zero arguments.

In reality, I have only two cases: for zero and one arg. I thought about overloading the spec like this:

@callback do_run() :: any()
@callback do_run(any()) :: any()

but that requires two do_run functions because the same name and different arity are two separate functions in the Erlang world.

If I make it @optional_callback there is a possibility that neither of them will get implemented.

@type allows specifying functions of any arity like this (... -> any()) so I would imagine that it should be possible to do the same with @callback.

Is it possible to spec this properly without reimplementing the behaviour?


Solution

  • I am not sure I understood the problem correctly; I do not follow what would be wrong with always passing a list of arguments as we do in mfa.

    Anyway, for the problem as it’s stated, Module.__after_compile__/2 callback and @optional_callbacks are your friends.

    defmodule MyBehaviour do
      @callback do_run() :: :ok
      @callback do_run(args :: any()) :: :ok
      @optional_callbacks do_run: 0, do_run: 1
      defmacro __using__(_) do
        quote location: :keep do
          @behaviour MyBehaviour
          @after_compile MyBehaviour
    
          def run(), do: MyBehaviour.run(__MODULE__)
          def run(do_run_args), do: MyBehaviour.run(__MODULE__, do_run_args)
        end
      end
    
      def run(module),
        do: fn -> apply(module, :do_run, []) end
    
      def run(module, do_run_args),
        do: fn -> apply(module, :do_run, do_run_args) end
    
      def __after_compile__(env, _bytecode) do
        :functions
        |> env.module.__info__()
        |> Keyword.get_values(:do_run)
        |> case do
          [] -> raise "One of `do_run/0` _or_ `do_run/1` is required"
          [0] -> :ok # without args
          [1] -> :ok # with args
          [_] -> raise "Arity `0` _or_ `1` please"
          [_|_]  -> raise "Either `do_run/0` _or_ `do_run/1` please"
        end
      end    
    end
    

    And use it as:

    defmodule Ok0 do
      use MyBehaviour
      def do_run(), do: :ok
    end
    Ok0.run()
    
    defmodule Ok1 do
      use MyBehaviour
      def do_run(arg1), do: :ok
    end
    Ok1.run([:arg1])
    
    defmodule KoNone do
      use MyBehaviour
    end
    #⇒ ** (RuntimeError) One of `do_run/0` _or_ `do_run/1` is required
    
    defmodule KoBoth do
      use MyBehaviour
      def do_run(), do: :ok
      def do_run(arg1), do: :ok
    end
    #⇒ ** (RuntimeError) Either `do_run/0` _or_ `do_run/1` please
    
    defmodule KoArity do
      use MyBehaviour
      def do_run(arg1, arg2), do: :ok
    end
    #⇒ ** (RuntimeError) Arity `0` _or_ `1` please