In Ruby 3.x, why does a wrapper that forwards only `*args` break keyword arguments, and what does `ruby2_keywords` change about that?
answer
- keywords collapse into a positional Hash
- a flag on that last Hash
- Hash.ruby2_keywords_hash?
- every hop marked since 3.2
- explicit **kwargs for Ruby 3 only
basics
~20 sA method with only *args has no keyword parameters, so incoming keywords become a positional Hash and are forwarded as one. ruby2_keywords flags that Hash so a later *args splat passes it as keywords again; Ruby-3-only code should forward **kwargs explicitly instead.
solid answer
~50 sGiven `def deliver(*args, &block) = @mailer.deliver(*args, &block)`, a call `deliver(to: x)` arrives with no keyword parameter to receive it, so Ruby converts the keywords into a Hash at the end of `args`. Forwarding `*args` then passes that Hash **positionally**, and since Ruby 3.0 a positional Hash never becomes keywords, so a target `def deliver(to:)` raises `ArgumentError`. `ruby2_keywords def deliver(*args, &block)` marks the method: when it is called with keywords, the final Hash is **flagged**, and a flagged Hash that is the last element of a `*args` splat is passed as keywords. `Hash.ruby2_keywords_hash?(args.last)` shows the flag. It exists for code that must run on both older and newer Rubies; since 3.2 every method in a forwarding chain needs it. For Ruby-3-only code, write `(*args, **kwargs, &block)` and forward both; the 3.2 release notes name that as the way to migrate away from it.
code
ruby · 21 linesclass Mailer
def deliver(to:, subject: "(no subject)") = "#{to}: #{subject}"
end
class PlainProxy
def initialize(target) = @target = target
def deliver(*args, &block) = @target.deliver(*args, &block)
end
class FlaggedProxy
def initialize(target) = @target = target
ruby2_keywords def deliver(*args, &block)
Hash.ruby2_keywords_hash?(args.last) # => true when called with keywords
@target.deliver(*args, &block)
end
end
PlainProxy.new(Mailer.new).deliver(to: "[email protected]")
# ArgumentError: wrong number of arguments (given 1, expected 0; required keyword: to)
FlaggedProxy.new(Mailer.new).deliver(to: "[email protected]")
# => "[email protected]: (no subject)"go deeper
Recall that a method taking only *args turns keywords into a Hash and that forwarding *args no longer turns it back in Ruby 3.
Explain how the ruby2_keywords flag works on the last element of args and why explicit **kwargs forwarding is safe even when no keywords are passed.
Diagnose a keyword-losing delegation chain, know the 3.2 every-hop rule and the serialization trap, and decide when a library may drop ruby2_keywords.
Set the support window for shared gems across Ruby versions, since that window decides how long ruby2_keywords shims must stay and when they can be removed.
## The problem: keywords lost in transit A **delegating wrapper** is a method that receives arguments and passes them on unchanged, typical of proxies, decorators, instrumentation and retry helpers. The classic pre-3.0 form captured everything with a rest parameter: ```ruby class InstrumentedMailer def initialize(mailer) = @mailer = mailer def deliver(*args, &block) @mailer.deliver(*args, &block) end end ``` Trace `InstrumentedMailer.new(mailer).deliver(to: "[email protected]")` on Ruby 3.x: 1. The wrapper's `deliver` declares **no keyword parameters**, so Ruby converts `to: "[email protected]"` into a Hash and appends it to `args`. 2. `@mailer.deliver(*args)` passes that Hash as an ordinary **positional** argument. 3. The target is `def deliver(to:, subject: "(no subject)")`. Since the Ruby 3.0 **keyword separation**, a positional Hash is never converted into keywords, so the call raises `ArgumentError: wrong number of arguments (given 1, expected 0; required keyword: to)`. Before 3.0 the last step silently converted the Hash back into keywords, so this pattern worked everywhere and is still common in older gems. ## Fix one: explicit keyword forwarding For code that only has to run on Ruby 3.0 or later, name the keywords and pass them on: ```ruby def deliver(*args, **kwargs, &block) @mailer.deliver(*args, **kwargs, &block) end ``` - Keywords land in `kwargs` and are splatted back as keywords. - A positional Hash stays positional, exactly as the caller meant. - When the caller passed no keywords, `**kwargs` is empty and an empty splat passes **nothing**, so targets without keyword parameters are unaffected. Ruby also offers anonymous and `...` forwarding forms for the same job; those belong to the syntax of rest parameters rather than to keywords. ## Fix two: `ruby2_keywords` `Module#ruby2_keywords` (private, called with method names, returning `nil`) marks a method that accepts `*args` but **no** explicit keywords or keyword splat: ```ruby ruby2_keywords def deliver(*args, &block) @mailer.deliver(*args, &block) end ``` `def` returns the method name as a Symbol, which is why the one-line form works. The mark changes step 1 above: when the method is called with keywords, the Hash appended to `args` carries a special **flag**. When a flagged Hash is the **last element** of a `*args` splat in a call that passes no explicit keywords or keyword splat, Ruby treats it as keywords again. Ruby's own `Delegator#method_missing` in `delegate.rb` is declared exactly this way. | Aspect | `**kwargs` forwarding | `ruby2_keywords` | |---|---|---| | Ruby versions | 3.0 and later | 2.7 and later, guarded with `respond_to?` before that | | How keywords travel | a separate Hash parameter | a flag on the last element of `args` | | Readable in the signature | yes | only via the marker | | Survives rebuilding the Hash | yes | no, a new Hash is unflagged | | Future | the normal form | documented as likely to be removed | ## Rules that bite in production - **Every hop needs the mark since Ruby 3.2.** Earlier versions kept the flag when the receiving method took `*args`; 3.2 fixed that as a bug, so a chain `foo(*args)` to `bar(*args)` to the real target needs `ruby2_keywords` on both `foo` and `bar`. - **Only splat-only methods qualify.** On a method that accepts keywords, a keyword splat or post arguments, or that has no `*args`, Ruby warns `Skipping set of ruby2_keywords flag` and leaves it unmarked. - **The flag lives on one Hash object.** Serializing arguments, for example to a job queue, and reading them back yields an unflagged Hash. `Hash.ruby2_keywords_hash(hash)` returns a flagged copy for such deserialization, and `Hash.ruby2_keywords_hash?(hash)` checks for the flag; the docs reserve both for debugging and serialization. - **Empty keywords are dropped.** Since 3.0, a marked method no longer keeps an empty keyword splat as an empty Hash in `args`. - **Procs too.** `Proc#ruby2_keywords` marks a block-based delegator the same way, as `delegate.rb` does for its generated lambdas. ## Choosing between them Use `**kwargs` forwarding in any code that targets Ruby 3 and later, which is every application pinned to Ruby 4.0. Keep `ruby2_keywords` only in libraries that still promise to run on older Rubies, guarded with `respond_to?(:ruby2_keywords, true)`, and plan to delete it when that support ends.
- A three-level delegation chain worked on Ruby 3.1 and breaks on 3.2 with only the outer method marked; why?Up to 3.1, Ruby kept the flag on the Hash when it reached another method taking `*args`, even an unmarked one. Ruby 3.2 treated that as a bug: every method that forwards keywords through `*args` must now carry `ruby2_keywords` itself. Mark each hop, or better, rewrite each hop with explicit `**kwargs` forwarding.
- How do you find which wrapper is dropping the keywords in a large call chain?Take the failing `ArgumentError`, go to the last method that must receive keywords and inspect the call chain there, for example with `caller`. Check each method or block on that chain that forwards `*args`: it must be marked with `ruby2_keywords` or rewritten to take and pass `**kwargs`. `Hash.ruby2_keywords_hash?(args.last)` inside a hop shows whether the flag survived to that point.
Forwarding keywords through *args is like passing on a parcel marked for signature only. A depot trained to read the mark (a method marked with ruby2_keywords) keeps the sticker on when it forwards the parcel; an untrained depot repacks it as ordinary mail, and the recipient that accepts only signed deliveries refuses it. Every depot on the route has to be trained, not just the first.
saying these in an interview costs you the question
- Forwarding *args passes keywords through unchanged in Ruby 3.
- ruby2_keywords is the recommended long-term way to forward keywords.
- Marking the outermost wrapper is enough for a whole delegation chain.
- ruby2_keywords can be applied to a method that already declares **kwargs.
- An empty **kwargs splat passes an empty Hash to targets without keywords.