Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In Elixir, why not use case statements instead of multiple function overloading?

Tags:

elixir

I'm learning Elixir and a little confused as to why we must branch using multiple definitions of the same function, instead of using the case statement. Here is an example from Elixir in Action, first edition page 81, for counting the lines in a file:

defmodule LinesCounter do
  def count(path) do
    File.read(path)
    |> lines_num
  end

  defp lines_num({:ok, contents}) do
    contents
    |> String.split("\n")
    |> length
  end

  defp lines_num({:error, _}), do: "error"
 end 

So we have two defp lines_num instances to handle the cases of :ok and :error. But doesn't the following do the same thing, arguably in a cleaner, and more concise way, and using only one function instead of three?

defmodule LinesCounterCase do
  def count(file) do
    case File.read(file) do
      {:ok, contents} -> contents |> String.split("\n") |> length
      {:error, _} -> "error"
    end
  end
end

Both work identically.

I don't want to learn incorrect idioms as I start out my journey on Elixir so clarifying the downsides of using the case statement in this way, is what I am looking for.

like image 952
Thomas Browne Avatar asked Mar 20 '16 21:03

Thomas Browne


2 Answers

In this particular instance, it may not make a difference which way you do it. There isn't anything that says you "must" use pattern matching function clauses.

The case statement version is more similar to how other languages would do this, so the author may have been introducing an Elixir specific concept in anticipation of making more use of it later.

I definitely prefer the multiple function clause version, but maybe that's because I've been looking at Erlang and Elixir code for a while and have gotten used to seeing it.

I asked on the Elixir Slack channel what reasons there were for choosing functions over case statements and the recommendation was to watch this video: https://www.youtube.com/watch?v=CQyt9Vlkbis

The main argument for using function clauses over a case statement is that you can give a name to the decision you're making. The example given in this question isn't as compelling on that point, but the video lays it out very well.

like image 81
CoderDennis Avatar answered Oct 18 '22 16:10

CoderDennis


The code from book is not very idiomatic and it tries to show multiple function clauses and pipes on example that is not the best.

Part1: Separation of concerns.

First thing is that general convention says that pipes should start with "raw" variable like this:

def count(path) do
  path
  |> File.read
  |> lines_num
end

Second thing is that this code really mixes responsibilities. Sometimes it is also good to thing about types returned by functions. If I saw, that lines_num returns integer or string, I would really scratch my head. Why lines_num should care about error while reading the file? The answer is: it shouldn't. It should take a string and return what it calculated:

defp lines_num(contents) do #skipping the tuple here
  contents
  |> String.split("\n")
  |> length
end

Now you have two options in your count function. You can either let it crash when there is a problem with file or handle the error. In this example there is only string "error" returned, so the most idiomatic way would be to skip it completely:

def count(path) do
  path
  |> File.read! #note the "!" it means it will return just content instead {:ok, content} or rise an error
  |> lines_num
  end
end

Elixir almost always provides func! version and it is exactly for that reason - to make it easier to pipe things.

If you want to handle the error, case statement is the best. Unix pipes also don't encourage branching.

def count(path) do
  case File.read(path) do
    {:ok, contents} -> lines_num(contents)
    {:error, reason} -> do_something_on_error(reason)
  end
end

Part2: Where multiple function clauses make sense?

There are two main cases where multiple function clauses are superior to case statements: recursion and polymorphism. There are some others, but those should be enough for a beginner.

Polymorphism

Suppose you want to make the lines_num more generic to also handle the list of chars representation:

defp lines_num(contents) when is_binary(contents) do
  ...
end
defp lines_num(contents) when is_list(contents) do
  contents
  |> :binary.list_to_bin #not the most efficient way!
  |> lines_num
end

The implementation might be different, but the end result will be the same: number of lines for different types: "foo \n bar" and 'foo \n bar'.

Recursion

def factorial(0), do: 0
def factorial(n), do: n * factorial(n-1)

def map([], _func), do: []
def map([head, tail], func), do: [func.(head), map(tail)]

(Warning: examples are not tail recursive) Using case for such functions would be much less readable/idiomatic.

Conclusion:

  1. Don't use function heads for branching logic unless you know what you are doing.
  2. If you have branching logic it is better to split pipes.
  3. Use function clauses for polymorphism and recursion.
like image 35
tkowal Avatar answered Oct 18 '22 18:10

tkowal