The safe-navigation operator is a great way to avoid repeated null checks. However, sometimes it can go a bit wrong. This post outlines a specific case where care is needed to ensure you don't break existing logic.

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

  • Submitted by lokhi
    3 months ago

    Hello, In the example santa_maria.owner?(User.new(99, "No name")) # returns true It should return false or I missing something ?

    • Submitted by Domhnall Murphy
      3 months ago

      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 …