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

Abusing defguard for keyword lists

keyword_guards.livemd

Abusing defguard for keyword lists

Mix.install([
  {:benchee, "~> 1.3"}
])

Section

In elixir you can pattern match on map key values pairs:

def match_map(%{key1: true}) do
  do_something()
end

def match_map(_) do
  do_something_else()
end

match_map(%{key1: true, key2: false})

It would be great if similar thing could be made using keyword lists. Currently you can match on keyword lists but the order of parameters has to be taken into consideration. When it changes your pattern match will fail.

def match_keyword([key1: true, _]) do
  do_something()
end

def match_keyword(_) do
  do_something_else()
end

match_keyword([key1: true, key2: false])
match_keyword([key2: false, key1: true])

The first function call will succeed but the second won’t be matched. For maps we can use also the dot syntax:

def match_map(opts) when opts.key1 == true do
  do_something()
end

Is there a way to do something similar for keywords? Looking at the documetation you can see this line in and not in operators (as long as the right-hand side is a list or a range). This looks promising because a keyword list is a list of tuples in the form of {key, value} it should be possible to do this:

def match_keyword(keyword) when {:key1, true} in keyword do
  do_something()
end

But after trying to compile this code we get an error:

** (ArgumentError) invalid right argument for operator "in", it expects a compile-time proper list or compile-time range on the right side when used in guard expressions, got: keywords

The only way to get have this working is having the list on compile time already defined.

def match_keyword(keyword) when {:key1, true} in [key1: true, key2: false] do
  do_something()
end

Looking at other possible guards there is this line functions that work on built-in datatypes (abs/1, hd/1, map_size/1, and others). When there is hd is there also a tl available ?? It turns out there is. Having a short list of options in your keyword list you can get around the missing guard by defining a new guard in this way:

  defguard keyword_match(keywords, key, val)
           when is_list(keywords) and
                  is_atom(key) and
                  (hd(keywords) == {key, val} or
                     hd(tl(keywords)) == {key, val})
def match_keyword(keyword) when keyword_match(keyword, :key1, true} do
  do_something()
end

It will check both positions. When you have more params you can just extend the check:

                  (hd(keywords) == {key, val} or
                     hd(tl(keywords)) == {key, val} or
                     hd(tl(tl(keywords))) == {key, val} or
                     hd(tl(tl(tl(keywords)))) == {key, val})

Below you can find a benchmark that you can run yourself, This are my results:

Comparison: 
map_pattern        37.45 M
map_guard          37.38 M - 1.00x slower +0.0455 ns
length_guard       27.88 M - 1.34x slower +9.16 ns
length             27.88 M - 1.34x slower +9.17 ns
guard4             25.73 M - 1.46x slower +12.16 ns
keyword            25.44 M - 1.47x slower +12.61 ns
guard8             18.42 M - 2.03x slower +27.60 ns

It shows that matching on maps is the fastest one. The proposed guard is around 2x slower depending on the amount of options you wan’t to check. It is still comparable with Keyword.get so if you’re using keyword get inside your function you may consider to switch to the proposed guard and remove some nesting. I also included the length function and guard for comparison. This is the only guard that traverses the whole list and can be slow or misused:

def function(x) when length(x) > 0, do: traverse(x)
def function(x) when length(x) == 2, do: traverse(x)

this can be slow when your list is big - instead you should match on exact list size when possible:

def function(x) when x != [], do: traverse(x)
def function([_, _]), do: traverse(x)
defmodule TTT do
  defguard keyword_val_eq4(keywords, key, val)
           when is_list(keywords) and
                  is_atom(key) and
                  (hd(keywords) == {key, val} or
                     hd(tl(keywords)) == {key, val} or
                     hd(tl(tl(keywords))) == {key, val} or
                     hd(tl(tl(tl(keywords)))) == {key, val})

  defguard keyword_val_eq8(keywords, key, val)
           when is_list(keywords) and
                  is_atom(key) and
                  (hd(keywords) == {key, val} or
                     hd(tl(keywords)) == {key, val} or
                     hd(tl(tl(keywords))) == {key, val} or
                     hd(tl(tl(tl(keywords)))) == {key, val} or
                     hd(tl(tl(tl(tl(keywords))))) == {key, val} or
                     hd(tl(tl(tl(tl(tl(keywords)))))) == {key, val} or
                     hd(tl(tl(tl(tl(tl(tl(keywords))))))) == {key, val} or
                     hd(tl(tl(tl(tl(tl(tl(tl(keywords)))))))) == {key, val})

  defguard keyword_val_eq(keywords, key, val)
           when is_list(keywords) and
                  is_atom(key) and
                  :lists.keyfind(key, 1, keywords) == val

  def len(x), do: length(x) > 0
  def length_guard(x) when length(x) > 0, do: true
  def keyword(x), do: Keyword.get(x, :a_4)
  def guard_in(x) when keyword_val_eq(x, :a_4, true), do: true
  def guard_in(_x), do: false
  def guard4(x) when keyword_val_eq4(x, :a_4, true), do: true
  def guard4(_x), do: false
  def guard8(x) when keyword_val_eq8(x, :a_4, true), do: true
  def guard8(_x), do: false
  def map_pattern(%{a_4: true}), do: true
  def map_pattern(_x), do: false
  def map_guard(map) when map.a_4 == true, do: true
  def map_guard(_x), do: false
end

defmodule BNCE do
  @list Enum.map(1..16, &{String.to_atom("a_#{&1}"), &1})
  @map Map.new(@list)

  def length do
    TTT.len(@list)
  end

  def length_guard do
    TTT.length_guard(@list)
  end

  def keyword do
    TTT.keyword(@list)
  end

  def guard_in do
    TTT.guard_in(@list)
  end

  def guard4 do
    TTT.guard4(@list)
  end

  def guard8 do
    TTT.guard8(@list)
  end

  def map_pattern do
    TTT.map_pattern(@map)
  end

  def map_guard do
    TTT.map_guard(@map)
  end
end

Benchee.run(
  %{
    "length" => &BNCE.length/0,
    "length_guard" => &BNCE.length_guard/0,
    "keyword" => &BNCE.keyword/0,
    "guard_in" => &BNCE.guard_in/0,
    "guard4" => &BNCE.guard4/0,
    "guard8" => &BNCE.guard8/0,
    "map_guard" => &BNCE.map_guard/0,
    "map_pattern" => &BNCE.map_pattern/0
  },
  time: 1,
  memory_time: 0
)