Guards clauses are a key feature in Elixir. Researching how to make it easier for developers to define guards, has led me to two enhancement proposal. This is the first one, which will allow developers to write guards, guard-safe macros and regular expressions in a more natural and succinct way.
The following macro is allowed in guards:
is_kind(term, kind)
determines if a giventerm
is of a certainkind
.
as well as the following operators:
term is kinds
determines ifterm
is each kind inkinds
.term is_not kinds
determines ifterm
is not of any of thekinds
.term is_any kinds
determines ifterm
is of any of thekinds
.list are kinds
determines if every term inlist
is of every kind inkinds
.list are_not kinds
determines if every item in listterms
is not ofkind
.list are_any kinds
determines if every item in listterms
is at least of one of givenkinds
.
My main initial goal was to find a way to easily translate from typespecs definitions into guard declarations.
Let's say, given:
@spec process_value(tuple, non_neg_integer, char)
def process_value(config, value, letter)
when is_tuple(config) and is_integer(value) and value >= 0 and letter in 0..0x10FFFF do
do_process_value(config, value, letter)
end
I thought of creating a guard-safe macro for every type in the language (is_*
functions), but that meant that a lot of new functions had to be created, thus taking the risk of poluting the language.
So I came up with is_kind/2
, which takes two arguments: term
and kind
,
so in the previous case you would call: is_kind(value, :non_neg_integer)
@spec process_value(tuple, non_neg_integer, char)
def process_value(config, value, letter)
when is_tuple(config) and is_kind(value, :non_neg_integer) and is_kind(letter, :char) do
do_process_value(config, value, letter)
end
The is_kind/2
macro allow us to support new kinds
provided they can be implemented in a guard-safe way.
Convenience functions have been added. For example, for an integer that is >= 0
, a :zero_or_pos_integer
kind has been created, and :non_neg_integer
has been aliased to a less confusing one named :zero_or_neg_integer
.
A two-element tuple can be used as kind, which extends the use of this macro for certain kinds, for example.
is_kind(term, {:function, 2})
checks that term is a function of arity 2.
Or is_kind(term, {:tuple, 3})
checks for a tuple with 3 elements, and
is_kind(term, {:>, 10})
checks that term is greater than 10.
is_kind/2
is the building block for every guard-safe macro in this proposal.
It is worth noting that we could use is_kind/2
for creating guard-safe macros, but you will see that we can use is/2
which feels more natural.
For a list of all supported functions, see the list at the end of this document.
iex> is_kind(self, :pid)
true
iex> is_kind(&(&1), :function)
true
iex> is_kind(&(&1), {:function, 1})
true
iex> is_kind([1, 2], {:list, 1})
false
term is
kinds — Returns true
if term
is of each kind in kinds
.
kinds
can be a single kind, or a list of them.
iex> :foo is :atom
true
iex> 100 is [:even, :zero_or_pos_integer]
true
iex> 100 is [:even, :zero_or_pos_float]
false
# it doesn't match both criteria
Use in guards:
The original function definition:
@spec is_two_element_tuple(term) :: boolean
def is_two_element_tuple(term) when is_tuple(term) and (tuple_size(term, 2),
do: true
def is_two_element_tuple(_),
do: false
could be rewritten like this:
@spec is_two_element_tuple(term) :: boolean
def is_two_element_tuple(term) when term is {:tuple, 2},
do: true
def is_two_element_tuple(_),
do: false
we could use many kinds by passing a list of them,
@spec is_integer_even_greater_than_ten(term) :: boolean
def is_integer_even_greater_than_ten(term) when term is [:even, {:gt, 10}]
do: true
def is_integer_even_greater_than_ten(_),
do: false
We can see that is
acts as a boolean and
when passed many kinds in a list.
We can use the is
operator as a more natural way of writing clauses, and it completely replaces is_kind/2
.
is/2
heavily relies on is_kind/2
, and these two macros are the building blocks for all the macros that we will explore from here onwards.
term is
kinds — Returns true
if term
is not of all the kinds
.
iex> term = 123
...> term is_not nil
true
which gives us a way of expressing conditions closer to the English natural language:
instead of writing: when not is_nil(term)
vs. when term is_not nil
is_not
can also accept a list of kinds on the right hand side (same as is
). It will return true, if term does not evaluate to true with every one of the kinds.
iex> 5 is_not [:even, :atom, :negative]
true
iex> 5 is_not [:even, :atom, :negative, :integer]
false
term is_any
kinds — Returns true
if term
is at least of one kind in kinds
.
iex> :foo is_any [:atom]
true
iex> 100 is_any [:even, :zero_or_pos_float]
true
iex> self is_any [:atom, :module, :port]
false
We can see that is_any/2
acts as a boolean "or".
Usage in guards:
@spec is_identifier(term) :: boolean
def is_identifier(term) when term is_any [:port, :reference],
do: true
def is_identifier(_),
do: false
list are
kinds — Returns true
if every term in list
is of every kind in kinds
.
Note that while is/2
and its sister functions take the term on the left side as a single item,
are/2
and sister functions must takes on the left side a list of terms to be evaluated.
iex> [1, 34, 255] are [:integer, :positive, :byte]
true
iex> [1, 34, 0] are [:integer, :positive, :byte]
false
@spec check(pos_integer, pos_integer, pos_integer) :: boolean
def check(int1, int2, int3)
when is_integer(int1) and int1 > 0
and is_integer(int2) and int2 > 0
and is_integer(int3) and int3 > 0 do
true
end
def check(_, _, _) do
false
end
We could rewrite it in a much more succinct way:
@spec check(pos_integer, pos_integer, pos_integer) :: boolean
def check(int1, int2, int3) when [int1, int2, int3] are :pos_integer,
do: true
def check(_, _, _),
do: false
now let's say you'd want all those arguments to be odd as well, you could do
def check(int1, int2, int3) when [int1, int2, int3] are [:pos_integer, :odd],
do: true
or simplify it even more
def check(int1, int2, int3) when [int1, int2, int3] are [:odd, :positive],
do: true
because :odd
implies :integer
, so that's saving us a comparison in the guards.
term_list are_not/2
kinds — Returns true
if every term in list
is not of any kind in kinds
.
iex> [:foo, 10.0, "string"] are_not [:integer, :zero, :pid]
true
iex> [:atom, 0.0, "string"] are_not [:integer, :zero, :pid]
false
term_list are_any/2
kinds — Returns true
if every term in list
is at least of one kind
in kinds
.
iex> [:foo, Kernel, 10] are_any [:atom, :integer]
true
iex> [:foo, Kernel, 10.0] are_any [:atom, :integer]
false
is_kind/2
and consequently all macros in the is
and are
family listed in this article can take numbers as kind.
So you can do:
iex> [39, 41, 42, 43, 45, 47] are_any [:odd, 42]
true
you can see 42
on the left will match with 42
on the right.
There are certain limitations when these macros are used in guard expressions:
Definitions:
-
A compile-time kind is a supported kind, can be a compile-time number, a compile-time atom, or a compile-time tuple where its first element is a compile-time supported atom.
-
A compile-time list of kinds is a compile-time list, where every element is a compile-time kind.
When these macros are used in guard expressions, they have certain requirements:
-
is_kind(term, kind)
kind
argument has to be a compile-time kind.
-
term
is
kinds; termis_not
kindskinds
has to be a compile-time kind, or a compile-time list of kinds.
-
term
is_any
kindskinds
hand operand has to be a compile-time list of kinds.
-
list
are
kinds, listare_not
kinds:list
must be a compile-time list. Items in the list do not need to be available at compile-time.kinds
has to be a compile-time kind, or a compile-time list of kinds.
-
list
are_any
kinds:list
must be a compile-time list. Items in the list do not need to be available at compile-time.kinds
must be a compile-time kind, or a compile-time list of kinds.
If these items are not defined at compile time, you will get a compile-time error.
Examples of usage in guard expressions:
def is_42(term) when term is 42,
do: true
def is_42(_, _),
do: false
def is_not_three_element_tuple(term, 3) when term is_not {:tuple, 3},
do: true
def is_not_three_element_tuple(_, _),
do: false
def is_atom_or_number(term) when term is_any [:atom, :number],
do: true
def is_atom_or_number(_, _),
do: false
def are_atom_or_number(item1, item2, item3) when [item1, item2, item3] are_any [:atom, :number],
do: true
def are_atom_or_number(_, _),
do: false
# Basic and built-in types
:arity |
:atom |
:binary |
:bitstring |
:boolean |
:byte |
:char |
:float |
:fun | :function |
{:fun, arity} | {:function, arity} | # function with arity
:identifier |
:integer |
:list |
{:list, non_neg_integer} | # list of a certain length
:map |
{:map, non_neg_integer} | # map of a certain size
:mfa |
:module |
:neg_integer | # negative integer
:node |
:non_neg_integer | :zero_or_pos_integer | # integer equal or greater than 0
:nonempty_list |
:number | # any integer or float
:pid |
:port |
:pos_integer | # positive integer
:reference |
:timeout |
:tuple |
{:tuple, non_neg_integer} | # tuple of a certain size
# Additional: Derived from is_* functions
:even | # even integer
:nil |
:odd | # odd integer
:record |
{:record, atom} | # record of a certain kind
{:record, pos_integer} | # record of a certain size
# Additional: Comparison
{:==, term} | {:eq, term} | # equal
{:!=, term} | {:not_eq, term} | # not equal
{:===, term} | {:strict_eq, term} | # strict equal
{:!==, term} | {:not_strict_eq, term} | # not strict equal
{:<, term} | {:lt, term} | # less than
{:<=, term} | {:lt_eq, term} | # less than or equal
{:>, term} | {:gt, term} | # greater than
{:>=, term} | {:gt_eq, term} | # greater than or equal
# Additional: Convenience
:empty_list | # []
:false |
:falsey | # false or nil
:negative | # negative number (integer or float)
:neg_float | # negative float
:positive | # positive number (integer or float)
:pos_float | # positive float
:true |
:truthy | # any value that is not false nor nil
:zero | # 0 or 0.0
:zero_or_negative | # number equal or less than 0
:zero_or_neg_float | # float equal or less than 0.0
:zero_or_neg_integer | # integer equal or less than 0
:zero_or_positive | # number equal or greater than 0
:zero_or_pos_float | # float equal or greater than 0.0
# Additional: Literal numbers
integer | # literal integers. Ex: 42
float # literal floats. Ex: 12.34
Note: It supports all built-in data types with the exception of:
charlist
iodata
iolist
maybe_improper_list
The full implemementation can be found at: https://github.com/eksperimental/elixir/tree/is-kind
— by Eksperimental