Overloaded function fails if a map is used versus a keyword list

Hi all, I have an issue with a module I’m writing.

In this module I have a function called SSHLogClient.stream/1 that I’m trying to inject default values into. Ideally, I’d like to use a map just to get some structure without worrying about order (and without extra boiler plate like with Keyword.get/3), however I’m running into a strange issue. When using map, the function just hangs… but when using a Keyword list, it returns correctly.

Can anyone explain the behavior? Or can anyone suggest a standard way to add structure to the function signature without adding too much extra code?

Below I have reproduced the behavior in the iex repl.

Working Keyword List Version

defmodule SSHLogClientKeywordList do
  def stream(user: user, ip: ip, file_path: file_path) do
    stream(user: user, ip: ip, file_path: file_path, offset: 0, limit: 1000)
  end

  def stream(user: user, ip: ip, file_path: file_path, offset: offset) do
    stream(user: user, ip: ip, file_path: file_path, offset: offset, limit: 1000)
  end

  def stream(li = [user: user, ip: ip, file_path: file_path, offset: offset, limit: limit]) do
    IO.inspect(li)
  end
end
# test that the keyword list has default values
SSHLogClientKeywordList.stream(user: "user", ip: "ip", file_path: "file_path") == [user: "user", ip: "ip", file_path: "file_path", offset: 0, limit: 1000]
# true

Not Working Map Version

defmodule SSHLogClientMap do
  def stream(%{user: user, ip: ip, file_path: file_path}) do
    stream(%{user: user, ip: ip, file_path: file_path, offset: 0, limit: 1000})
  end

  def stream(%{user: user, ip: ip, file_path: file_path, offset: offset}) do
    stream(%{user: user, ip: ip, file_path: file_path, offset: offset, limit: 1000})
  end

  def stream(li = %{user: user, ip: ip, file_path: file_path, offset: offset, limit: limit}) do
    # This inspect never is called
    IO.inspect(li)
  end
end
# test that the map has default values
SSHLogClientMap.stream(%{user: "user", ip: "ip", file_path: "file_path"}) == %{user: "user", ip: "ip", file_path: "file_path", offset: 0, limit: 1000}
# does not resolve and hangs
iex --version
IEx 1.15.4 (compiled with Erlang/OTP 26)

This is because maps do partial matches while lists do not (well, technically they do in this form [a | _] = [1, 2, 3]).

What’s happening in that map version is that the second function head is always matching. When you call stream(%{user: user, ip: ip, file_path: file_path, offset: offset, limit: 1000}), the match being performed is as follows:

%{user: user, ip: ip, file_path: file_path, offset: offset} =
  %{user: user, ip: ip, file_path: file_path, offset: offset, limit: 1000}

So this results in an endless loop.

As an aside, this is why we generally write our matches in function heads with the variable on the right, like so:

def foo(%{bar: baz} = arg), do: # ...

This makes it clearer as to the match that’s going to be performed.

4 Likes

Reordering the clauses in your example avoids the infinite recursion:

defmodule SSHLogClientMap do
  def stream(li = %{user: user, ip: ip, file_path: file_path, offset: offset, limit: limit}) do
    # This inspect never is called
    IO.inspect(li)
  end

  def stream(%{user: user, ip: ip, file_path: file_path, offset: offset}) do
    stream(%{user: user, ip: ip, file_path: file_path, offset: offset, limit: 1000})
  end

  def stream(%{user: user, ip: ip, file_path: file_path}) do
    stream(%{user: user, ip: ip, file_path: file_path, offset: 0, limit: 1000})
  end
end

The heads with fewer keys will always match longer maps, so they need to be after the more-specific heads.

Regarding adding more structure to arguments, consider making a struct to combine user/IP/path together. Alternately, you could use the URI struct in stdlib and get a configuration parser for free:

iex(5)> URI.parse("ssh://user@example.com:2222/foo/bar/baz")

%URI{
  authority: "user@example.com:2222",
  fragment: nil,
  host: "example.com",
  path: "/foo/bar/baz",
  port: 2222,
  query: nil,
  scheme: "ssh",
  userinfo: "user"
}
6 Likes

To summarize what the other two posters said: when doing pattern-matching in function heads, start with the most concrete clause and make it more and more general as you go down.

Simplified example:

def do_stuff(%{a: a, b: b, c: c}), do: nil
def do_stuff(%{a: a, b: b}), do: nil
def do_stuff(%{a: a}), do: nil

Your code’s second map clause always matches when you attempt to call the 3rd for that reason.

TL;DR in Elixir the pattern-matching clause order matters.

7 Likes

Thank you, all! Gosh, just 8 months away, and even the basics have escaped me. Yes, of course, this makes sense; it’s exactly why the _ -> ... is put at the bottom and not the top of a case clause.

I think I’ve been programming too much TypeScript is why; the convention is the opposite, with more general at the top and more specific at the bottom.

Really appreciate all your help! I chose the solution for the extra suggestion of using URI, which is a great idea.

4 Likes

I don’t know Typescript or anything about its overloading mechanics but it might help to note that it’s not technically correct to refer to Elixir’s multi-head function definition syntax as “overloading”. Multi-head defintions with the same artity, regardless of differing argument types, result in a single function after compilation. In other words, a multi-head definition is equivalent to a single-head definition with a case statement as its body.

3 Likes