3

The answers to every question I can find (Q1, Q2) regarding Ruby's new safe navigation operator (&.) wrongly declare that obj&.foo is equivalent to obj && obj.foo.

It's easy to demonstrate that this equivalence is incorrect:

obj = false
obj && obj.foo  # => false
obj&.foo        # => NoMethodError: undefined method `foo' for false:FalseClass

Further, there is the problem of multiple evaluation. Replacing obj with an expression having side effects shows that the side effects are doubled only in the && expression:

def inc() @x += 1 end

@x = 0
inc && inc.itself  # => 2

@x = 0
inc&.itself        # => 1

What is the most concise pre-2.3 equivalent to obj&.foo that avoids these issues?

user513951
  • 12,445
  • 7
  • 65
  • 82
  • I don't think there is a pre-2.3 equivalent. That's why they added it. – Mike S Jan 05 '16 at 00:56
  • But considering that Rails `try` method seems to function like the `&` I would guess they implemented very similarly: `inc.try(:itself) #=> 1`. You can view the `try` source here https://github.com/rails/rails/blob/be589a8b01500a4b52a86248458bf57597e568e8/activesupport/lib/active_support/core_ext/object/try.rb#L62 – Mike S Jan 05 '16 at 00:57
  • @Mike I was able to come up with `(x = inc; x.itself unless x.nil?)` as a pre-2.3 equivalent. I'm hoping there's something less verbose. ActiveSupport's `try` may end up replicating a lot of the `&.` functionality, but it's not a direct equivalent. – user513951 Jan 05 '16 at 01:04
  • `&.` is not equivalent to `obj && obj.foo`, but in most cases it is. _u&.profile reminds us as short form of u && u.profile._ says Matz in https://bugs.ruby-lang.org/issues/11537#note-42. – sbs Jan 05 '16 at 05:35

2 Answers2

1

The safe navigation operator in Ruby 2.3 works almost exactly the same as the try! method added by ActiveSupport, minus its block handling.

A simplified version of that could look like this:

class Object
  def try(method, *args, &block)
    return nil if self.nil?
    public_send(method, *args, &block)
  end
end

You can use this like

obj.try(:foo).try(:each){|i| puts i}

This try method implements various details of the safe navigation operator, including:

  • It always returns nil if the receiver is nil, regardless of whether nil actually implements the queried method or not.
  • It raises a NoMethodError if the non-nil receiver doesn't support the method.
  • It doesn't swallow any exceptions on method calls.

Due to differences in language semantics, it can not (fully) implement other features of the real safe navigation operator, including:

  • Our try method always evaluates additional arguments, in contrast to the safe navigation operator. Consider this example

    nil&.foo(bar())
    

    Here, bar() is not evaluated. When using our try method as

    nil.try(:foo, bar())
    

    we always call the bar method first, regardless of whether we later call foo with it or not.

  • obj&.attr += 1 is valid syntax in Ruby 2.3.0 which can not be emulated with just a single method call in previous language versions.

Note that when actually implementing this code in production, you should have a look at Refinements instead of patching core classes.

Holger Just
  • 52,918
  • 14
  • 115
  • 123
  • What do you mean, *minus its block handling*? – user513951 Jan 05 '16 at 20:04
  • Well, you can call ActiveSupport's `try!` method with a block (and no other arguments) and it will yield to the block unless the receiver is `nil`. This is not possible with the the safe navigation operator. See the (above linked) [source](https://github.com/rails/rails/blob/be589a8b01500a4b52a86248458bf57597e568e8/activesupport/lib/active_support/core_ext/object/try.rb#L69-L80) for details. – Holger Just Jan 05 '16 at 22:15
  • Your code does not handle `try(:nil?)` on `nil` as pointed out in Jesse Sielaff's comment above. `a = nil; a.try(:nil?) #=> nil` – yez Jan 05 '16 at 22:48
  • Which is exactly what the safe navigation operator does. `nil&.nil?` returns `nil` in Ruby 2.3.0. – Holger Just Jan 05 '16 at 22:52
  • You are correct, however that behaviour is extremely surprising. So in versions lower than ruby 2.3, `nil.to_i #=> 0` and with the safe operator `nil&.to_i #=> nil`. So even methods that `nil` understands are just thrown away and `nil` is returned instead of actually calling the method... TIL – yez Jan 05 '16 at 23:20
  • Your answer makes an excellent point about `bar` never being evaluated in the `&.` expression versus being evaluated in the regular `.` expression. I hadn't considered that feature. – user513951 Jan 05 '16 at 23:43
  • I don't think it's true that the `&.`...`+=` functionality can't be emulated in pre-2.3 Ruby. The "macro"-like substitution I came up with above, `(x = {{receiver expression}}; x.{{method call}} unless x.nil?)` is perfectly capable of emulating it. – user513951 Jan 05 '16 at 23:45
  • 1
    I edited the sentence to be more precise. Basically, what you would have to do is to check the receiver and then call `attr` and `attr=` only if it is not `nil`. As these two method calls are independent of each other (i.e. the receiver `obj)` can't know it is called with a `+=` operator, you can't handle it in a single `try` call. – Holger Just Jan 05 '16 at 23:51
0

I think the most similar method that the safe traversal operators emulate is Rails' try method. However not exactly, we need to handle the case when the object is not nil but also does not respond to the method.

Which will return nil if the method can not evaluate the given method.

We can rewrite try pretty simply by:

class Object
  def try(method)
    if !self.respond_to?(method) && !self.nil?
      raise NoMethodError, "undefined method #{ method } for #{ self.class }"
    else
      begin
        self.public_send(method) 
      rescue NoMethodError
        nil
      end
    end
  end
end

Then it can be used in much the same way:

Ruby 2.2 and lower:

a = nil
a.try(:foo).try(:bar).try(:baz)
# => nil

a = false
a.try(:foo)
# => NoMethodError: undefined method :foo for FalseClass

Equivalent in Ruby 2.3

a = nil
a&.foo&.bar&.baz
# => nil

a = false
a&.foo
# => NoMethodError
yez
  • 2,368
  • 9
  • 15
  • 1
    Admittedly, this is the height of nitpicking, but I'm not sure this is an exact equivalent. Given `obj = nil`, I get `obj&.foo&.nil? # => nil` and `obj.try(:foo).try(:nil?) # => true`. – user513951 Jan 05 '16 at 01:20
  • Interesting observation. I'll admit that I'm stumped. It might be the case that an intermediary "`nil` but not really `nil`" value is returned via the safe traversal operator that the above definition of `try` does not do. – yez Jan 05 '16 at 01:24
  • 1
    (1) You swallow exceptions on `public_send` and (2) you don't return `nil` on any method calls on `nil` itself, both of which deviate from the behavior of the safe navigation operator. – Holger Just Jan 05 '16 at 12:29
  • 1) You do not swallow exceptions. The difference between `send` and `public_send` is that `public_send` only calls public methods. If the method does not exist, it still raises a `NoMethodError`, and if the method does exist and errors, so will the code. 2) The `nil` point is valid but as discussed above, would require a different return type other than `nil` to behave EXACTLY like the safe navigation operator. – yez Jan 05 '16 at 18:02
  • @yez `self.public_send(method) rescue nil` swallows any exception which might be raised, including the `NoMethodError`, regardless of whether you use `send` or `public_send` here. Also, you don't need "another" nil. If the receiver is `nil`, the safe navigation operator *always* returns `nil`. The same nil that is used everywhere else in Ruby. – Holger Just Jan 05 '16 at 22:24
  • A final point which was not covered yet in your code is that objects are not required to signal if they respond to a message in their `respond_to?`. It is certainly a good idea to do so but not required. If you e.g. simply overwrite `method_missing` without also specifying `respond_to_missing?`, you can still handle the method call normally, including by using the safe navigation operator. Your code raises a `NoMethodError` instead. – Holger Just Jan 05 '16 at 22:32
  • 1. Yes the rescue gets rid of exceptions, not the `public_send` as your first comment indicated. 2. This code does not handle the case where someone defined `method_missing` and not `respond_to` but it also doesn't handle code that changes itself dynamically every single invocation, because who would be crazy enough to do that? – yez Jan 05 '16 at 22:41