Changes to positional and keyword args in Ruby 3.0

Ruby 3.0 introduced a breaking change in how it treats keyword arguments.

There is an excellent blog post Show archive.org snapshot on the official Ruby blog going into the details. You should probably read that, but here is a slightly abbreviated version:

What changed

When the last argument of a method call is a Hash, Ruby < 3 automatically converted it to to Keyword Arguments. If you call a method in Ruby >= 3 that accepts keyword arguments, either explicitly or via **args, you cannot call it with a hash. I.e.

def explicit_kwargs(x:, y:)
  # ...
end

def splat_kwargs(**kwargs)
  # ...
end

def no_kwargs(*args)
  # ...
end

explicit_kwargs(x: 1, y: 2)     # works
explicit_kwargs({x: 1, y: 2})   # raises an ArgumentError
explicit_kwargs(**{x: 1, y: 2}) # works

splat_kwargs(x: 1, y: 2)        # works
splat_kwargs({x: 1, y: 2})      # raises an ArgumentError 
splat_kwargs(**{x: 1, y: 2})    # works

no_kwargs(x: 1, y: 2)           # works, args = [{x: 1, y: 2}]
no_kwargs({x: 1, y: 2})         # also works!

Why it was changed

It was felt that the automatic conversion lead to too many unexpected edge cases and bugs, such as this one.

What will break

The change will be felt most in code that is supposed to work across Ruby 2 and 3, especially when using delegation.

In the olden times, Ruby delegation would be done like this:

def wrapper(*args, &block)
  # do something
  wrapped_method(*args, &block)
end

In Ruby 3, this will only work if wrapped_method happens to not accept keyword arguments. If it does, this will break. The correct way to do delegation in Ruby 3 is

def wrapper(*args, **kwargs, &block)
  # do something
  wrapped_method(*args, **kwargs, &block)
end

However, this style will not work correctly in older Rubies. There are differences between Ruby 2.7, and 2.6 and lower, but the effect is that the wrapped_method may end up with an extra or missing empty hash.

To achieve delegation across Ruby versions, if the target method (might) accept keyword args, the recommended way is

ruby2_keywords def wrapper(*args, &block) # You cannot accept **kwargs of any kind here!
  # do something
  wrapped_method(*args, &block)
end

ruby2_keywords(method_name) was introduced in 2.7. It does some stupidly complex stuff Show archive.org snapshot , but basically makes things just work.

In Ruby 2.6 and below you can add the ruby2_keywords-Gem, which will polyfill the method with a noop (since in Ruby 2.6, this style of delegation always works fine anyways).

The "forward everything" syntax

Starting with Ruby 2.7 there is also the following syntax to "forward" everything. I don't consider it very useful, since it is not available on older Rubies, and it prevents you from doing anything with the arguments before forwarding, but it works like this:

def wrapper(...)
  wrapped_method(...)
end
Tobias Kraze About 3 years ago