Search code examples
rubykeyword-argumentruby-2.6ruby-2.7

Different behavior of `public_send` in Ruby 2.6 / 2.7


class A
  def a
    1
  end
end

a = A.new
x = {}

a.a(**x) # => 1 in both Ruby 2.6 and 2.7

a.public_send(:a, **x) # => 1 in Ruby 2.7

In Ruby 2.6, however:

ArgumentError: wrong number of arguments (given 1, expected 0) 

Is this a bug in pre-2.7 public_send/send/__send__? What would you suggest to overcome this difference?

You can check this failing live here.


Solution

  • In Ruby 2.6 and before, the **argument syntax was mostly (but not entirely) syntactic sugar for a passed hash. This was done to keep the convention to pass a variable hash as the last argument to a method valid.

    With Ruby 2.7, keyword arguments however were semantically updated and do not map to a hash parameter anymore. Here, the keyword arguments are handled from the positional arguments.

    In Ruby 2.6 and before, the following two method definitions where (for many aspects at least) equivalent:

    def one(args={})
      #...
    end
    
    def two(**args)
      #...
    end
    

    In both cases, you could pass either a verbatim hash or a splatted hash with the same result:

    arguments = {foo: :bar}
    
    one(arguments)
    one(**arguments)
    
    two(arguments)
    two(**arguments)
    

    With Ruby 2.7 however, you should pass the keyword arguments as such (the previous behavior still works but is deprecated with a warning). Thus, the call to two(arguments) will result in a deprecation warning in 2.7 and will become invalid in Ruby 3.0.

    Internally, a splatted hash argument (which passes keyword arguments to a method) thus results in an empty keyword argument list in Ruby 2.7, but a positional argument with an empty Hash in 2.6.

    You can see what happens here in detail by verifying how Ruby interprets arguments to its public_send method. In Ruby 2.6 and earlier, the method has effectively the following interface:

    def public_send26(method_name, *args, &block);
      p method_name
      p args
    
      # we then effectively call
      #    self.method_name(*args, &block)
      # internally from C code
    
      nil
    end
    

    When calling this method in Ruby 2.6 as public_send26(:a, **{}), you will see that the keyword arguments are again "wrapped" in a Hash:

    :a
    [{}]
    

    With Ruby 2.7, you have the following effective interface instead:

    def public_send27(method_name, *args, **kwargs, &block);
      p method_name
      p args
      p **kwargs
    
      # Here, we then effectively call
      #    self.method_name(*args, **kwargs, &block)
      # internally from C code
    
      nil
    end
    

    You can see that the keyword arguments are separately handled and retained as keyword arguments in Ruby 2.7, rather than they being handled as a regular positional Hash argument to the method as was done in Ruby 2.6 and earlier.

    Ruby 2.7 still contains fallback behavior so that code expecting the Ruby 2.6 behavior still works (although with a warning). In Ruby 3.0, you HAVE to strictly separate keyword arguments and positional arguments. You can find some additional description of these changes in a news entry on ruby-lang.org.