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 …