Does Ruby safe navigation operator evaluate its parameters when its receiver is nil?
Asked Answered
F

5

6

Question:

Does Ruby safe navigation operator (&.) evaluate its parameters when its receiver is nil?

For example:

logger&.log("Something important happened...")
  • Is the "Something important happened..." string evaluated here?
  • Could you provide an authoritative source, which proves or denies this fact?
  • Or suggest a way how to check it?

Thanks in advance.




Why I am looking for an answer to this question?

I have the code like the following throughout my codebase:

logger.log("Something important happened. (#{Time.current})") if verbose

My main goal is to remove the repetition of the if verbose check whenever I call the log method since it is easy to forget about it and you will be not notified at all about the misusage.

Inspired by the Tell, don't ask principle,

I have moved if verbose check inside log method implementation.

class Logger
  # ...
  
  def log(message)
    return unless verbose

    # ...
  end
end

def logger
  @logger ||= Logger.new
end

logger.log("Something important happened. (#{Time.current})")

This approach simplified my code since I have solved my main problem - I don't need to remember to place if verbose whenever I call the log method,

but I have received another issue.

"Something important..." string is always evaluated, no matter whether verbose is true or false.

Therefore, I have completely changed the solution:

  • logger returns nil when verbose is false.
  • Ruby safe navigation operator should be used in front of log calls.
def logger
  @logger ||= Logger.new if verbose
end

logger&.log("Something important happened. (#{Time.current})")

As a result, I have replaced the initial problem of remembering if verbose checks to remembering of &. calls.

But, anyway, I consider this as an improvement, since forgetting to utilize the safe navigation operator raises the NoMethodError, in other words, notifies about the log method misusage.

So now, in order to be sure that the 'safe navigation operator approach' is actually a 'better' option for my problem,

I need to know exactly whether the safe navigation operator in Ruby evaluates its parameters when its receiver is nil.

Fauteuil answered 21/7, 2020 at 20:9 Comment(0)
P
7

To quote from the syntax documentation for the safe navigation operator:

&., called “safe navigation operator”, allows to skip method call when receiver is nil. It returns nil and doesn't evaluate method's arguments if the call is skipped.

As such, the arguments of your log method are not evaluated if the logger is nil when you call it as

logger&.log("something happened at #{Time.now}")

With that being said, note that the Ruby core logger offers a different solution to your exact issue, namely to avoid having to evaluate potentially expensive arguments if the log level is to high.

The Ruby core logger implements its add method something like this (simplified):

class Logger
  attr_accessor :level

  def initialize(level)
    @level = level.to_i
  end

  def add(severity, message = nil)
    return unless severity >= level
    
    message ||= yield
    log_device.write(message)
  end

  def info(message = nil, &block)
    add(1, message, &block)
  end
end

You can then use this as

logger = Logger.new(1)
logger.info { "something happened at #{Time.now}" }

Here, the block is only evaluated if the log level is high enough that the message is actually used.

Picked answered 21/7, 2020 at 20:59 Comment(0)
A
3

Expression Parsed But Not Executed

The argument to logger&.log isn't evaluated when logger.is_a?(NilClass) == true. Every Ruby expression that's evaluated should have an impact, so consider:

test = 1
nil&.log(test+=1); test
#=> 1

If the argument were evaluated by the interpreter, test would equal two. So, while the parser certainly parses the expression in your argument, it doesn't execute the inner expression.

You can verify what the parser sees with Ripper#sexp:

require 'ripper'

test = 1
pp Ripper.sexp "nil&.log(test+=1)"; test
[:program,
 [[:method_add_arg,
   [:call,
    [:var_ref, [:@kw, "nil", [1, 0]]],
    [:@op, "&.", [1, 3]],
    [:@ident, "log", [1, 5]]],
   [:arg_paren,
    [:args_add_block,
     [[:opassign,
       [:var_field, [:@ident, "test", [1, 9]]],
       [:@op, "+=", [1, 13]],
       [:@int, "1", [1, 15]]]],
     false]]]]]
#=> 1

This clearly shows that the parser sees the incremented assignment in the symbolic expression tree. However, the assignment is never actually executed.

Angellaangelle answered 21/7, 2020 at 21:35 Comment(1)
Your test only shows that this particular version of this particular implementation will, for this particular piece of code not evaluate the expression. There is no guarantee that a future version of a different Ruby implementation for a different piece of code behave the same. Hence, why the OP is asking for a quote from a spec or official documentation instead of an example. Examples, by their very nature, can never prove anything, they can only disprove, in the form of a counter-example. This is similar to how tests can never prove that code is bug-free, they can only prove bugs.Borstal
C
1

It does not evaluate them:

require 'pry'

logger = nil
logger&.log(binding.pry)

This returns:

nil

If it evaluated it then it would trigger the binding like this example does:

a = []
a&.push(binding.pry)

If you don't have pry but do have a modern version of Ruby you can substitute binding.irb for binding.pry.

Whether or not this is a "better" solution is something you should benchmark to be sure.

You can read more about the safe navigation operator at How is the Ruby safe navigation (&.) implemented?

Congener answered 21/7, 2020 at 20:21 Comment(1)
Your test only shows that this particular version of this particular implementation will, for this particular piece of code not evaluate the expression. There is no guarantee that a future version of a different Ruby implementation for a different piece of code behave the same. Hence, why the OP is asking for a quote from a spec or official documentation instead of an example. Examples, by their very nature, can never prove anything, they can only disprove, in the form of a counter-example. This is similar to how tests can never prove that code is bug-free, they can only prove bugs.Borstal
R
1

No, and it's very easy to test:

$ irb
> def test
>   puts 'triggered!'
> end
 => :test 
> def nothing
> end
 => :nothing 
> nothing&.whatever(test)
 => nil
> nothing&.whatever("string_#{test}")
 => nil 

Conceptually you might think of safe navigation operator as this:

x&.test(param) # is "conceptually" equal to

if x.respond_to?(:test)
  x.test(param)
end

# or, as pointed in the comment: 
unless x.nil?
  x.test(param)
end

And now it's pretty clear why it's not evaluated when it's not called.

Roguery answered 21/7, 2020 at 20:37 Comment(3)
Your first example is not conclusive (since it doesn't differ whether test would be called or not) and your conceptional explanation is wrong (since &. only tests whether the receiver is nil) and slightly misleading (since you can not emulate the full behavior of the safe-navigation operator in pure Ruby, specifically because it does evaluate method arguments if the receiver is nil.Picked
Yeah, I updated the code. And, if you look closely, I wrote "conceptually". I can update that part as well...Roguery
Your test only shows that this particular version of this particular implementation will, for this particular piece of code not evaluate the expression. There is no guarantee that a future version of a different Ruby implementation for a different piece of code behave the same. Hence, why the OP is asking for a quote from a spec or official documentation instead of an example. Examples, by their very nature, can never prove anything, they can only disprove, in the form of a counter-example. This is similar to how tests can never prove that code is bug-free, they can only prove bugs.Borstal
Z
1

This is not an answer to the specific question, as that has already been answered, but is instead an alternative in light of the stated goal.

Ruby's Logger already has a block syntax, where the block is only evaluated iff the log level would print the statement.

logger.debug { puts "Do not evaluate this!"; "My String" }

If the log level is debug or higher, then the block will be evaluated, and the string it returns will be logged. If the log level is less than debug the statement will not be evaluated at all.

TL;DR: Pass expensive log statements to your logger inside blocks.

Zebu answered 7/11, 2023 at 5:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.