Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Elixir: How to deal with optional / default parameters in functions and nil values?

Tags:

elixir

I have a general question about default parameters and nil values. Suppose I have two functions. One calls the other (which is a helper function). Both have an optional parameter.

The helper function just joins a list to a string with a joiner. The joiner is passed to the first function inside an opts keyword list. The passing of the joiner is optional, it defaults to "AND"

defmodule ParamTest do
  def func_1(list, opts \\ []) do
    helper(list, opts[:joiner])
    # Do something else with the result
  end

  defp helper(list, joiner \\ "AND") do
    Enum.join(list, " #{joiner} ")
  end
end

# Example 1
["el 1", "el 2"] 
|> ParamTest.func_1(joiner: "AND")
# Result "el 1 AND el 2"

# Example 2
["el 1", "el 2"] 
|> ParamTest.func_1
# Result: "el 1  el 2"
# But it should be also "el 1 AND el 2"

The problem is: opts[:joiner] will be nil in the second example. But it is still present so the default value will not be used.

One possible solution would be to use case:

defmodule ParamTest do
  def func_1(list, opts \\ []) do
    case is_nil(opts[:joiner]) do
      true -> helper(list)
      false -> helper(list, opts[:joiner])
    end
    # Do something else with the result
  end

  defp helper(list, joiner \\ "AND") do
    Enum.join(list, " #{joiner} ")
  end
end

Another way would be to use two function definitions for helper and use pattern matching:

defmodule ParamTest do
  def func_1(list, opts \\ []) do
    case is_nil(opts[:joiner]) do
      true -> helper(list)
      false -> helper(list, opts[:joiner])
    end
  end

  defp helper(list, nil) do
    Enum.join(list, " AND ")
  end

  defp helper(list, joiner \\ "AND") do
    Enum.join(list, " #{joiner} ")
  end
end

But I have the feeling that this is not very elegant and could get messy in more complex situations.

What is a better solution for this scenario?

like image 957
Ole Spaarmann Avatar asked Mar 31 '16 09:03

Ole Spaarmann


2 Answers

The best solution would be to make joiner mandatory in helper and provide default options in func_1.

def func_1(list, opts \\ [joiner: "AND"]) do
  helper(list, opts[:joiner])
  ...
end
defp helper(list, joiner) do
  ...
end

Always try to separate your concerns. helper is not part of your public API, so you can always pass all the options. Let it just do its work and not worry about defaults.

func_1 is your public API and it should worry about defaults. You want to specify "AND" joiner by default, so do it instead of defaulting to empty option list. When developers read your code, they won't need to go deeper to check where does the "AND" come from and can easily figure out, that they can pass this option without reading docs or even function body.

It is usually a good idea to have defaults only for convenience at top level functions (the API) and just pass explicitly everything down. Otherwise, you would have to check at each level, if the option was passed like you did in your example with case. This is error prone.

like image 136
tkowal Avatar answered Nov 05 '22 16:11

tkowal


No better solution that what you already have in my opinion. Personally, I would ask myself the following questions:

  • am I sure I need a default argument for the helper/2 private function? I'm not confident about this, but I feel like default \\ args to private function may be some kind of code smell.
  • where do I want to handle the complexity if I go with a \\ default argument? :)

If I had to choose, in this particular case I'd probably go with calling helper/1 and helper/2 separately, based on the presence of the :joiner option:

defmodule ParamTest do
  def func_1(list, opts \\ []) do
    if joiner = opts[:joiner] do
      helper(list, joiner)
    else
      helper(list)
    end
  end

  defp helper(list, joiner \\ "AND") do
    Enum.join(list, " #{joiner} ")
  end
end

However, as I stated above, since helper/2 is a private function it may make sense (depending on your use case, this one is too small for us to make a more thoughtful decision :P) to move the optional joiner completely to the boundary of the "system", i.e., just to func_1/2 by using a default value for the option:

defmodule ParamTest do
  def func_1(list, opts \\ []) do
    helper(list, opts[:joiner] || "AND")
  end

  defp helper(list, joiner) do
    Enum.join(list, " #{joiner} ")
  end
end

Again, this may not scale well in your use case but I feel like it's the best we can do with the information we have from the question :).

like image 20
whatyouhide Avatar answered Nov 05 '22 17:11

whatyouhide