Introduction
The safe-navigation operator (&.
) was added to ruby in version 2.3.
I have found it a fantastic addition to the language as it offers a simple way to condense
verbose nil
-checks across your code.
The effect of the operator can be explained as follows:
When we write the code a.b
this means that we want to call the method b
on the callee a
.
At runtime, if a
happens to be nil
then a NoMethodError
is raised. Indeed, there is no method b
on nil
.
By contrast, when we code a&.b
this will call the method b
on the callee a
, as before, however if the callee, a
, happens to be
nil
then a value of nil
is returned and no error is raised.
The following code looks at a concrete case in the console
Explorer = Struct.new(:id, :name)
chris = Explorer.new(99, "Christopher Colombus")
ferdinand = nil
chris.name # returns "Christopher Colombus"
chris&.name # returns "Christopher Colombus"
ferdinand.name # raises NoMethodError
ferdinand&.name # returns nil, no error raised
OK this is a very simple effect, but it does allow us to keep things pretty tight.
Notwithstanding the Law of Demeter,
(LoD), I am sure most of us have come across chains of method invocations where the presence of
the initial callee is not guaranteed. These situations were previously defensively-coded with short-circuit
boolean nil
-checks. For example:
def get_name(user)
user && user.profile && user.profile.name
end
The safe-navigation operator can provide a bit of a clean-up (whilst not addressing the LoD violation):
def get_name(user)
user&.profile&.name
end
Note, the defensive nil
-checking is still there, but we have just kept things a
bit briefer, which should help with overall readability and maintainability.
The Problem
If you want to switch out existing boolean short-circuit method chains, as in the example above, you would be forgiven for thinking that a substitution like the following will be universally equivalent:
user && user.name =====> user&.name
However, we must be more cautious as the equivalence will depend on where the substitution is
taking place. We extend the Explorer
example above, by considering a Vessel
class that is parameterised by a name
and an owner_id
, as follows:
class Vessel
attr_reader :name, :owner_id
def initialize(name, owner_id)
@name = name
@owner_id = owner_id
end
def owner?(explorer)
explorer && explorer.id == owner_id
end
end
The Vessel
class exposes an instance method, owner?
which takes an
explorer
as a parameter, and returns true if the explorer.id
matches the
owner_id
of the Vessel
instance. Lets see it in action:
santa_maria = Vessel.new("Santa Maria", chris.id)
santa_maria.owner?(chris) # returns true
santa_maria.owner?(Explorer.new(99, "No name")) # returns true
santa_maria.owner?(nil) # returns false
unowned_vessel = Vessel.new("Unowned", nil)
unowned_vessel.owner?(chris) # returns false
unowned_vessel.owner?(nil) # returns nil (falsey)
As well as looking at the basic usage of the owner?
method, we also see how this method
behaves if we have an 'unowned' Vessel
, i.e. a Vessel
where the
owner_id
is nil
. It correctly identifies that chris
is not the
owner of the unowned_vessel
, and if we try to call it with an empty Explorer
we get a nil
return value. Not perfect, but the behaviour is reasonably sensible.
Now lets do our substitution, making use of our safe-navigation operator.
class Vessel
attr_reader :name, :owner_id
def initialize(name, owner_id)
@name = name
@owner_id = owner_id
end
def owner?(user)
user&.id == owner_id
end
end
santa_maria = Vessel.new("Santa Maria", chris.id)
santa_maria.owner?(chris) # returns true
santa_maria.owner?(Explorer.new(99, "No name")) # returns true
santa_maria.owner?(nil) # returns false
unowned_vessel = Vessel.new("Unowned", nil)
unowned_vessel.owner?(chris) # returns false
unowned_vessel.owner?(nil) # returns true !!!
The behaviour looks the same as before until we get to the last line, unowned_vessel.owner?
returns true
when we pass an empty Explorer
instance!
Yikes, this could pose an obvious security risk, so what's going on here?
The problem is that our substitution
explorer && explorer.id =====> explorer&.id
is not equivalent in this case because it is followed by the boolean equality operator. The original version
has two separate expressions, and relied on the short-circuiting behaviour of the boolean &&
operator. The second expression (explorer.id == owner.id
) is never evaluated
when the explorer
is nil
.
def owner?(explorer)
explorer && explorer.id == owner_id
end
By contrast, in the new version we have lost the short-circuiting effect of &&
, and we will always
do the equality comparison. This has an unintended effect of returning true when both the explorer
and the owner_id
are both nil
. Our substitution has fundamentally changed the logic
in this case, and we have broken the original method:
def owner?(explorer)
explorer&.id == owner_id # returns true when both explorer and owner_id are nil
end
end
Summary
The safe-navigation operator (&.
) has been a very useful addition to the ruby language.
Switching out boolean short-circuit chains can lead to terser code, but beware direct substitutions within
longer boolean expressions, as you may end up changing the logic unintentionally.
Comments
Hello, In the example santa_maria.owner?(User.new(99, "No name")) # returns true It should return false or I missing something ?
Just realised that I accidentally introduced a User class in that line, that should really have been Explorer.new(99, "No name"). It is expected to return true here as the #owner? method only checks the ID of the entity passed to see if it matches the owner ID. It doesn't matter if we have contructed a new instance to pass into the method, and the name is not significant in this check.
Got your own view or feedback? Share it with us below …