Why `throw/catch` is discouraged for control flow in favor of `with` special form?

Hi there :wave: !

This week at CodeBEAM I had a chance to meet an amazing crowd and participate in some interesting conversations. One of them was with more experienced with erlang folks discussing new maybe_expr in OTP-25. @max-au raised concerns about it which I initially didnā€™t get right.

Iā€™ve been happily writing elixir and using with for years by now, thinking thatā€™s the right way to handle cleanly the scenario of nested branching expressions with a single happy path. And for some reason I never questioned why throw/catch is reserved as some sort of a ā€œlast resortā€ for libs that donā€™t return :ok/:error tuples but throw in error case.[*]

Also as mentioned in EEP-0049#Elixir

Elixir has a slightly different semantic approach to error handling compared to Erlang. Exceptions are discouraged for control flow (while Erlang specifically uses throw for it), and the with macro is introduced

Example:

Here, is the synthetic example that hopefully will help to show the idea:
Say I want to put together a function that does multiple things with given argument:

defmodule Foo do
  def bar(num) do
    num
    |> double()
    |> subtract(num)
    |> divide(num)
    |> multiply(num)
  end

  defp double(num), do: 2 * num
  defp subtract(a, b), do: a - b
  defp divide(a, b), do: a / b
  defp multiply(a, b), do: a * b 
end

Often in real world some of these private functions are expected to return erroneous resultsā€¦ often we wrap the result in :ok and :error tuples and use with special form.

Here is the example of the same module, but where each private function might return either :ok or :error tuple and parent function uses with to focus on happy path and still be aware of possible erroneous results:

defmodule Foo do
  @spec bar(number) :: {:ok, number} | {:error, :not_number | :zero_division}
  def bar(num) do
    with {:ok, double_num} <- double(num),
         {:ok, subtracted_num} <- subtract(double_num, num),
         {:ok, divided_num} <- divide(subtracted_num, num) do
      multiply(divided_num, num)
    end
  end

  defp double(x) do
    if is_number(x),
      do: {:ok, 2 * x},
      else: {:error, :not_number}
  end

  defp subtract(a, b) do
    if is_number(a) and is_number(b),
      do: {:ok, a - b},
      else: {:error, :not_number}
  end

  defp divide(_a, 0), do: {:error, :zero_division}

  defp divide(a, b) do
    if is_number(a) and is_number(b),
      do: {:ok, a / b},
      else: {:error, :not_number}
  end

  defp multiply(a, b) do
    if is_number(a) and is_number(b),
      do: {:ok, a * b},
      else: {:error, :not_number}
  end
end

However, we could have achieved the same behavior without need to wrap everything into ā€œmonadicā€ :ok/:error tuples, just throw when something goes wrong and let parent catch it. As a bonus we can again use pipes (though, thatā€™s not the priority point at this moment).

defmodule Foo do
  
  @spec bar(number) :: number | :not_number | :zero_division  
  def bar(num) do
    num
    |> double()
    |> subtract(num)
    |> divide(num)
    |> multiply(num)
  catch
    error -> error
  end

  defp double(x) do
    if is_number(x),
      do: 2 * x,
      else: throw(:not_number)
  end

  defp subtract(a, b) do
    if is_number(a) and is_number(b),
      do: a - b,
      else: throw(:not_number)
  end

  defp divide(_a, 0), do: throw(:zero_division)

  defp divide(a, b) do
    if is_number(a) and is_number(b),
      do: a / b,
      else: throw(:not_number)
  end

  defp multiply(a, b) do
    if is_number(a) and is_number(b),
      do: a * b,
      else: throw(:not_number)
  end
end

Apparently, this style is used in Erlang quite often.

Now Iā€™m really puzzled, why it is discouraged in elixir?


[*] elixir-lang.org | Getting Started / Try, catch and rescue / Throws

Those situations are quite uncommon in practice except when interfacing with libraries that do not provide a proper API

7 Likes

Now Iā€™m really puzzled, why it is discouraged in elixir?

I am not sure about Elixir, but personally I can find these reasons where with is preferred to catch

  1. Dialyzer. Consider this code
defmodule Throwing do

  def f(x, y, z) do
    divide(divide(x, y), z)
  end

  defp divide(_, 0), do: throw :oops
  defp divide(x, y), do: x / y

end

defmodule With do

  def f(x, y, z) do
    with(
      {:ok, xy} <- divide(x, y),
      {:ok, xyz} <- divide(xy, z)
    ) do
      xyz
    else
      :this_clause_doesnt_handle_anything -> :error
    end
  end

  defp divide(_, 0), do: :error
  defp divide(x, y), do: {:ok, x / y}

end

Dialyzer will only warn us about with, but it wonā€™t warn about unhandled throw :oops

lib/sample.ex:1:pattern_match
The pattern can never match the type.

Pattern:
:this_clause_doesnt_handle_anything

Type:
:error

And the problem here is not just a dialyzer problem. Even a programmer wonā€™t be able to figure out whether the thown value is handled somewhere up the callstack or is it just a develop who forgot to write a proper catch.

  1. Debugging
    Letā€™s assume, we donā€™t use dialyzer and we forgot to write catch clause to handle throw. The process will just fail, and all weā€™ll have is stacktrace of this throw. Now, with with weā€™d also have a place where we failed the matching, which will provide us an information on what was expected against what was received in match.

  2. Performance
    throw collects stacktrace information when it traverses the stack in search of the matching catch clause. with doesnā€™t do that

See the benchmark:

defmodule Throwing do

  def f(x1, x2, x3, x4, x5) do
    {:ok, divide(divide(divide(divide(x1, x2), x3), x4), x5)}
  catch
    :oops -> :error
  end

  defp divide(_, 0), do: throw :oops
  defp divide(x, y), do: x / y

end

defmodule With do

  def f(x1, x2, x3, x4, x5) do
    with(
      {:ok, r} <- divide(x1, x2),
      {:ok, r} <- divide(r, x3),
      {:ok, r} <- divide(r, x4),
      {:ok, r} <- divide(r, x5)
    ) do
      {:ok, r}
    else
      :error -> :error
    end
  end

  defp divide(_, 0), do: :error
  defp divide(x, y), do: {:ok, x / y}

end

Benchee.run(%{
  "throw" => fn ->
    :error = Throwing.f(1, 2, 3, 4, 0)
  end,
  "with" => fn ->
    :error = With.f(1, 2, 3, 4, 0)
  end
}, [
  warmup: 1,
  time: 5,
  memory_time: 2
])

"""
Operating System: Linux
CPU Information: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
Number of Available Cores: 8
Available memory: 15.35 GB
Elixir 1.13.4
Erlang 24.3.4.5

Benchmark suite executing with the following configuration:
warmup: 1 s
time: 5 s
memory time: 2 s
parallel: 1
inputs: none specified
Estimated total run time: 16 s

Benchmarking throw...
Benchmarking with...

Name            ips        average  deviation         median         99th %
with        10.11 M       98.90 ns Ā±39076.74%          29 ns          90 ns
throw        8.27 M      120.93 ns Ā±24530.51%          61 ns         170 ns

Comparison:
with        10.11 M
throw        8.27 M - 1.22x slower +22.02 ns

Memory usage statistics:

Name     Memory usage
with            120 B
throw           120 B - 1.00x memory usage +0 B
"""
7 Likes

Most of the exception workflows in the programming languages I worked with are frowned upon because they pass the responsibility for error handling in their own code to upstream callers. Which would be fine ā€“ if it was more visible.

(Java has checked exceptions that can be appended to the function signatures e.g. public void do_stuff_with_file(String path) throws IOException which helps but Iā€™ve noticed many former colleagues have been outright ignoring those.)

So #1 reason for me is: code must be obvious through and through, 100%, including any and all errors it can return.

Code must not have surprising effects. Code should be ā€œwhat you see is what you getā€.

6 Likes

Actually throw isnā€™t for exceptions, it is exactly meant as an early return mechanism.

5 Likes

Bubble-wrapping (creating tuples of {ok, Result} and {error, Reason} is actually less performant compared to a single try-catch instance, because a tuple needs to be created and disposed of every single function call. It may get quite expensive when there is some wrapping-unwrapping, say, in a list comprehension.

Bubble-wrapping also tends to lose call stack information (unless call stack is re-created as nested tuples, like {error, {nested_error, {even_more_nested_error, OriginalReason}}}).

After all, tuple-wrapping just does not look beauful as it requires a barrage of nested case statements. Itā€™s less pronounced in Elixir with |>, but in Erlang it does not look nice at all.

3 Likes

less performant compared to a single try-catch instance

No, itā€™s not. See the benchmark

defmodule ThrowingDivide do
  def divide(_, 0), do: throw :oops
  def divide(x, y), do: x / y
end

defmodule Throwing do
  import ThrowingDivide
  def f(x1, x2) do
    divide(x1, x2)
  catch
    :oops -> :error
  end
end

defmodule EitherDivide do
  def divide(_, 0), do: :error
  def divide(x, y), do: {:ok, x / y}
end

defmodule With do
  import EitherDivide

  def f(x1, x2) do
    with(
      {:ok, r} <- divide(x1, x2)
    ) do
      {:ok, r}
    else
      :error -> :error
    end
  end
end

Benchee.run(%{
  "throw" => fn ->
    :error = Throwing.f(1, 0)
  end,
  "with" => fn ->
    :error = With.f(1, 0)
  end
}, [
  warmup: 1,
  time: 2,
  memory_time: 2
])

"""
Operating System: Linux
CPU Information: 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz
Number of Available Cores: 8
Available memory: 15.35 GB
Elixir 1.13.4
Erlang 24.3.4.5

Benchmark suite executing with the following configuration:
warmup: 1 s
time: 2 s
memory time: 2 s
parallel: 1
inputs: none specified
Estimated total run time: 10 s

Benchmarking throw...
Benchmarking with...

Name            ips        average  deviation         median         99th %
with       289.31 M        3.46 ns Ā±34806.55%           0 ns          34 ns
throw       14.36 M       69.64 ns  Ā±7412.63%          60 ns         106 ns

Comparison:
with       289.31 M
throw       14.36 M - 20.15x slower +66.18 ns

Memory usage statistics:

Name     Memory usage
with              0 B
throw           136 B - āˆž x memory usage +136 B

**All measurements for memory usage were the same**
"""

Youā€™re not creating error tuples in that benchmark.

That is also quite poor use of throw/1 as throws IMHO never should cross the module boundary. throw/1 is not for exceptions.

2 Likes

Yeah, I just seprated it into other module to avoid inlining optimizations

The details donā€™t really matter to me in this regard, I avoid implicit early returns like the plague. We have that in non-FP languages already. For an FP language Iā€™d like to make full use of explicit workflow returning of values, be they a success or an error.

2 Likes

Yes, in general I agree, just sometimes it is way easier to simplify the flow with early return. It mostly happens in case of for example params validation that can go quite deep in the call tree and you want to quickly jump to the top. Rare use case, but handy one when really needed.

4 Likes

As an Elixir learning noob this also confuses me. ā€˜throw/catchā€™ = bad is one of the first things that stuck out. So what should I use? Seems like arguments for both.

Case in point nimble_options/nimble_options.ex at 133f652b3e6c0562aa88dbfe2870a488dc4414b1 Ā· dashbitco/nimble_options Ā· GitHub

Also throw != raise

2 Likes

This is a bad example, because this can be translated into case without losing readability or performance or anything

I think that throw is meant to ā€œbreakā€ for comprehensionā€¦ If so, then not to case but could be translated to Enum.reduce_whileā€¦ just like the other validate_type does in the same module nimble_options/nimble_options.ex at 133f652b3e6c0562aa88dbfe2870a488dc4414b1 Ā· dashbitco/nimble_options Ā· GitHub

1 Like

Unfortunately, you cannot early break for comprehensions with just case.

2 Likes

TILā€¦ and time for me to abuse tf outta this in real world production code! :joy: :beers:

I kid, I kid. But there definitely have been times where I miss having a return statement.

As @RudManusachi points out, itā€™s to break from the comprehension and the alternative is Enum.reduce_while, which can be rather noisy.

Another case where throw is useful is when you have a big legacy codebase that is doing some crazy stuff and you need to fix the validations asap. Using throw in the innermost function in the call stack and catching at the right place is quicker than doing a full blown refactor when the issue is critical and time sensitive. I had to do that only once in a long time.

1 Like

Sorry, I meant to say ā€œprivate recursive functionā€ instead of ā€œcaseā€. It was 3am for me and I am insomniac these days :frowning:

3 Likes

Unfortunately, writing such function for Stream is impossible in current Elixir without using non-public Stream API