Hi everyone,
Erlang/OTP 21 comes with two new guards contributed by @michalmuskala: map_get/2
and is_map_key/2
. Now we need to discuss how we would integrate those in Elixir. This proposal will be broken in three parts, each with their own discussion. Feel free to comment on any conclusion individually or as a group, but please let us know why.
NOTE: this is a focused thread, so we appreciate if everybody stayed on topic. Feel free to comment anything in regards to maps and guards but avoid off-topic or loosely related topics. For example, if you would like to know when other OTP 21 features will be added to Elixir, please use a separate thread.
The map_get/2
and is_map_key/2
guards
The first topic to consider is if we want to add the map_get/2
and is_map_key/2
guards to Kernel. Here are some things to consider.
First of all, in Elixir we donât duplicate Kernel
functionality in other modules. For example, since we have Kernel.map_size/1
, there is no such thing as Map.size/1
. However, given that we have Map.fetch!/2
(equivalent to map_get/2
) and Map.has_key?/2
(equivalent to is_map_key/2
), adding those two new guards means we would break those rules, as we simply canât remove the Map
functions.
Second of all, I am not particularly sure that map_get/2
and is_map_key/2
are beneficial in actual code, since they donât add anything that you canât do with a pattern matching. Letâs see the website example, without guards:
def serve_drinks(%User{age: age} = user) when age >= 21 do
...
end
Now written with guards:
def serve_drinks(%User{} = user) when map_get(user, :age) >= 21 do
...
end
Or if we assume we donât need to check for the struct name:
def serve_drinks(user) when map_get(user, :age) >= 21 do
...
end
In both cases, I personally prefer the original example. It is clearer and promote better and more performant practices (pattern matching instead of field access).
In my opinion, the real advantage of map_get/2
and is_map_key/2
is that we can use them to build composable guards. For example, now we can finally implement guards such as is_struct/1
:
defguard is_struct(struct)
when is_map(struct) and
:erlang.is_map_key(:__struct__, struct) and
is_atom(:erlang.map_get(:__struct__, struct))
Luckily, to implement those guards, we donât need to add is_map_key/2
and map_get/2
to Elixir. We can just invoke them from the :erlang
module.
Discussion #1: Should we add map_get/2
and is_map_key/2
to Kernel
?
Expand defguard
If the main goal of map_get/2
and is_map_key/2
is to help in the construction of composable guards, then one possible route to take is to expand the defguard
construct, introduced in Elixir v1.6, to also support patterns. Such that the following:
def serve_drinks(%User{age: age} = user) when age >= 21 do
...
end
Could be written as:
defguard is_drinking_age(%User{age: age}) when age >= 21
def serve_drinks(user) when is_drinking_age(user)
The guard above would internally be written as:
defguard is_drinking_age(user)
when is_map(user) and
:erlang.is_map_key(:__struct__, user) and
:erlang.map_get(:__struct__, user) == User and
:erlang.is_map_key(:age, user) and
:erlang.map_get(:age, user) >= 21
We can translate almost any pattern to guards. The only exception are binary matches, which wonât be supported.
Discussion #2: Should we allow patterns in defguard/1
?
Support map.field
in guards
Yet another approach to take is to support only map.field
in guards. Again, if we take the original example:
def serve_drinks(%User{age: age} = user) when age >= 21 do
...
end
We could write it as:
def serve_drinks(%User{} = user) when user.age >= 21 do
...
end
Or even as:
def serve_drinks(user) when user.age >= 21 do
...
end
Where the latest is, IMO, by far the most readable.
However, this also has its own issues. First of all, as we said in the first section, pattern matching is typically more performant and clearer. Second of all, the foo.bar
syntax in Elixir can mean either map.field
OR module.function
. This may be the source of confusion since invoking remote functions is not allowed in guards and those will make the guard to fail. For example, if somebody passes serve_drinks(User)
to def serve_drinks(user) when user.age >= 21 do
, you will get a FunctionClauseError
, as only passing maps would be supported. However, I personally donât think this would be a large issue in practice, as guards have always been limitted, remote calls are never supported in guards and dynamic remote calls are rare anyway, especially zero-arity ones.
Discussion #3: Should we allow map.field
in guards?