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.
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.
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.
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
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.
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'
.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With