Guards, Guards!
Guards in Elixir (and Erlang)
Marten / Qqwy
Guards, Guards!
Guards in Elixir (and Erlang)
Marten / Qqwy
Why talk about guards?
Why talk about guards?
- Guards are specific to Erlang/Elixir:
- Design choices: Riding the line between efficiency/simplicity \(\leftrightarrow\) flexibility
- Implications not immediately obvious to newcomers
I'll be mostly showing Elixir code
Summary
- What are guards, exactly?
- Case, Cond, If, ... when use what?
- Custom guards & guard-aware macros
- How to go overboard with writing guard-safe code
Conclusion
Summary
- What are guards, exactly?
- Case, Cond, If, ... when use what?
- Custom guards & guard-aware macros
- How to go overboard with writing guard-safe code
Conclusion
4 'levels of difficulty'
1. What are guards, exactly?
1. What are guards, exactly?
What is Pattern-Matching, exactly?
\(\rightarrow\) Extension to pattern-matching
1. What are guards, exactly?
What is Pattern-Matching, exactly?
\(\rightarrow\) Extension to pattern-matching
\(\rightarrow\) Powerful conditional programming construct
def list_length(list) do
case list do
[] ->
0
[ _ | tail ] ->
1 + list_length(tail)
end
end
"Powerfulness"
of programming abstractions
"Powerfulness"
of programming abstractions
"Powerfulness"
of programming abstractions
How powerful is pattern-matching?
?! Not possible with just a pattern
How powerful is pattern-matching?
+ guards
"extra boolean checks"
1. What are guards, exactly?
def abs(x) when x < 0, do: -x
def abs(x) when x >= 0, do: x
1. What are guards, exactly?
def sign(x) when x == 0, do: 0
def sign(x) when x > 0, do: 1
def sign(x) when x < 0, do: -1
def abs(x) when x < 0, do: -x
def abs(x) when x >= 0, do: x
'Enhanced pattern matching'
Guards are allowed (essentially) anywhere patterns are allowed
- function clauses
- case expressions
- anonymous functions
- for and with
- try
- receive
- match?
- not allowed in `=`!
Only some functions are allowed in a guard
-
comparison operators(==, !=, <, >, <=, >=, ===, !==)
-
some arithmetic operations (+, -, *, /, div, rem, abs, round, ...)
-
is_* "type-check" functions
-
some functions to check sizes or convert tuples, lists, maps, strings
-
(strict) boolean operators (and, or, not)
But...
And nothing else!
Only some functions are allowed in a guard
-
comparison operators(==, !=, <, >, <=, >=, ===, !==)
-
some arithmetic operations (+, -, *, /, div, rem, abs, min, max, round, ...)
-
is_* "type-check" functions
-
some functions to check sizes or convert tuples, lists, maps, strings
-
(strict) boolean operators (and, or, not)
But...
And nothing else!
\(\leftarrow\) only added later!
Only some functions are allowed in a guard
... why?
Why not allow any user-written code?
Problems with allowing arbitrary code:
- What if it's slow?
- What if it writes to disk, calls a server, ... ?
- What about exceptions?
- Pattern-matching inside a pattern-match?
Problems with allowing arbitrary code:
- What if it's slow?
- What if it writes to disk, calls a server, ... ?
- What about exceptions?
- Pattern-matching inside a pattern-match?
Solution: Restrict to a tiny subset of Built-In Functions! ('BIFs')
Side note: Guards and exceptions
-
Some guards do 'throw exceptions'
-
Immediately makes whole guard fail
-
Problem when making more complex guards
-
-
Special syntax was introduced
def empty?(val)
when map_size(val) == 0
when tuple_size(val) == 0,
do: true
def empty?(val) when map_size(val) == 0 or tuple_size(val) == 0 do
true
end
Patterns with guards:
- "Idiomatic"...
- ...but limited
Some things you cannot do:
- more complicated math: \(2^x, \sqrt{x}, \sin{x}, \log{x}\)
- nested patterns
- 'full' support for custom datatypes
...But I want to use something else in my guard...
Side note: Other languages made a different choice
- Haskell: allows arbitrary guards (guaranteed to be 'pure')
- F#: any code (even impure)
"Originally when they were just simple tests the difference between guards and normal expressions was greater and more distinct. When guards were extended with full boolean expressions (which in itself is was probably a Good Thing) this difference became much less distinct and guards then became seen as restricted normal expressions, which they are not. This has caused problems.”
~Robert Virding
the Erlang Rationale
https://rvirding.blogspot.com/2019/01/the-erlang-rationale.html
2. case, cond, if,... when to choose what?
`case`
- built-in 1:1 the same between Erlang \(\leftrightarrow\) Elixir
- (only) accepts patterns + guards
def sign(x) do
case x do
x when x == 0 -> 0
x when x > 0 -> 1
x when x < 0 -> -1
end
end
- Readable + Efficient
- not flexible
multiple function clauses
- Identical to using `case`
def sign(x) when x == 0, do: 0
def sign(x) when x > 0, do: 1
def sign(x) when x < 0, do: -1
def sign(x) do
case x do
x when x == 0 -> 0
x when x > 0 -> 1
x when x < 0 -> -1
end
end
`if`...`else`
- Compiles to
- allows any expression: flexible
- hard-to-read nested ladders when you have multiple alternatives
case expression do
true ->
IO.puts("true")
false ->
IO.puts("false")
end
if expression do
IO.puts("true!")
else
IO.puts("false")
end
(also: unless)
`if`...`else`
- Compiles to
- allows any expression: flexible
- hard-to-read nested ladders when you have multiple alternatives
case expression do
result not in [nil, false] ->
IO.puts("true")
_ ->
IO.puts("false")
end
if x do
IO.puts("true!")
else
IO.puts("false")
end
Cannot always be optimized
(Elixir is "truthy")
(also: unless)
`cond`
- Compiles to nested `case`
- Allows any expression: flexible
- Use this when you have multiple alternatives
def sign(x) do
cond do
x == 0 -> 0
x > 0 -> 1
x < 1 -> -1
end
end
def sign(x) do
case x == 0 do
true -> 0
false ->
case x > 0 do
true -> 1
false ->
case x < 0 do
true -> -1
false -> raise CondClauseError
end
end
end
end
`cond`
- Compiles to nested `case`
- Allows any expression: flexible
- Use this when you have multiple alternatives
def sign(x) do
cond do
x == 0 -> 0
x > 0 -> 1
x < 1 -> -1
end
end
def sign(x) do
case x == 0 do
true -> 0
false ->
case x > 0 do
true -> 1
false ->
case x < 0 do
true -> -1
false -> raise CondClauseError
end
end
end
end
def sign(x) do
case x == 0 do
result not in [false, nil] -> 0
_ ->
case x > 0 do
result not in [false, nil] -> 1
_ ->
case x < 0 do
result not in [false, nil] -> -1
_ -> raise CondClauseError
end
end
end
end
Side Note: Erlang also has an `if`
- works similar to `cond`
However... Elixir does not use it, because:
- Elixir uses both `false` and `nil` as non-true
- Erlang's `if` only allows guard clauses
When to choose what?
When to choose what?
We've talked about readability + flexibility
What about performance?
When to choose what?
Which implementation is faster?
def guard_sign(x) when x == 0, do: 0
def guard_sign(x) when x > 0, do: 1
def guard_sign(x) when x < 0, do: -1
def case_sign(x) do
case x do
x when x == 0 -> 0
x when x > 0 -> 1
x when x < 0 -> -1
end
end
def cond_sign(x) do
cond do
x == 0 -> 0
x > 0 -> 1
x < 1 -> -1
end
end
def if_sign(x) do
if x == 0 do
0
else if x > 0 do
1
else
-1
end
end
end
Let's look at the generated BEAM bytecode!
{:function, ____________, 1, 21,
[
...
{:label, 21},
{:test, :is_eq, {:f, 22}, [x: 0, integer: 0]},
{:move, {:integer, 0}, {:x, 0}},
:return,
{:label, 22},
{:test, :is_lt, {:f, 23}, [integer: 0, x: 0]},
{:move, {:integer, 1}, {:x, 0}},
:return,
{:label, 23},
{:move, {:integer, -1}, {:x, 0}},
:return
]}
Which implementation is this?
{:function, ____________, 1, 21,
[
...
{:label, 21},
{:test, :is_eq, {:f, 22}, [x: 0, integer: 0]},
{:move, {:integer, 0}, {:x, 0}},
:return,
{:label, 22},
{:test, :is_lt, {:f, 23}, [integer: 0, x: 0]},
{:move, {:integer, 1}, {:x, 0}},
:return,
{:label, 23},
{:move, {:integer, -1}, {:x, 0}},
:return
]}
Which implementation is this?
Answer: All of them!
Disclaimer: this was a tiny function
Optimizing Compilers are awesome!
- "Premature optimization is the root of all evil"
- Do not guess for your compiler
- Compiler might either be more clever
- ... or possibly not understand at all
Check, Profile and Benchmark if you want to be sure!
Takeaway:
3. Custom Guards
& Guard-aware Macros
`defguard`
- macro that expands to more complex guard at compile-time
- Also does compile-time sanity-checks!
defguard is_cool(number) when number == 42 or number != 13
case {:ok, 10} do
:ok -> "Yay!"
{:ok, _} -> "Yay!"
{:ok, _, _} -> "Yay!"
# ...
_ -> "Failure"
end
defguard is_ok(x) when x == :ok or (is_tuple(x) and tuple_size(x) >= 1 and elem(x, 0) == :ok)
case {:ok, 10} do
res when is_ok(res) -> "Yay!"
_ -> "Failure"
end
What if defguard is not enough?
- Different implementation for guard and 'normal' code?
- Support more datatypes in 'normal' code
- Have simpler (or maybe more efficient) non-guard implementation.
- Usage example: `Numbers` operators
__CALLER__
defmacro a + b do
case __CALLER__.context do
nil ->
quote do
YourFancyImplementation.plus(a, b)
end
:guard ->
quote do
Kernel.+(a, b)
end
# :match is also available
end
end
__CALLER__.context
There are tools help to reduce friction
of working with custom datatypes
- Not perfect, but hey, it's something :-)
Takeaway:
4. My journey to define
a guard-safe `modulo`
Down the rabbit hole...
What is `mod`?
- Remainder of division (similar to rem)
- But: result is positive as long as divisor is positive
- Used as precondition in many common algorithms
- Not built-in in Erlang!
mod(dividend, divisor)
Integer.mod/2 (not guard-safe)
mod(a, n) === a - (n * floor_div(a, n))
- Problem: `div` is not floored division!
- So how to write `floor_div(a, b)`?
Solution:
a | n | rem(a, n) | div(a,n) + ?
+ | + | 0 | 0
+ | + | !0 | 0
- | + | 0 | 0
- | + | !0 | -1
+ | - | 0 | 0
+ | - | !0 | -1
- | - | 0 | 0
- | - | !0 | 0
0 | + | 0 | 0
0 | + | !0 | 0
0 | - | 0 | 0
0 | - | !0 | 0
mod(a, n) === a - (n * floor_div(a, n))
- Problem: `div` is not floored division!
- So how to write `floor_div(a, b)`?
Solution:
a | n | rem(a, n) | div(a,n) + ?
+ | + | 0 | 0
+ | + | !0 | 0
- | + | 0 | 0
- | + | !0 | -1
+ | - | 0 | 0
+ | - | !0 | -1
- | - | 0 | 0
- | - | !0 | 0
0 | + | 0 | 0
0 | + | !0 | 0
0 | - | 0 | 0
0 | - | !0 | 0
floor_div(a, n) === div(a, n)
+ div(sign(rem(a, n) * n) - 1, 2)
mod(a, n) === a - (n * floor_div(a, n))
- Problem: `sign` is not guard-safe
Solution:
floor_div(a, n) === div(a, n)
+ div(sign(rem(a, n) * n) - 1, 2)
sign(x) === div(x, abs(x))
What about dividing by 0?
mod(a, n) === a - (n * floor_div(a, n))
- Problem: `sign` is not guard-safe
Solution:
floor_div(a, n) === div(a, n)
+ div(sign(rem(a, n) * n) - 1, 2)
sign(x) === div(x, max(abs(x), 1))
mod(a, n) === a - (n * floor_div(a, n))
Solution:
floor_div(a, n) === div(a, n)
+ div(sign(rem(a, n) * n) - 1, 2)
sign(x) === div(x, max(abs(x), 1))
- Problem: `max` is not guard-safe!
max(a, b) === div(a+b + abs(a-b), 2)
mod(a, n) === a - (n * floor_div(a, n))
Solution:
floor_div(a, n) === div(a, n)
+ div(sign(rem(a, n) * n) - 1, 2)
sign(x) === div(x, max(abs(x), 1))
max(a, b) === div(a+b + abs(a-b), 2)
mod(x, n) === x - (n * div(x, n) + div(div(rem(x,n) * n, div(abs(rem(x, n) * n) + 1 + abs(abs(rem(x, n) * n) - 1), 2)) - 1, 2))
Solution:
oof.
mod(a, n) === a - (n * floor_div(a, n))
Optimized Solution:
floor_div(a, n) === div(a, n) + ((rem(a, n) * n) >>> abs(a * n))
'bit twiddling'
mod(a, n) === a - (n * div(a, n) + ((rem(a, n) * n) >>> abs(a * n)))
Becoming:
Let's Benchmark
list_with_10_000_integer_pairs |> Enum.map(fn {a, b} -> mod(a, b) end)
Let's Benchmark
list_with_10_000_integer_pairs |> Enum.map(fn {a, b} -> mod(a, b) end)
Only
- ~\(1.5 \times\) slower than non-guard version
- ~\(2.2 \times\) slower than `rem`
Conclusions
- Patterns with guards can be useful
- Powerful tools exist to make these more flexible
- But if you cannot structure your code this way, don't fret
- Optimizing compilers are awesome!
- Profile, Check & Benchmark if you want to know if something really is too slow
Marten / Qqwy
My company:
Me:
Wiebe-Marten Wijnja
Thank you!
Guards, Guards!
By qqwy
Guards, Guards!
- 833