When to use: Case vs if

I’m curious what the consensus (if there is one) on case vs if statements for conditionals is. Specifically, in cases where you are only dealing with boolean options for example not is_nil(value).

Personally, when I write Elixir I find that I reach for case way, way more than if even when if would work fine. I think I do it because I don’t always know all the potential conditionals when I start writing the function, I like the sort of open ended nature of case as one can add other cases over time. I also just find something visually unappealing about if statements compared to case when I read Elixir code.

I’m not totally against if I use it from time to time when I know that there will only ever be two conditions or when I can do a single line: if foo, do: func(foo), else: func(bar).

Is this an anti-pattern is there a good reason why I should start using if more regularly?

3 Likes

I think that if works great for boolean conditions and it’s often used in the Elixir’s codebase. I’d say that case with just true/false feels weird.

9 Likes

I pretty much feel the same way you do.

I think if/2 is a good fit when you have:

  • no other nested ifs under it,
  • when you check for truthy/false values.

Other than that I prefer to use case and cond. cond is particularly useful for replacing nested ifs or if/elseifs.

5 Likes

Thanks for the responses. I was worried I was overusing it from a style perspective but this all makes sense.

I experienced this in a Credo report today:

Refactoring opportunities
┃ [F] → Cond statements should contain at least two conditions besides `true`,
┃           consider using `if`  instead.

Not convinced by this rules :confused:

2 Likes

I was doing LeetCode and was solving a problem by reducing an enumerable, where the reducer does different things to the accumulator based on whether a condition is true or false.

I didn’t think much about the difference between if-else and case. So I did this:

case MapSet.member?(acc, item) do
  true -> {:halt, true}
  false -> {:cont, MapSet.put(acc, item)}
end

And the runtime was 655 ms, better than 25% solutions.

I tried using if instead, the runtime was 525 ms, that is 100 ms shorter and better than 100% solutions.

In conclusion, if you are only dealing with true or false, if-else is definitely faster.

Yeah absolutely doesn’t make any sense. case is an erlang construct that is considered a core operation, while if is a syntactic sugar (AKA macro) that uses case under the hood, you can inspect the source code yourself.

2 Likes

More than that if does more work: it’s checking falsiness.

But I still use if. Usually your conditionals are not bottlenecks and it’s easier to read.

2 Likes

Absolutely, however I would strongly recommend to read this topic: https://elixirforum.com/t/my-thoughts-on-the-if-statement/61985 .

There is a clear intent difference between using case with true/false and if.

2 Likes

Thanks for sharing! I didn’t know that until now. (This is mental!)

I’m reading the source code and saw optimize_boolean, tbh I have zero idea what this is. Is it this what makes if slightly faster?

My assumption is this is a compile-time optimization, for example you have the following case:

a = true

if a == true do
...
end

This statement can be evaluated at compile-time, hence there is no need to execute the case statement.

In your case, if that is the case, then you literally can optimize if out of your codebase, so it makes no sense in benchmarking if vs case.

optimize_boolean is a red herring here - it marks the enclosed AST so that a later pass can expand it:

If the AST definitely has only true/false, rewrite_case_clauses replaces the when x in [false, nil] clause generated by if with… case / end with two branches :thinking:

However, I don’t think it’s directly related to your situation since returns_boolean has a very specific list of things it’s looking for:

and MapSet.member? isn’t on that.


A more interesting (IMO) thing to look at around performance is the shape of the BEAM code produced by the compiler. @compile :S is helpful as ever, given this input:

defmodule Foo2 do
  @compile :S

  def with_if(arg) do
    if arg do
      :ok
    else
      :nope
    end
  end

  def with_sim_if(arg) do
    case arg do
      false -> :nope
      nil -> :nope
      _ -> :ok
    end
  end

  def with_case(arg) do
    case arg do
      true -> :ok
      false -> :nope
    end
  end

  def with_case_default(arg) do
    case arg do
      false -> :nope
      _ -> :ok
    end
  end

  def with_if_eq(arg) do
    if arg == false do
      :nope
    else
      :ok
    end
  end

  def with_if_when(arg) when is_boolean(arg) do
    if arg do
      :ok
    else
      :nope
    end
  end

  def with_case_when(arg) when is_boolean(arg) do
    case arg do
      true -> :ok
      false -> :nope
    end
  end
end

with_if shows that the case generated by the macro translates directly to a select instruction:

  {label,17}.
    {select_val,{x,0},{f,19},{list,[{atom,false},{f,18},{atom,nil},{f,18}]}}.
  {label,18}.
    {move,{atom,nope},{x,0}}.
    return.
  {label,19}.
    {move,{atom,ok},{x,0}}.
    return.

with_sim_if produces exactly the same instructions (other than different labels for being in a different function):

  {label,20}.
    {line,[{location,"foo2.ex",12}]}.
    {func_info,{atom,'Elixir.Foo2'},{atom,with_sim_if},1}.
  {label,21}.
    {select_val,{x,0},{f,23},{list,[{atom,false},{f,22},{atom,nil},{f,22}]}}.
  {label,22}.
    {move,{atom,nope},{x,0}}.
    return.
  {label,23}.
    {move,{atom,ok},{x,0}}.
    return.

I’d expect that form to have exactly the same performance as if in your example.

The code for the case with explicit true and false is slightly different:

  {label,9}.
    {select_val,{x,0},{f,12},{list,[{atom,false},{f,11},{atom,true},{f,10}]}}.
  {label,10}.
    {move,{atom,ok},{x,0}}.
    return.
  {label,11}.
    {move,{atom,nope},{x,0}}.
    return.
  {label,12}.
    {line,[{location,"foo2.ex",21}]}.
    {case_end,{x,0}}.

This version has a third possible way to exit, if arg isn’t true or false.

I suspect this may cause the performance difference - maybe an optimization in the JIT for simple branches?

There are some other interesting variations:

  • with_case_default uses _ instead of true, so there’s no “third exit”. It produces very different instructions:

    {label,14}.
      {test,is_eq_exact,{f,15},[{x,0},{atom,false}]}.
      {move,{atom,nope},{x,0}}.
      return.
    {label,15}.
      {move,{atom,ok},{x,0}}.
      return.
    

    I suspect this is competitively fast, if not faster than others.

  • with_if_eq shows the optimize_boolean machinery doing its thing - it optimizes to the same instructions as with_case_default! Since the compiler knows == cannot return nil, that part of the check has been removed:

    {label,25}.
      {test,is_eq_exact,{f,26},[{x,0},{atom,false}]}.
      {move,{atom,nope},{x,0}}.
      return.
    {label,26}.
      {move,{atom,ok},{x,0}}.
      return.
    

    I’d expect this to perform identically to the with_case_default form.

  • with_case_when and with_if_when supply the compiler with type information via an is_boolean guard, so they both produce identical instructions:

    {label,17}.
      {select_val,{x,0},{f,16},{list,[{atom,false},{f,19},{atom,true},{f,18}]}}.
    {label,18}.
      {move,{atom,ok},{x,0}}.
      return.
    {label,19}.
      {move,{atom,nope},{x,0}}.
      return.
    
5 Likes

My guess (not gonna bother checking the bytecode unless people REALLY want) is that optimize_boolean ALSO has a list of “known functions with strictly boolean output” for example, and, or (vs &&, ||) and is able to eliminate the nil check from the if statement.

Well if it doesn’t do that, it probably should. It could probably also optimze some known stdlib functions, like Map.has_key?, etc.

Worth pointing out MapSet.member?(map_set, element) does not strictly return a boolean—it also raises if map_set is not a %MapSet{}. This is probably part of the reason why it’s not on the list. (The same holds true with Map.has_key?/2).

Definitely this. Code is read more than it is written.

Agree 100%

I rarely use if, if you have multi return values from one pattern(for example case x do 1->... 2->...) then go with case do, but if you’re dealing with many patterns that are almost booleans (for example if x==1....if y ! =2 then cond do will be perfect