The most interesting addition to Ruby 2.3.0 is the Safe Navigation Operator(&.
). A similar
operator has been present in C# and Groovy for a long time with a slightly different syntax - ?.
.
So what does it do?
TLDR
It is used for avoiding NoMethodError
exceptions when calling methods on objects that may be
nil
.
account = nil
account.number
# => NoMethodError (undefined method `number' for nil:NilClass)`)
account&.number
# => nil
Example
Imagine you have an account
that has an owner
and you want to get the owner
’s address
. If
you want to be safe and not risk a nil
error, you would write something like the following:
if account && account.owner && account.owner.address
...
end
This is really verbose and annoying to type. ActiveSupport includes the try
method which has a
similar behaviour (but with few key differences that will be discussed later):
if account.try(:owner).try(:address)
...
end
It accomplishes the same thing - it either returns the address or nil
if some value along the
chain is nil
. The first example may also return false
if, for example, the owner
is set to
false
.
Using the safe navigation operator (&.)
We can rewrite the previous example using the safe navigation operator:
account&.owner&.address
The syntax is a bit awkward but I guess we will have to deal with it because it does make the code more compact.
More examples
Let’s compare all three approaches in more detail.
account = Account.new(owner: nil) # account without an owner
account.owner.address
# => NoMethodError: undefined method `address' for nil:NilClass
account && account.owner && account.owner.address
# => nil
account.try(:owner).try(:address)
# => nil
account&.owner&.address
# => nil
No surprises so far. What if owner
is false
(unlikely but not impossible in the exciting world
of shitty code)?
account = Account.new(owner: false)
account.owner.address
# => NoMethodError: undefined method `address' for false:FalseClass j
account && account.owner && account.owner.address
# => false
account.try(:owner).try(:address)
# => nil
account&.owner&.address
# => undefined method `address' for false:FalseClass`
Here comes the first surprise - the &.
syntax only skips nil
but recognizes false
! It is not
exactly equivalent to the s1 && s1.s2 && s1.s2.s3
syntax.
What if the owner is present but doesn’t respond to address
?
account = Account.new(owner: Object.new)
account.owner.address
# => NoMethodError: undefined method `address' for #<Object:0x00559996b5bde8>
account && account.owner && account.owner.address
# => NoMethodError: undefined method `address' for #<Object:0x00559996b5bde8>`
account.try(:owner).try(:address)
# => nil
account&.owner&.address
# => NoMethodError: undefined method `address' for #<Object:0x00559996b5bde8>`
Oops, the try
method doesn’t check if the receiver responds to the given method. This is why
it’s always better to use the stricter version of try
- try!
:
account.try!(:owner).try!(:address)
# => NoMethodError: undefined method `address' for #<Object:0x00559996b5bde8>`
Bonus 2.3 features - Array#dig and Hash#dig
The #dig
method is probably the most useful feature in this version. No longer do we have to
write abominations like the following:
address = params[:account].try(:[], :owner).try(:[], :address)
# or
address = params[:account].fetch(:owner) { {} }.fetch(:address)
We can now simply use Hash#dig
and accomplish the same thing:
address = params.dig(:account, :owner, :address)