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?(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 …