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
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
on the callee
At runtime, if
a happens to be
nil then a
is raised. Indeed, there is no method
By contrast, when we code
a&.b this will call the method
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
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.
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
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
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
nil. It correctly identifies that
chris is not the
owner of the
unowned_vessel, and if we try to call it with an empty
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,
true when we pass an empty
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
By contrast, in the new version we have lost the short-circuiting effect of
def owner?(explorer) explorer && explorer.id == owner_id end
&&, and we will always do the equality comparison. This has an unintended effect of returning true when both the
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
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.