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?(User.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?(User.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
There are no existing comments
Got your own view or feedback? Share it with us below …