Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Alternate Syntax for Multi-Clause Functions in Elixir

multi_clause_def.livemd

Alternate Syntax for Multi-Clause Functions in Elixir

Why?

Defining additional clauses for a function looks the same as defining entirely new functions; the names and arities just match the original.

defmodule MyModule do
  def first([head | _tail]) do
    head
  end

  def first([]) do
    nil
  end
end
{:module, MyModule, <<70, 79, 82, 49, 0, 0, 5, ...>>, {:first, 1}}

When a function has default arguments and multiple explicit clauses, the defaults must live in a separate function head.

defmodule MyModule do
  def first(list, default \\ nil)

  def first([head | _tail], _default) do
    head
  end

  def first([], default) do
    default
  end
end
{:module, MyModule, <<70, 79, 82, 49, 0, 0, 6, ...>>, {:first, 2}}

In my opinion this syntax has a couple downsides:

  • It can be to see at first glance that definitions are part of the same function and not independent functions.
  • Defining many clauses of a function requires a lot of repetition.

Multi-clause functions have a much more succinct syntax:

first = fn
  [head | _tail], _default ->
    head

  [], default ->
    default
end
#Function<43.65746770/2 in :erl_eval.expr/5>

The anonymous function syntax is a lot clearer at first glance, and it is quite a bit more consistent with other Elixir constructs such as case (again most of this is my opinion). Unfortunately there’s not a great way to use this syntax with named functions, and since anonymous functions must be a constant arity, their syntax doesn’t provide for default arguments.

This experiment is an attempt to combine my favorite parts of both syntaxes.

MultiClauseDef

To support the syntax I created a new set of “def” macros. They look for use of the new syntax and transform it to multiple clauses of the function, falling back to their Kernel counterparts if the new syntax is not used.

defmodule MultiClauseDef do
  @typep clause() :: {[Macro.t()], guard(), keyword(), Macro.t()}
  @typep def_type() :: :def | :defp | :defmacro | :defmacrop
  @typep guard() :: (Macro.t() -> Macro.t())

  defmacro def(call, expr) do
    define(:def, call, expr, __CALLER__)
  end

  defmacro defp(call, expr) do
    define(:defp, call, expr, __CALLER__)
  end

  defmacro defmacro(call, expr) do
    define(:defmacro, call, expr, __CALLER__)
  end

  defmacro defmacrop(call, expr) do
    define(:defmacrop, call, expr, __CALLER__)
  end

  @spec define(def_type(), Macro.t(), Macro.t(), Macro.Env.t()) :: Macro.t() | no_return()
  defp define(type, call, expr, env) do
    with {:ok, name, arity} <- parse_call(call),
         {:ok, clauses, rest} <- parse_expr(expr, arity) do
      quote do
        # define a function head with the original call to handle default arguments
        unquote(build_definition(type, call, nil))
        # define each clause
        unquote_splicing(build_definitions(type, name, clauses, rest))
      end
    else
      # if the new syntax is not used, fallback to original syntax
      :fallback ->
        build_definition(type, call, expr)

      {:arity, actual: actual, expected: expected, line: line} ->
        raise CompileError,
          description: "incorrect arity; expected: #{expected}, got: #{actual}",
          file: env.file,
          line: line || env.line
    end
  end

  @spec parse_call(Macro.t()) :: {:ok, atom(), arity()} | :fallback
  defp parse_call(call)

  defp parse_call({name, _meta, args}) when is_atom(name) and is_list(args) do
    {:ok, name, length(args)}
  end

  defp parse_call(_call) do
    :fallback
  end

  @spec parse_expr(Macro.t(), arity()) ::
          {:ok, [clause()], keyword()} | :fallback | {:arity, keyword()}
  defp parse_expr(expr, arity)

  defp parse_expr([{:do, clauses = [{:->, _, _} | _]} | rest], arity) do
    parsed_clauses = Enum.map(clauses, &amp;parse_clause/1)

    case Enum.find(parsed_clauses, &amp;bad_arity?(&amp;1, arity)) do
      {args, _guard, meta, _block} ->
        {:arity, actual: length(args), expected: arity, line: meta[:line]}

      nil ->
        {:ok, parsed_clauses, rest}
    end
  end

  defp parse_expr(_expr, _arity) do
    :fallback
  end

  @spec parse_clause(Macro.t()) :: clause()
  defp parse_clause({:->, meta, [args, block]}) do
    {args, guard} = parse_guard(args)
    {args, guard, meta, block}
  end

  @spec parse_guard(Macro.t()) :: {[Macro.t()], guard()}
  defp parse_guard(args)

  defp parse_guard([{:when, meta, args}]) do
    {args, [guard]} = Enum.split(args, -1)
    {args, &amp;{:when, meta, [&amp;1, guard]}}
  end

  defp parse_guard(args) do
    {args, &amp;Function.identity/1}
  end

  @spec bad_arity?(clause(), arity()) :: boolean()
  defp bad_arity?({args, _guard, _meta, _block}, arity) do
    length(args) != arity
  end

  @spec build_definitions(def_type(), atom(), [clause()], keyword()) :: [Macro.t()]
  defp build_definitions(type, name, clauses, rest) do
    Enum.map(clauses, fn {args, guard, meta, block} ->
      call = guard.({name, meta, args})
      build_definition(type, call, [{:do, block} | rest])
    end)
  end

  @spec build_definition(def_type(), Macro.t(), Macro.t()) :: Macro.t()
  defp build_definition(type, call, expr) do
    quote do
      Kernel.unquote(type)(unquote(call), unquote(expr))
    end
  end
end
{:module, MultiClauseDef, <<70, 79, 82, 49, 0, 0, 26, ...>>, {:build_definition, 3}}

Usage

To use the new syntax, def/2 (or whichever macro used) must be explicitly excluded from the automatic imports from Kernel. Then def/2 must be imported from MultiClauseDef. It would be easy to wrap that in __using__/1 so one could just use MultiClauseDef, but the docs recommend not providing a __using__/1 that just imports, so I didn’t do that.

Using the new syntax to redefine MyModule, it feels clearer to me that I’m looking at 2 clauses of the first/2 function.

defmodule MyModule do
  import Kernel, except: [def: 2], warn: false
  import MultiClauseDef, only: [def: 2]

  def first(list, default \\ nil) do
    [head | _tail], _default ->
      head

    [], default ->
      default
  end
end
{:module, MyModule, <<70, 79, 82, 49, 0, 0, 6, ...>>, {:first, 2}}

The defined function works as expected. When passed a non-empty list, the first clause matches and returns the first element in the list.

MyModule.first([:a, :b, :c], :d)
:a

If the list is empty, the second clause matches and returns the default.

MyModule.first([], :d)
:d

And the default argument for default still works.

MyModule.first([])
nil

Guard clauses also work as expected, clause order is preserved, and the new macros can be used with single-clause functions. To illustrate, I’ll create a function that dispatches to a recursive private function (the exact functionality is gibberish and doesn’t matter).

defmodule AnotherModule do
  import Kernel, except: [def: 2, defp: 2]
  import MultiClauseDef, only: [def: 2, defp: 2]

  def do_something(string) when is_binary(string) do
    do_something(string, [])
  end

  defp do_something(string, acc) do
    <>, acc when character in ?a..?z ->
      do_something(string, [character + ?A - ?a | acc])

    <>, acc when character in ?0..?9 ->
      do_something(string, [character | acc])

    <<_character, string::binary>>, acc ->
      do_something(string, acc)

    <<>>, acc ->
      :erlang.list_to_binary(acc)
  end
end
{:module, AnotherModule, <<70, 79, 82, 49, 0, 0, 8, ...>>, {:do_something, 2}}

And just to show that compiled into something that does something:

AnotherModule.do_something("Ch4WYqWoXzZ+pWwuvyB/Nsio5LvgUT5kH0Qh9BV5V8Q=")
"859H0K5GV5OISYVUWPZOQ4H"

Wrap Up

Of course I’d never do something like this in production code, but this exercise was fun for me. I got to play with metaprogramming, and I’m pretty happy with how the new syntax turned out. I feel like especially with the second example the separation between the public do_something/1 and the private do_something/2 is much clearer than the standard syntax.