Raise allocates a map so yeah, but Iām more comparing to how it would compare to the with, hmm, letās do a super quick but probably incorrect benchmark (specifically built the raise version to use hardcodeable exception values so it shouldnāt cost anything extra for the allocation as it will reference the constant pool):
defmodule WithThrowBench do
def classifiers(), do: nil
def time(_), do: 2
def inputs(_cla),
do: %{
"" => 1..100,
}
def setup(_cla), do: nil
def teardown(_cla, _setup), do: nil
def validate_throw(foo) do
is_integer(foo) || throw :not_integer
rem(foo, 2) == 0 || throw :not_even
foo > 50 || throw :too_big
true
catch
:not_integer -> 1
:not_even -> 2
:too_big -> 3
end
defmodule NotIntegerError do
defexception message: "not an integer"
end
defmodule NotEvenError do
defexception message: "not an even number"
end
defmodule TooBigError do
defexception message: "number is too big"
end
def validate_raise(foo) do
is_integer(foo) || raise %NotIntegerError{}
rem(foo, 2) == 0 || raise %NotEvenError{}
foo > 50 || raise %TooBigError{}
true
rescue
NotIntegerError -> 1
NotEvenError -> 2
TooBigError -> 3
end
def validate_with(foo) do
with true <- is_integer(foo) || :not_integer,
true <- rem(foo, 2) == 0 || :not_even,
true <- foo > 50 || :too_big do
true
else
:not_integer -> 1
:not_even -> 2
:too_big -> 3
end
end
def actions(_cla, _setup), do: %{
"with" => fn vs -> Enum.map(vs, &validate_with/1) end,
"throw" => fn vs -> Enum.map(vs, &validate_throw/1) end,
"raise" => fn vs -> Enum.map(vs, &validate_raise/1) end,
}
end
And results:
⯠mix bench with_throw
Operating System: Linux
CPU Information: AMD Phenom(tm) II X6 1090T Processor
Number of Available Cores: 6
Available memory: 15.63 GB
Elixir 1.10.0
Erlang 22.2.5
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 2 s
memory time: 2 s
parallel: 1
inputs:
Estimated total run time: 18 s
Benchmarking raise with input ...
Benchmarking throw with input ...
Benchmarking with with input ...
##### With input #####
Name ips average deviation median 99th %
with 92.77 K 10.78 μs ±21.07% 10 μs 16 μs
throw 53.77 K 18.60 μs ±80.95% 18 μs 25 μs
raise 41.96 K 23.83 μs ±70.59% 23 μs 34 μs
Comparison:
with 92.77 K
throw 53.77 K - 1.73x slower
raise 41.96 K - 2.21x slower
Memory usage statistics:
Name Memory usage
with 1.81 KB
throw 11.63 KB - 6.42x memory usage
raise 11.63 KB - 6.42x memory usage
**All measurements for memory usage were the same**
⯠mix bench with_throw
Operating System: Linux
CPU Information: AMD Phenom(tm) II X6 1090T Processor
Number of Available Cores: 6
Available memory: 15.63 GB
Elixir 1.10.0
Erlang 22.2.5
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 2 s
memory time: 2 s
parallel: 1
inputs: invalid-0, invalid-1, invalid-2, valid
Estimated total run time: 1.20 min
Benchmarking raise with input invalid-0...
Benchmarking raise with input invalid-1...
Benchmarking raise with input invalid-2...
Benchmarking raise with input valid...
Benchmarking throw with input invalid-0...
Benchmarking throw with input invalid-1...
Benchmarking throw with input invalid-2...
Benchmarking throw with input valid...
Benchmarking with with input invalid-0...
Benchmarking with with input invalid-1...
Benchmarking with with input invalid-2...
Benchmarking with with input valid...
##### With input invalid-0 #####
Name ips average deviation median 99th %
with 857.40 K 1.17 μs ±485.73% 1.10 μs 2.10 μs
throw 159.86 K 6.26 μs ±191.15% 6 μs 11 μs
raise 123.96 K 8.07 μs ±299.72% 8 μs 14 μs
Comparison:
with 857.40 K
throw 159.86 K - 5.36x slower
raise 123.96 K - 6.92x slower
Memory usage statistics:
Name Memory usage
with 0.90 KB
throw 3.71 KB - 4.13x memory usage
raise 3.71 KB - 4.13x memory usage
**All measurements for memory usage were the same**
##### With input invalid-1 #####
Name ips average deviation median 99th %
with 536.45 K 1.86 μs ±95.17% 1.80 μs 2.70 μs
throw 143.36 K 6.98 μs ±145.59% 7 μs 10 μs
raise 117.37 K 8.52 μs ±210.54% 8 μs 14 μs
Comparison:
with 536.45 K
throw 143.36 K - 3.74x slower
raise 117.37 K - 4.57x slower
Memory usage statistics:
Name Memory usage
with 0.87 KB
throw 3.58 KB - 4.13x memory usage
raise 3.58 KB - 4.13x memory usage
**All measurements for memory usage were the same**
##### With input invalid-2 #####
Name ips average deviation median 99th %
with 307.61 K 3.25 μs ±800.67% 3 μs 5 μs
throw 219.04 K 4.57 μs ±363.08% 4 μs 8 μs
raise 183.71 K 5.44 μs ±289.52% 5 μs 12 μs
Comparison:
with 307.61 K
throw 219.04 K - 1.40x slower
raise 183.71 K - 1.67x slower
Memory usage statistics:
Name Memory usage
with 0.95 KB
throw 2.26 KB - 2.37x memory usage
raise 2.26 KB - 2.37x memory usage
**All measurements for memory usage were the same**
##### With input valid #####
Name ips average deviation median 99th %
with 424.42 K 2.36 μs ±56.56% 2.30 μs 3.10 μs
throw 136.27 K 7.34 μs ±147.99% 7 μs 11 μs
raise 100.66 K 9.93 μs ±75.73% 10 μs 15 μs
Comparison:
with 424.42 K
throw 136.27 K - 3.11x slower
raise 100.66 K - 4.22x slower
Memory usage statistics:
Name Memory usage
with 0.90 KB
throw 3.71 KB - 4.13x memory usage
raise 3.71 KB - 4.13x memory usage
**All measurements for memory usage were the same**
Iām guessing because it has to encode the stack frame or so. Iād have figured the valid/happy path to be about the same speed on them all, guess that shows how expensive stack frames are.
In our projects weāve basically replaced all usages of with with happy_with, wich IMO is a strictly superior form of with (in fact, if you want, you can just use it as a standard with with less anacronystic syntax)
happy_with do
@integer true <- is_integer(foo)
@even true <- rem(foo,2) == 0
@large_enough true <- foo > 50
# do something
else
{:integer, _} -> {:error, "foo must be an integer"}
{:even, _} -> {:error, "foo must be even"}
{:large_enough, _} -> {:error, "foo must be larger than 50"}
end
withl label1: result1 <- expr1(),
label2: result2 <- expr2() do
do_something(result1, result2)
else
label1: error -> handle_error1(error)
label2: error -> handle_error2(error) # note that result1 is accessible here in case you need it
end
Iām not trying to be pedantic, but your example does seem like āvalidationā ā a lot of the standard Ecto.Changeset validations are āsimple boolean check[s]ā. Your checks may or may not fit āchangeset validationsā even better depending on how or whether your simple checks āaccumulateā (or not), i.e. would it be better for callers to know all of the checks that have failed or do you āreally wantā to āfailā at a specific check (performed in a specific order) or would it be better to perform all of the checks and āreportā all of those that have failed?
In your original example, Iād guess that itād be better to report all of the failed checks, e.g. so a user (or caller) wouldnāt have to repeatedly call whatever code contains your with until all checks pass.
you can ignore the example in its wholeness. itās not real code, itās something I wrote down solely when writing the initial post.
the sole purpose was to illustrate some case where among other various statements (fetch data, transform it, count it,ā¦), I need to make a ultra simple boolean check like true <- foo.count < 30 || :capacity_full and deal with that roadblock in my happy path. so my real code is not just a series of 3 boolean checks that should be returned together.
sorry if the example was misleading in this respect. but it seems the discussion covers whole range of approaches, from simple ones like the one I choose for now, up to more generic approaches for more complex use cases.
everyone: one thing that surprised me was the recommendation that else block should be ideally empty or have one ācapture allā error. on the other hand, most of the examples here do use the else block to hadle various types of errors separately. what gives?
Ah true true! cond is for booleans, how did that slip my mind, especially when I was staring at such a similar usage of it in my code just minutes ago⦠>.>
Hmmm ā itās always hard to think about an example that doesnāt reflect your question!
I think the ālogicā still applies, at least conditionally: if there are multiple āchecksā performed, the first question one should ask is whether performing all of the checks every time { makes sense / is friendliest }; if āyesā, then ideally youād return something like an (Ecto) changeset.
I think the recommendation to keep the else block empty is because any āfailureā in a with expression will return the non-matching value. If that value is something like {:error, whatever} then any ācallerā can itself match on that. The with docs cover that pretty well.
I think the reason why all of the examples on this post donāt do that is that we donāt know anything about your callers, and your question, and its original example, explicitly asked about ācatchingā those errors directly. Moving āerror handlingā from the else clause of a with to, e.g. a case in a caller is ātrivialā (and you should definitely do it if it makes more sense for you and your code).