Custom Infix Functions in Elixir

Published Sat Feb 13 2016 16:00:00 GMT-0800 (PST) by Rodney Folz

(…sort of)

I’m writing a Math module/shim for Elixir, which means I need to write tests that deal with floats. In particular, I need to know when two floats are really, really close to being equal, even though they might not be 100% equivalent.

My tests look like this:

test "add" do
  assert sum(0.1, 0.2) <~> 0.3
end

test "asin" do
  assert asin(0) <~> 0
  assert asin(1) <~> pi/2
end

and they pass!

➜  mix test
........

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)
8 tests, 0 failures

This might look like magic, especially if you’ve tried and failed to write custom infix functions in the past (like I have). Here’s how I implemented my nearly-equal test:

@doc """
Equality-ish test for floats that are nearly equal.
"""
@spec number <~> number :: boolean
def x <~> y do
  nearly_equal? x, y
end

defp nearly_equal?(x, y) do
  # ...
end

That’s right: it’s just a regular function definition!

This isn’t the only infix operator you can use. Here’s all the infix operators you can define in this way (as of Elixir 1.2.0):

\\, <-, |, ~>>, <<~, ~>, <~, <~>, <|>, <<<, >>>, |||, &&&, and ^^^.

Sadly, you can’t use any other custom symbols or names as infix. But hopefully those fourteen infix operators will be enough to satisfy :-)


Now for the deep dive into why this is the case.

Let’s look at the source code for another infix operator like the built-in + addition operator. First we need to find where + is defined – the Getting Started guide gives us a hint:

The Kernel module is also where operators like +/2 and functions like is_function/2 are defined, all automatically imported and available in your code by default.

Ok, that’s promising. Let’s check out the Kernel module docs for +/2.

Elixir's +/2 docs

Hmm, not really helpful for us. However, there’s a link to the source code for +/2. Maybe that will be what we’re looking for?

@spec (number + number) :: number
def left + right do
  :erlang.+(left, right)
end

This isn’t the answer, but it’s a start. Notice that when the Kernel module implements +/2, it’s already using the left-op-right form. So does this mean we can just define our own infix functions like the ones in Kernel are defined?

@spec (number inf number) :: String.t
def left inf right do
  IO.puts "#{left} INFIX #{right}"
end

No, we can’t:

== Compilation error on file lib/ops.ex ==
** (SyntaxError) lib/ops.ex:34: syntax error before: ')'
    (elixir) lib/kernel/parallel_compiler.ex:100: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/8

Darn, it would have been nice if custom infix functions Just Worked.

But what about trying it the other way? Can we define an infix-by-default operator as a prefix function?

def <~>(left, right) do
  IO.puts "#{left} OP #{right}"
end

This doesn’t work. In fact, it’s a compile error to try to define <~> in the regular way.

➜  iex -S mix
== Compilation error on file lib/ops.ex ==
** (SyntaxError) lib/ops.ex:30: syntax error before: ')'
    (elixir) lib/kernel/parallel_compiler.ex:100: anonymous fn/4 in Kernel.ParallelCompiler.spawn_compilers/8

From this, we can guess that the Elixir parser does not generally support infix notation, but instead has the infix operators in Kernel special-cased. Let’s validate our hunch by finding how Elixir is parsed.

This information, however, is not to be found in the Getting Started Guide. Instead, a brief Google search for “elixir parser” led me to this very interesting article on parsing source code with Elixir. Near the end of article, the author mentions that Elixir itself is parsed using the same methods discussed in the article:

Want another real-world™ example? Wait, I think I have one: ever heard of the Elixir programming language? It’s a nice language built atop the Erlang virtual matching, focused on concurrency, fault to… Well, it’s parsed by yecc :).

Jackpot! Let’s check the source for elixir_parser.yrl, the file linked in that quote, and see what we get.

Left       5 do.
Right     10 stab_op_eol.     %% ->
Left      20 ','.
Nonassoc  30 capture_op_eol.  %% &
Left      40 in_match_op_eol. %% <-, \\ (allowed in matches along =)
Right     50 when_op_eol.     %% when
Right     60 type_op_eol.     %% ::
Right     70 pipe_op_eol.     %% |
Right     80 assoc_op_eol.    %% =>
Right     90 match_op_eol.    %% =
Left     130 or_op_eol.       %% ||, |||, or
Left     140 and_op_eol.      %% &&, &&&, and
Left     150 comp_op_eol.     %% ==, !=, =~, ===, !==
Left     160 rel_op_eol.      %% <, >, <=, >=
Left     170 arrow_op_eol.    %% |>, <<<, >>>, ~>>, <<~, ~>, <~, <~>, <|>
Left     180 in_op_eol.       %% in
Left     190 three_op_eol.    %% ^^^
Right    200 two_op_eol.      %% ++, --, .., <>
Left     210 add_op_eol.      %% +, -
Left     220 mult_op_eol.     %% *, /
Nonassoc 300 unary_op_eol.    %% +, -, !, ^, not, ~~~
Left     310 dot_call_op.
Left     310 dot_op.          %% .
Nonassoc 320 at_op_eol.       %% @
Nonassoc 330 dot_identifier.

There we have it. In Elixir, operators are predefined in the parser. Since the parse step happens before Elixir is bootstrapped, there’s no way for us to define our own operators in our code. We would either have to modify the Elixir parser directly (and recompile Elixir) or just be satisfied with one of the fourteen unused operators:

\\, <-, |, ~>>, <<~, ~>, <~, <~>, <|>, <<<, >>>, |||, &&&, and ^^^.

After I posted an excited tweet about my "discovery", José Valim reached out to me and confirmed that the unused operators were deliberately included for exactly this use case.

In summary:

  • Elixir’s infix operators are special-cased in the parser.
  • The Kernel module just implements them, but doesn’t do anything special, syntax-wise, itself.
  • You can implement behavior for unused operators that the parser knows about.
  • You can’t create new infix operators without recompiling Elixir from source.

But wait, there’s more!

While I was searching around for how infix operators worked, I came across this #elixir-lang-talk thread which discussed overriding the built-in Kernel module’s operators.

One special thing about Kernel is that it defines the original implementations for each operator. You can redefine any of the operators if you first unimport it inside your module.

Once you have a new implementation for an operator (say, in module called MyOp), you can call it as a function

MyOp.>(a, b)

or you can import it and use it as an operator

import Kernel, except: [>: 2]
import MyOp

a > b

This trick is used by the pipespect library to automatically IO.inspect every stage in a |> pipe

import Kernel, except: [{:|>, 2}]
defmacro first |> rest do
  # code and edge cases to wrap the pipeline in IO.inspect
end

Since this is monkey-patching the language, redefining Kernel’s built-in operators may break expectations from other Elixir code and should be used with care, if it’s used at all. But it’s a fascinating example of how predictable and regular Elixir can be.


You can also find me @rodneyfolz on Twitter.