loading...
Cover image for Ruby's Safe Navigation Operator `&.` and is it a Code Smell?

Ruby's Safe Navigation Operator `&.` and is it a Code Smell?

hkly profile image hkly Originally published at blog.beezwax.net ・Updated on ・3 min read

Originally published on Beezwax.net.


What is &.?

Ruby devs are probably all too familiar with seeing this error:

NoMethodError (undefined method `foo' for nil:NilClass)

If you're not familiar with this error, you can check out Ben's post on it:

Most of the time, the error is probably due to a typo, but every now and then we end up having to do something like:

defined?(bar) && bar.foo
# returns nil if bar is nil

If you're on Rails, or are using ActiveSupport, you can use present? or try():

bar.present? && bar.foo
# returns false if bar is nil

bar.try(:foo)
# returns nil if bar is nil

In Ruby 2.3.0, they introduced the safe navigation operator &. which returns nil when called on a nil object, like so:

bar&.foo
# returns nil if bar is nil

This can be pretty handy when you're not in Rails-land or just want some compact code.

&. vs .try()

So other than the number of characters, what's the difference between ActiveSupport's try() and the Ruby safe navigation operator &.?

&. will only return nil if called on a nil object. Calling &. will raise a NoMethodError if it is called on a non-nil object that doesn't implement the method. However, try() will just return nil if called on anything that doesn't implement the method. (Note: try!() works just like &.)

bar = 'a'

bar&.foo
# returns NoMethodError (undefined method `foo' for "a":String)

bar.try(:foo)
#returns nil

bar.try!(:foo)
# returns NoMethodError (undefined method `foo' for "a":String)

This also means that &. will return NoMethodError if called on false, whereas doing a presence check would return false before attempting to call the method. I'm not sure when this scenario would come up, but it is good to be aware of.

bar = false 

bar&.foo
# returns NoMethodError (undefined method `foo' for false:FalseClass)

bar.try(:foo)
#returns nil

bar.present? && bar.foo
# returns false

Is this a code smell?

The safe navigator isn't inherently bad. In fact if you have something like this in your code foo && foo.bar Rubocop will admonish you for not using safe navigation.

However, using &. too often in your code is probably something to avoid. Relying on safe navigation encourages the bad habit of passing around nils, it can make bugs harder to find, and personally I find it can make code harder to read.

For example, I had this piece of code:

if posts.empty? || mean(posts.pluck(:unicorns_count))&.zero?

At first it seems pretty innocuous, I was getting a NoMethodError (undefined method 'zero?' for nil:NilClass), the fastest thing to do was just toss the safe navigator on there and move on. However, if I left it that way, I'd just run into the error again if I tried calling another method on the results of mean() somewhere else in my code. Does that mean going around tagging on safe navigators everywhere I called mean()? No way! The answer was to look at why a method called mean() was returning nil to begin with and refactor the method.

The safe navigation operator should only be used when nil is an acceptable result, but more often than not, you don't want to be returning or passing nil anyway. If you're leaning on &. too often or you find yourself with code that looks something like:

potatoes.foo&.bar&.baz&.qux

It's time to take a closer look at refactoring your code. The 'Null Object Pattern` could be one potential solution (and potentially a future post for me to writeπŸ€”).


I wrote the future post!


Cover photo by https://unsplash.com/@lucabravo

Discussion

pic
Editor guide
Collapse
wulymammoth profile image
David

πŸ‘ for mentioning the "null object pattern". Solid write-up!

Seeing too many #try and &. in code is a major code smell! Chances are the code isn't tested well either, because, well, each of those calls expands into two new branches of tests -- one for the nil case and another for non-nil case. In the example you've shown...

potatoes.foo&.bar&.baz&.qux

complete test coverage for the potato unit test should have at least 2^3 => 8 test cases, where 2 is the number of branches spawned for each &. invocation and 3 being the number of &. used

Chaining is typically bad and it also violates the "Law of Demeter" where the subject/object should only know about its immediate neighbors/relationships. The class or code that surrounds/wraps potatoes.foo&.bar&.baz&.qux is subject to change and breakage if bar, baz, or qux's behaviors change. This is tight-coupling and no bueno!

Collapse
hkly profile image
hkly Author

Yes, exactly!