RudManusachi
Why `throw/catch` is discouraged for control flow in favor of `with` special form?
Hi there
!
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
throwfor it), and thewithmacro 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
Most Liked
hst337
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
- 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.
-
Debugging
Let’s assume, we don’t use dialyzer and we forgot to writecatchclause to handle throw. The process will just fail, and all we’ll have is stacktrace of thisthrow. Now, withwithwe’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. -
Performance
throwcollects stacktrace information when it traverses the stack in search of the matchingcatchclause.withdoesn’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
"""
dimitarvp
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”.
hauleth
Actually throw isn’t for exceptions, it is exactly meant as an early return mechanism.
Popular in Discussions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









