Findmypast Tech

Elixir magic for fun and profit!

or, how I learned to stop worrying and love the macro
Andy Mendelsohn Andy Mendelsohn
Reading time: 4 min

It started on slack…

…with a brief slack exchange where David Elliot pasted a specific snippet of elixir describing a data structure and how to use the put_in function to modify that data:

our_tasty_map =
      %{fields: [
        %{name: "rank", rank: 0},
        %{name: "location", location: ""}],
      type: "how-common-is-surname"}
        |> put_in([:fields, Access.at(0), :rank], 100)
        |> put_in([:fields, Access.at(1), :location], "Weston Super Mare")

which transforms this:

       %{
         fields: [
            %{name: "rank", rank: 0},
            %{name: "location", location: ""}],
          type: "how-common-is-surname"
        }

into this:

%{
  fields: [
     %{name: "rank", rank: 100},
     %{name: "location", location: "Weston Super Mare"}],
   type: "how-common-is-surname"
 }

My first thought was “yuck” because, at first glance, I hated this:

put_in([:fields, Access.at(0), :rank], 100)
put_in([:fields, Access.at(1), :location], "Weston Super Mare")

This is certainly not David’s fault. It’s just the argument syntax required by the put_in function, and specifically the array passed as the second argument to put_in. It’s an array of keys and/or list locations, and it was crying out for some syntactic sugar. Forgive me if I admit to immediately craving something akin to the perl5 data structure de-referencing syntax. (hey, listen, I like Perl, m’kay?)

Deal!

$hash->{"fields"}->[1]->{"location"}

It’s a while since I’ve written any Perl, but I think this, or something similar is the syntax required to get to the data stored under the {“location”} key in the anonymous hash referenced at the second [1] element of the anonymous array referenced to by the {“fields”} key of the hash referenced to by the variable $hash.

So I was really craving this:

put_in(access_at("{:fields}->[1]->{:location}"), "Weston Super Mare")

instead of this:

put_in([:fields, Access.at(1), :location], "Weston Super Mare")

Yes, it’s more key-presses, but it feels (IMO) nicer, cleaner and easier to understand. It might not work for you, but it worked for me… And yes, to some, it feels uncomfortable when elixir uses -> to indicate the body of a function, but the string quoting of that syntax is probably enough to disambiguate it.

At the end of the day this is just code generation where I needed this:

access_at("{:fields}->[1]->{:location}")

to generate (morph into) this:

[:fields, Access.at(1), :location]

Macro FTW!

This is a perfect job for a macro based Meta-programming job. It’s all about code generation!

Meta-programming is bad!

meta programming bad!

Wicked. Evil. Meta-programming and the macros that make it easy can also do stuff that you don’t know about or expect.

On the other hand….

Meta-programming is fantastic!

meta programming bad!

Wonderful, amazing things. Meta-programming and the macros that make it easy can do stuff that makes magic, saves you time, makes your code more readable, etc, etc..

Elixir’s macros are not hard to apply, understand or use. They are a little surprising. But they are surprising in that they seem to just work and they work with such ease.

So, I took a stab at it, and after a little fumbling I got my integration test to pass using the following code:

defmodule Mapit do

  defmacro access_at(data_structure_string) do
    String.split(data_structure_string, "->") |> Enum.map(&convert_from/1)
  end

  defp convert_from(string) do
    cond do
      string =~ ~r/\{.*}/ ->
        mapped = Regex.named_captures(~r/\{:(?<key>.*)\}/,string)
        String.to_atom mapped["key"]
      string =~ ~r/\[.*\]/ ->
        mapped = Regex.named_captures(~r/\[(?<index>.*)\]/,string)
        index = String.to_integer mapped["index"]
        quote do Access.at(unquote(index)) end
    end
  end

Wow.

It worked.

But I didn’t like it.

It felt clunky. cryptic. repetitive. conditional. conditional. Yes, that was it. The conditional needed to go. But also, that convert_from was just doing far too much.

So I broke it up into smaller pieces of functional functions.

The First problem was how to match once as opposed to twice and to capture everything in one hit rather than two and how to reference the matches easily: Regex.named_captures to the rescue!

   ~r/(\{:(?<key>.*)\}|\[(?<index>.*)\])/

This regular expression matches {:map_key} OR [n] not both (it can’t be both!). It also returns a map with both keys, one of which will hold an empty string and the other a value (depending on whether it matches a key or an index).

That made calling a pattern matching function very easy.

defmodule Accessit do

  defmacro access_at(data_structure_string) do
    String.split(data_structure_string, "->")
    |> Enum.map( &get_key_or_index/1 )
    |> Enum.map( &convert_from_capture/1)
  end

  defp get_key_or_index(string) do
    Regex.named_captures(~r/(\{:(?<key>.*)\}|\[(?<index>.*)\])/,string)
  end

  defp convert_from_capture(%{"key" => key, "index" => ""}) do
    String.to_atom(key)
  end

  defp convert_from_capture(%{"key" => "", "index" => index}) do
    index = String.to_integer(index)
    quote do Access.at(unquote(index)) end
  end

end

Pretty bloody simple.

access_at takes a string, splits it on the (possibly contentious) “->” and passes the resultant array to a map calling the get_key_or_index function which, surprise surprise, gets the key or index. get_key_or_index is a simple one line regex call that matches on the key or index, based on the syntax of “” or [n] and returns a simple Map of matches. Simples!

The nice thing about Maps is that you don’t have to care about their order when you are using them to pattern match function arguments. And, because you can’t be a map key -and- an array index, the Map returned by get_key_or_index would have either ‘key’ or ‘index’ with a value and the other pointing to an empty string. That’s pattern matching heaven!

In the case of it being a Map key, we merely call String.to_atom (because we stripped out the ‘:’ in the regex). For list elements, we take advantage of both quote and unquote in order to return Access.at with the correct index number (as opposed to calling it).

See: no conditionals!

And now there’s a git repo. accessit .Clone it , Stick the lib/accessit.ex in your lib, require it with:

defmodule Mymodule do

require 'accessit'
import Accessit

...

end

There are still a few minor problems, like the fact that it’s not matching a key that’s a string, but that’s pretty easy to fix. The tests are all integration testing because we only really want to test the code that’s generated and not the meta-code or AST.

So there you have it. Bob’s your uncle, Mary’s your aunt and Meta-programming is the grand wizard that heals your wounds and makes you smile.