Function with default arguments and explicitly declared clauses

Problem
I have a module that does a lookup and I structured in a way where you can pass either 1, 2 or 3 arguments.

defmodule FooExampleOne do
	def lookup(id) do
       IO.inspect("Lookup 1: #{id}")
	end

	def lookup(id, place_id) do
       IO.inspect("Lookup 2: #{id} #{place_id}")
	end

	def lookup(id, place_id, company_id) when company_id in [:internal, :external] do
      IO.inspect("Lookup 3: #{id} #{place_id} #{company_id}")
	end
end

Adding a default date as another argument
I then got a requirement to provide a date as another argument. The user can always pass in another date but by default it would be today.

defmodule FooExampleTwo do

	@default DateTime.utc_now()

	def lookup(id, date \\ @default) do
      IO.inspect("Lookup 1: #{id} #{date}")
	end

	def lookup(id, place_id, date \\ @default) do
      IO.inspect("Lookup 2: #{id} #{place_id} #{date}")
	end

	def lookup(id, place_id, company_id, date \\ @default) when company_id in [:internal, :external] do
       IO.inspect("Lookup 3: #{id} #{place_id} #{company_id} #{date}")
	end
end

Compile Error
This won’t work as I will get a (CompileError) def lookup/3 defaults conflicts with lookup/2. I looked at the docs and I saw this and then came up with this solution and it works with some changes.

defmodule FooExampleThree do

	@default DateTime.utc_now()

    def lookup(params, date \\ @default)
	def lookup({id}, date) do
       IO.inspect("Lookup 1: #{id} #{date}")
	end

	def lookup({id, place_id}, date) do
        IO.inspect("Lookup 2: #{id} #{place_id} #{date}")
	end

	def lookup({id, place_id, company_id}, date) when company_id in [:internal, :external] do
      IO.inspect("Lookup 3: #{id} #{place_id} #{company_id} #{date}")
	end

end

Question About My Approach
I added a function head that declares the default and then put my arguments in a tuple. Is this style ok?

Part of me doubts that this solution is ideal. If it’s not a tuple I can alway pass in a map in the params position.

Any feedback or thoughts around the approach?

1 Like

The general way of doing this is to declare a single head with all the optional arguments, and then a single function with the maximum number of arguments :smiley:

  def lookup(params, place_id \\ default_place(), company_id \\ default_company(), date \\ default_date())

  def lookup(params, place_id, company_id, date) do
    # do stuff
  end
  
  def lookup(params, place_id, company_id, date) do
    # another clause
  end

Note that when you do this:

@default DateTime.utc_now()

It is evaluated at compile time. So the default date will be the date at the moment of compilation, which is probably not what you want.

But you can use function calls as default arguments:

  def my_default() do
    DateTime.utc_now()
  end

  def my_fun(date \\ my_default()) do
    #
  end

Because this:

  def my_fun(date \\ my_default()) do
    #
  end

Compiles as the same as this:

  def my_fun() do
    my_fun(my_default())
  end

  def my_fun(date) do
    #
  end

Edit:

If you regenerate Erlang code from this module:

defmodule Demo do
  def my_fun(a \\ :w, b \\ :x, c \\ :y, d \\ :z)

  def my_fun(a, b, c, d) do
    [a, b, c, d]
  end
end

You get a bunch of attribute and these function definitions:

my_fun() -> my_fun(w, x, y, z).

my_fun(_@1) -> my_fun(_@1, x, y, z).

my_fun(_@1, _@2) -> my_fun(_@1, _@2, y, z).

my_fun(_@1, _@2, _@3) -> my_fun(_@1, _@2, _@3, z).

my_fun(_a@1, _b@1, _c@1, _d@1) ->
    [_a@1, _b@1, _c@1, _d@1].
6 Likes

The problem you ran into was that lookup/2 (with a date explicitly supplied) isn’t distinguishable from lookup/2 with place_id and the date defaulted.

The tuple provides a way to distinguish the two - one has {id}, date and the other has {id, place_id} - but it does seem kind of odd. A keyword list (lookup(id, place_id: ... company_id: ..., etc)) would be more idomatic.


One important callout: defining @default like your examples will set the default to the date when the module is compiled! DateTime.utc_now will run exactly once…

The behavior is different if the code is written out in the \\ clause:

defmodule Foo do                                                                                         
  @default IO.inspect("wat", label: "in @default")                                                       
  def with_default(x \\ @default), do: IO.inspect(x, label: "in with_default")                           
  def with_default_function(x \\ IO.inspect("wat", label: "in with_default_function's default")), do: IO.inspect(x, label: "in with_default_function")
end
iex(1)> defmodule Foo do                                                                                         
...(1)>   @default IO.inspect("wat", label: "in @default")                                                       
...(1)>   def with_default(x \\ @default), do: IO.inspect(x, label: "in with_default")                           
...(1)>   def with_default_function(x \\ IO.inspect("wat", label: "in with_default_function's default")), do: IO.inspect(x, label: "in with_default_function")
...(1)> end
in @default: "wat"
{:module, Foo,
 <<70, 79, 82, 49, 0, 0, 8, 4, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 214, 0,
   0, 0, 20, 10, 69, 108, 105, 120, 105, 114, 46, 70, 111, 111, 8, 95, 95, 105,
   110, 102, 111, 95, 95, 10, 97, 116, ...>>, {:with_default_function, 1}}

iex(2)> Foo.with_default()
in with_default: "wat"
"wat"

iex(3)> Foo.with_default_function()
in with_default_function's default: "wat"
in with_default_function: "wat"
"wat"

Note that "in @default" prints before the compiled module is inspected.

3 Likes

Note that when you do this:

@default DateTime.utc_now()

It is evaluated at compile time. So the default date will be the date at the moment of compilation, which is probably not what you want.

Good catch!

The Head
I took the code below and threw it in IEX
In my example the 2nd and 3rd arguments can be nil.

defmodule FooExampleFour do

  def lookup(id, place_id \\ nil, company_id \\ nil, date \\ default_date())
  def lookup(id, place_id, company_id, date), do: IO.inspect("#{id} #{place_id} #{company_id} #{date}")
  def default_date(), do: DateTime.utc_now()

end

and it generated a bunch of these definitions as per your Erlang example

iex(2)> FooExampleFour.
default_date/0    
lookup/1          
lookup/2          
lookup/3          
lookup/4

Now if I write out lookup(“A”) I will get back “A” and a Date.
But I want to control where that appears in the module.
I don’t know if I’m making sense here.

Part of me is thinking maybe I need to rewrite this module and and be more explicit like this:

defmodule ExampleFive do
  def lookup_by_id(id, date \\ default_date()), do: IO.inspect("omitted")
  def lookup_by_id_and_place(id, place_id, date \\ default_date()), do: IO.inspect("omitted")
  def lookup_by_id_and_place_and_company(id, place_id, company_id, date \\ default_date()) when company_id in [:internal, :external], do: IO.inspect("omitted")
  def default_date(), do: DateTime.utc_now()
end

One important callout: defining @default like your examples will set the default to the date when the module is compiled ! DateTime.utc_now will run exactly once

Thank you for the example

A keyword list (lookup(id, place_id: ... company_id: ..., etc) ) would be more idomatic.

I like idiomatic. Does the order of the keyword matter? Are these two examples considered the same or different.

def lookup(id, place_id: "A", date: "date")
def lookup(id, date: "date", place_id: "A")

The keyword order won’t matter if you’re using fetch or get to retrieve them and you’re not using/allowing/expecting duplicate keys.

2 Likes

Hmmm not sure I understand.

I like idiomatic. Does the order of the keyword matter?

If you pattern match on the keyword list then the list has to be passed in the exact same order with the exact same number of items.

These two are equivalent:

def lookup(id, place_id: "A", date: "date")
def lookup(id, [{:place_id, "A"}, {:date, "date"}])

So as @cmo says you can use Keyword.fetch/2 in the function body instead.

You can pattern match on a map in any order though, also ignoring extra keys.

Otherwise you do not have to use default arguments, you can write equivalent of the generated functions by hand by providing all the clauses. But yeah if you want to accept nil then a keyword or map would be more suited.

Now, are you sure you will use all those different combinations? If not, then maybe using functions like lookup_by_id_and_place_and_company is more direct, indeed. Only write code that you will use.

2 Likes

Hmmm not sure I understand.

I have been doing some context switching between Elixir and Swift. And my brain is thinking in Swift too much when I wrote that.

In the Swift language you can write something like this:

struct Foo {
    static func lookup(id: String, date: Date = Date.now) {
        // ..
    }
    static func lookup(id: String, placeID: String, date: Date = Date.now) {
        //..
    }
    static func lookup(id: String, placeID: String, companyID: String, date: Date = Date.now) {
        // ..
    }
}

and then you have functions signatures like this

Foo.lookup(id: “A”)
Foo.lookup(id: “A”, placeID: “B”)
Foo.lookup(id: “A”, placeID: “B”, companyID: “C”)

WHEN passing a custom date

Foo.lookup(id: “A”, date: Date.tomorrow())
Foo.lookup(id: “A”, placeID: “B”, date: Date.tomorrow())
Foo.lookup(id: “A”, placeID: “B”, companyID: “C”, date: Date.tomorrow())

I am basically trying to replicate this Swift code in the Elixir idiomatic way.

Well you can pattern match the arguments and use guards

def lookup(id, date \\ default())
def lookup(id, %DateTime{} = date), do: …
def lookup(id, place_id) when is_binary(place_id), do: …
def lookup(id, place_id, %DateTime{} = date), do: …
def lookup(id, place_id, company_id) when is_binary(company_id), do: …
…

Not very clear to read but it lets you add the extra argument.

I would rather use a map for pattern matching on 1, 2 and 3 keys, but that would just be a more explicit version of your first tuple solution.

Can you really not pass the id and a keyword list like it was suggested?

2 Likes

Even though this thread was likely very educational for you I’d still suggest you go with this. Be explicit unless you really truly need that flexibility in your own code, which I’m reasonably certain you don’t (meaning you can write it in a way to use the explicitly named functions).

2 Likes

I agree with you not very clear to read but still an interesting approach with the guards.

Can you really not pass the id and a keyword list like it was suggested?

I’m sure I could, I just wanted to collect some feedback.

I agree with this feedback.

Updated Solution

I think I have a better understanding on how Keywords can be passed in as function arguments and the benefits. In my original post I was rigid in having my function arguments be in a specific order and named.

But the new solution I think is more idiomatic. I am much happier and learned quite a lot in this thread.

New Approach

defmodule Example do

  @doc """
  Does a lookup

  ## Example

    iex> Example.lookup("a")

    iex> Example.lookup("a", date: "20230131")

    iex> Example.lookup("a", date: "20230131", place_id: "b")

    iex> Example.lookup("a", date: "20230131", place_id: "b", direction_id: :internal)

  """

  def lookup(id, opts \\ []) do
    defaults = [date: today_string()]
    opts = Keywords.merge(defaults, opts)

    results = QuerySomeDB.get(id, opts[:date]) # some querying would happen
    do_lookup(results, opts)
  end

  defp do_lookup(results, opts) do
    place_id   = opts[:place_id]
    company_id = opts[:company_id]

    results = if place_id, do: filter_by_place_id(results, place_id), else: results
    results = if company_id, do: filter_by_company_id(results, place_id), else: results

    results
end

 defp filter_by_place_id(results, place_id) do
    # ... do some filtering and return the new results
    results
 end

 defp filter_by_company_id(results, company_id) when company_id in [:internal, :external] do
    # ... do some filtering and return the new results
    results
  end

 defp today_string() do
    #... returns a date string
 end

end

In this new version, you query all your database, filtering only on dates. It can be fine, it can be a problem.