In this article we’ll try to battle different Elixir control structures in order to understand their efficiency and applicability in our projects.
TL;DR:
- You can use
try
withelse
- instead ofcase
literally (only literally!);- Never use
with
instead ofcase
- only instead ofcase
chains;- Use
try
if you need it - forcatch
cases;- Forget about
throw
andcatch
in your code - they are really slow;
Do you want to know, what literally means? Welcome under cut!
The field of study
Today we’ll try to observe next (very common) situation:
Your code performs some data conversions and calculations. The result should be pattern-matched in order to continue calculations.
We can do that in Elixir in multiple ways:
- The most obvious (at lest it seems to be) -
case
:case do_something(data) do pattern1 -> do_continue1(pattern1) pattern2 -> do_continue2(pattern2) ... _ -> fallback() end
- What about
with
statement:with pattern1 <- do_something(data) do do_continue1(pattern1) else pattern2 -> do_continue2(pattern2) ... _ -> fallback() end
- We can do it with
try
andelse
:try do do_something(data) else pattern1 -> do_continue1(pattern1) pattern2 -> do_continue2(pattern2) ... _ -> fallback() end
- We can also do it using
throw
andcatch
:try do throw(do_something(data)) catch pattern1 -> do_continue1(pattern1) pattern2 -> do_continue2(pattern2) ... _ -> fallback() end
Other constructions don’t match the condition, because:
if
andcond
don’t return value for pattern-matchingraise
is not used to return good results, but we’ll still look at it in this article.
While we have at least four different options to do the same - it will be good idea to analyze them and get the final answer:
What should I use in my code?
Building the test cases
Let’s build some simple examples (a.k.a test cases) to see, how it works.
# Firstly, defining workers
def dumb_worker(number) when rem(number, 2) == 0, do: {:error, "odds are bad!"}
def dumb_worker(number), do: {:ok, number}
def throw_worker(number) when rem(number, 2) == 0, do: throw({:error, "odds are bad!"})
def throw_worker(number), do: throw({:ok, number})
# Now, wrapping all the test options
def to_case(number) do
case dumb_worker(number) do
{:ok, number} -> number
{:error, reason} -> reason
end
end
def to_with(number) do
with {:ok, number} <- dumb_worker(number) do
number
else
{:error, reason} -> reason
end
end
def to_try(number) do
try do
dumb_worker(number)
else
{:ok, number} -> number
{:error, reason} -> reason
end
end
def to_throw(number) do
try do
throw_worker(number)
catch
{:ok, number} -> number
{:error, reason} -> reason
end
end
This part seems to be very straightforward, but we’ll see how we can sweeten this code in next sections.
Breaking throw the MACROS
As we know, Elixir uses macros extensively, especially in control flow. Basically, if
is a syntax sugar for case
.
While with
is specified inside Elixir’s compiler, it still should be a syntax sugar for something, because with
statement is not defined
in Erlang.
So, we need to◊k look at these functions from another angle: from Erlang code representation.
In order to do this, let’s follow this perfect article and disassemble our compiled code to lurk inside.
to_case(_number@1) ->
case dumb_worker(_number@1) of
{ok, _number@2} -> _number@2;
{error, _reason@1} -> _reason@1
end.
to_with(_number@1) ->
case dumb_worker(_number@1) of
{ok, _number@2} -> _number@2;
__@1 ->
case __@1 of
{error, _reason@1} -> _reason@1;
__@2 -> erlang:error({with_clause, __@2})
end
end.
to_throw(_number@1) ->
try throw_worker(_number@1) catch
throw:{ok, _number@2} -> _number@2;
throw:{error, _reason@1} -> _reason@1
end.
to_try(_number@1) ->
try dumb_worker(_number@1) of
{ok, _number@2} -> _number@2;
{error, _reason@1} -> _reason@1
end.
Even if you’ve never worked with Erlang code, this chunk should be extremely straightforward, because it has only minimum differences from Elixir code.
case
, try->else
and throw->catch
even dont change from Elixir to Erlang.
Bu, as we expected, with
statement is a syntax sugar for case
statement, and it’s turned into case
chain
inside Elixir’s compiler.
Also, we can note that with
creates two case
statements, while case
creates one.
This probably can lead to performance problem, but we need to measure it.
Also notable, that case .. of
and try .. of
constructions are so much the same in Erlang:
the difference between to_try
and to_case
- only one word.
Why Try can be preferred over Case?
Well, this question obviously can appear, because variant with try -> else
seems to have more lines of code and unused words:
two end
instead of one, useless line try do
, which has 0 business value for the code.
The answer is probably unexpected, but bright:
We can omit
try
statement if it wraps the function body!
It’s not so good documented in Elixir guidelines, but we can compile the information from Try, Catch and Rescue guide:
Sometimes you may want to wrap the entire body of a function in a
try
construct, often to guarantee some code will be executed afterwards. In such cases, Elixir allows you to omit thetry
line.
Elixir will automatically wrap the function body in a
try
whenever one ofafter
,rescue
orcatch
is specified.
Fairly saying, the last statement is not full. Elixir will automatically wrap the body also when else
is specified.
While this is some kind of a taste preference, you still can use this sugar in your code!
Let’s rewrite our examples following this rule:
def to_try(number) do
dumb_worker(number)
else
{:ok, number} -> number
{:error, reason} -> reason
end
def to_throw(number) do
throw_worker(number)
catch
{:ok, number} -> number
{:error, reason} -> reason
end
While this is valid Elixir code, and after disassembling into Erlang nothing has changed, we still need to see the performance difference between different approaches.
Performance
I’ve using benchee for benchmarking purposes and put all the previous examples code inside this repo, so you can check the code, run the benchmarks and tests by yourself.
Here I am bringing the table with results:
small | middle | big | |
---|---|---|---|
case | 1.04x | x | 1.04x |
with | 1.04x | 1.04x | 1.01x |
try | x | 1.03x | x |
throw | 1.94 | 2.07x | 2.00x |
From the table we see, that try -> else
, with
and case
have almost the same performance.
Here it seems, that one extra
case
insidewith
after-compilation code do nothing with performance.
throw
brings us 2 times performance loss, that is definitely not acceptable for our projects.
Raise
While raise
is not working so much that same as we defined at the beginning of the article,
here it’s probably a good place to test it’s performance too - just to compare with all other mechanisms of error handling.
Let’s add example function:
def raise_worker(number) when rem(number, 2) == 0, do: raise "odds are bad!"
def raise_worker(number), do: number
def to_raise(number) do
raise_worker(number)
rescue
e -> e
end
And see it’s disassemble:
to_raise(_number@1) ->
try raise_worker(_number@1) catch
error:__@2:___STACKTRACE__@1 ->
_e@1 = 'Elixir.Exception':normalize(error, __@2,
case __@2 of
__@3
when erlang:tuple_size(__@3)
== 2
andalso
erlang:element(1,
__@3)
== badkey;
__@3 == undef;
__@3 ==
function_clause ->
___STACKTRACE__@1;
_ -> []
end),
_e@1
end.
As you see this code is significantly bigger the all previous implementations. It collects __STACKTRACE__
, and even from
this view on the code can be expected to work very slow.
But, let’s benchmark it across all previous cases.
Resulting table looks something like this:
small | middle | big | |
---|---|---|---|
case | 1.04x | x | 1.04x |
with | 1.04x | 1.04x | 1.01x |
try | x | 1.03x | x |
throw | 1.94x | 2.07x | 2.00x |
raise | 14.9x | 12.8x | 11.5x |
As you see 10 to 15 TIMES slower than everything - it’s incredible performance loss. Simply forget about exceptions
in your performant code.
Afterwords
You can follow the statements in TL;DR section from the beginning of the article in order to write great and performant code. And at least now you really see and know why different realizations influence on the code.
Feel free to play with benchmarks and code - you can find it here.
Special thanks to @pro.elixir Telegram channel for help and discussion about this theme.