Try vs Case: The last battle

  • Dmitry Rubinstein
  • 23 February 2019
  • Comments

Versus

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:

  1. You can use try with else - instead of case literally (only literally!);
  2. Never use with instead of case - only instead of case chains;
  3. Use try if you need it - for catch cases;
  4. Forget about throw and catch 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:

  1. 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
    
  2. What about with statement:
     with pattern1 <- do_something(data) do
       do_continue1(pattern1)
     else
       pattern2 -> do_continue2(pattern2)
       ...
       _ -> fallback()
     end
    
  3. We can do it with try and else:
     try do
       do_something(data)
     else
       pattern1 -> do_continue1(pattern1)
       pattern2 -> do_continue2(pattern2)
       ...
       _ -> fallback()
     end
    
  4. We can also do it using throw and catch:
     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:

  1. if and cond don’t return value for pattern-matching
  2. raise 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 the try line.

Elixir will automatically wrap the function body in a try whenever one of after, rescue or catch 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 inside with 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.

Dmitry Rubinstein

Elixir Developer, Architect and Evangelist. Has more then 3 years in Elixir production development, and half a dozen Hex packages under maintaining