Check if a function head has a matching pattern

Is it possible to determine whether a function head will match given arguments without executing the function body?

Here’s some example code to demonstrate the use case I’m trying to support. The following Calculator module defines supported?/2 functions which return true if an operation is supported by the calculator. For each supported function there is a corresponding execute function for the operation.

defmodule Calculator do
  def reduce(ops, initial) do
    ops
    |> Enum.filter(fn {op, n} -> supported?(op, n) end)
    |> Enum.reduce(initial, fn {op, n}, acc -> execute(op, n, acc) end)
  end

  defp supported?(:add, n) when is_number(n), do: true
  defp supported?(:subtract, n) when is_number(n), do: true
  defp supported?(_op, _n), do: false

  defp execute(:add, n, acc) when is_number(n), do: acc + n
  defp execute(:subtract, n, acc) when is_number(n), do: acc - n
end

Calculator.reduce([{:add, 10}, {:subtract, 5}, {:multiply, 2}], 0)

The problem with this code is the duplication between having both supported? and execute functions to ensure they are consistent (both functions exist, have same args, pattern matches, and guard clauses). This type of code is error prone because one must remember to modify both functions when making changes and is made worse because they may be physically separated when many different operations are supported.

Elixir has Module.defines?/3 which checks if the module defines a named function or macro of the given arity and kind (private, public function/macro). I’m after something similar, but which also takes arguments to check for a match, perhaps: Module.matches?(__MODULE__, {:function_name, 3}, :defp, [arg1, arg2, arg3]).

With such a function the above example could be rewritten as:

defmodule Calculator do
  def reduce(ops, initial) do
    ops
    |> Enum.filter(fn {op, n} -> supported?(op, n, initial) end)
    |> Enum.reduce(initial, fn {op, n}, acc -> execute(op, n, acc) end)
  end

  defp supported?(op, n, initial) do
    # Is there a matching `execute/3` function for the given args?
    args = [op, n, initial]
    Module.matches?(__MODULE__, {:execute, 3}, :defp, args)
  end

  defp execute(:add, n, acc) when is_number(n), do: acc + n
  defp execute(:subtract, n, acc) when is_number(n), do: acc - n
end

Calculator.reduce([{:add, 10}, {:subtract, 5}], 0)

This replaces the manually defined and duplicated supported? functions with checks to see whether a matching execute function exists matching the given args.

Is there any existing way of acheiving this goal of having the supported? function determined by the presence of a matching execute function? One possible solution would be defining a macro which could implement the supported?/2 function under the covers by copying the args/guard clauses, but I’d prefer not to have to resort to a macro if possible.

1 Like

Reading through the Module documentation I’ve (re)discovered the @on_definition attribute which might provide the extension point to allow defining a matching supported? function for each execute/3 function which can apply the same argument pattern matching and guard clauses.

Instead of defining supported?, why not just define a default execute/3 that’s a no-op?

defmodule Calculator do
  def reduce(ops, initial) do
    Enum.reduce(opts, initial, fn {op, n}, acc -> execute(op, n, acc) end)
  end

  defp execute(:add, n, acc) when is_number(n), do: acc + n
  defp execute(:subtract, n, acc) when is_number(n), do: acc - n
  defp execute(_op, _n, acc), do: acc
end

Calculator.reduce([{:add, 10}, {:subtract, 5}, {:multiply, 2}], 0)

That should do what you want without duplication or any complicated logic around whether a function head will match.

@blatyo Here’s the actual use case for what I’m trying to acheive. It’s an event handler process that receives events persisted to a store.

Handlers are only interested in a subset of the events, so sending all persisted events to the process is often wasteful since it just gets ignored. To only receive the subset of interested events a selector/1 function can be provided. This filters unwanted events (in the producing process) before sending to the receiving proces.

defmodule AnEventHandler do
  use Commanded.Event.Handler, 
    name: "AnEventHandler",
    selector: &interested?1

  def interested?(%AnEvent{}), do: true
  def interested?(_event), do: false

  def handle(%AnEvent{}, _metadata) do
    :ok
  end
end

My issue is the duplication, it will be very easy to add or amend a handle/2 function and forget to modify the interested? selector function leading to subtle and hard to find bugs when later tested/deployed.

I would probably have the processors subscribe to the events they want. Then you’d only broadcast an event to those subscribed to that event. You could use Registry for that.

https://hexdocs.pm/elixir/master/Registry.html#module-using-as-a-pubsub

The issue I’m looking to prevent is having two places defining the list of interested events (the subscription list is still separate from the handle/2 functions tha process the events) to prevent them from getting out of sync. I’d also like to support pattern matching by event content too.

I’d like to hide the complexity of this performance optimisation from consumers of the macro so they don’t even need to think about it or stumble into problems.

A macro is probably your best bet then. Something like:

handle %AnEvent{foo: foo} when is_integer(foo) do
  #handle logic
end

You can store the event pattern in a module attribute and define a __before_compile__/1 macro that then defines all the supported? functions and then the handle functions. You’ll have to do it in a before compile because you can’t interleave definitions for supported? and handle.

https://hexdocs.pm/elixir/Module.html#module-before_compile

I’ve been able to use the on_definition callback to capture execute/3 functions and define a corresponding supported?/3 function which is working, but I cannot work out how to include the guard clauses.

defmodule Hooks do
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)

      Module.register_attribute(
        __MODULE__,
        :executes,
        accumulate: true,
        persist: false
      )

      @on_definition {unquote(__MODULE__), :on_definition}
      @before_compile {unquote(__MODULE__), :before_compile}
    end
  end

  def on_definition(env, _kind, :execute, [_op, _n, _acc] = args, guards, _body) do
    Module.put_attribute(env.module, :executes, {args, guards})
  end

  def on_definition(_env, _kind, _func, _args, _guards, _body) do
  end

  defmacro before_compile(env) do
    executes = Module.get_attribute(env.module, :executes)

    supported_ops =
      Enum.map(executes, fn {[op, n, acc], guards} ->
        quote do
          defp supported?(unquote(op), _n, _acc) do
            true
          end
        end
      end)

    quote do
      unquote(supported_ops)

      defp supported?(op, n, _initial) do
        false
      end
    end
  end
end

Usage is just to define a standard Elixir function, not a macro:

defmodule Calculator do
  use Hooks

  def reduce(ops, initial) do
    ops
    |> Enum.filter(fn {op, n} -> supported?(op, n, initial) end)
    |> Enum.reduce(initial, fn {op, n}, acc -> execute(op, n, acc) end)
  end

  defp execute(:add, n, acc) when is_number(n), do: acc + n
  defp execute(:subtract, n, acc) when is_number(n), do: acc - n
end

I’ve pushed the current source up to GitHub including a failing unit test for the unsupported guard clause.

Finally have guard clauses working now. I needed to combine the multiple guards provided as a list into an AST using or (see combine_guards/1 below for implementation).

defmodule Hooks do
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)

      Module.register_attribute(
        __MODULE__,
        :executes,
        accumulate: true,
        persist: false
      )

      @on_definition {unquote(__MODULE__), :on_definition}
      @before_compile {unquote(__MODULE__), :before_compile}
    end
  end

  def on_definition(env, _kind, :execute, args, guards, _body) do
    Module.put_attribute(env.module, :executes, {args, guards})
  end

  def on_definition(_env, _kind, _func, _args, _guards, _body) do
  end

  defmacro before_compile(env) do
    executes = Module.get_attribute(env.module, :executes)

    supported_ops =
      Enum.map(executes, fn {args, guards} ->
        guard = combine_guards(guards)
        used_args = exclude_unused_args(args)

        quote generated: true do
          defp supported?(unquote_splicing(args)) when unquote(guard) do
            # Silence compiler warnings about unused variables
            _ = {unquote_splicing(used_args)}
            true
          end
        end
      end)

    quote generated: true do
      unquote(supported_ops)

      # Operations are not supported by default.
      defp supported?(_op, _n, _initial) do
        false
      end
    end
  end

  # Combine list of guard clauses to single guard using `or`.
  defp combine_guards(guards)

  defp combine_guards([]), do: nil

  defp combine_guards(guards) do
    Enum.reduce(guards, fn guard, acc ->
      quote do: unquote(acc) or unquote(guard)
    end)
  end

  defp exclude_unused_args(args) do
    Enum.reject(args, fn
      {arg, _context, _args} -> Atom.to_string(arg) =~ "_"
      _ -> false
    end)
  end
end

The approach is now completely generic and could work with any length function. Next step is to make it configurable via the using macro (e.g. define source and generated functions).

3 Likes