Proper way of handling simple booleans in `with` statement

My preferred approach for this is to have a generic validate helper which converts boolean into :ok | {:error, reason}:

def validate(true, _reason), do: :ok
def validate(false, reason), do: {:error, reason}

And now with can be expressed as:

with :ok <- validate(is_integer(foo), :not_an_integer),
     :ok <- validate(...),
     ...,
     do: ...

I briefly discussed this in this blog post.

24 Likes

Cursed answer:

def validate(foo) do
  is_integer(foo) or throw :not_integer
  rem(foo,2) == 0 or throw :not_even
  foo > 50 or throw :too_big
catch
  :not_integer -> ...
  :not_even -> ...
  :too_big -> ...
end
10 Likes

Yep, composability. I like this one the most.

4 Likes

Get this Java sorcery away from my sight!

1 Like

Oh I like this pattern for simple booleans, itā€™s so simple, even more so than what I generically do! I love it!

This is what I usually do yeah. Itā€™s nice because it works on any return type, not just booleans.

Yeah I have a few of these as well for more purpose specific cases.

Lol, wonder how much overhead that gives, hmmā€¦

2 Likes

Probably not much. If Iā€™m not mistaken, throw is not as bad as raise

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**
3 Likes

Letā€™s see how much each type actually costs, so changed the inputs to this:

  def inputs(_cla),
    do: %{
      "valid" => Enum.filter(0..50, &(rem(&1, 2) == 0)),
      "invalid-0" => Enum.map(0..25, fn _ -> "not integer" end),
      "invalid-1" => Enum.filter(0..50, &(rem(&1, 2) == 1)),
      "invalid-2" => 51..76,
    }

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: 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**
5 Likes

Iā€™m surprised throw is so bad in the happy path! Guess I learned something today!! Thanks

5 Likes

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.

5 Likes

catching and rescueing is really expensive. In Elixir core It is absolutely discouraged unless really necessary

3 Likes

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
4 Likes

We too :wink:

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
3 Likes

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.

1 Like

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? :slight_smile:

3 Likes

If youā€™re just working with booleans, couldnā€™t you use cond?

cond do
  not is_integer(foo) ->
    :not_integer

  rem(foo, 2) != 0 ->
    :not_even

  foo <= 50 ->
    :too_small

  true ->
    :ok
end
9 Likes

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ā€¦ >.>

1 Like

Hmmm ā€“ itā€™s always hard to think about an example that doesnā€™t reflect your question! :slight_smile:

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).

1 Like

Delete this :smiley: (jk)

cond gets my vote too!

6 Likes

Hey, this seems to solve this exact problem:

https://hexdocs.pm/bunch/Bunch.html#withl/1

3 Likes