In Ruby 4.0, how do you forward positional arguments, keyword arguments and a block through public_send to a method chosen at run time?
answer
- three channels, not one
- name is send's first argument
- ... with a leading name
- *args alone turns keywords into a Hash
- dropped block fails silently
basics
~20 sCollect and re-pass all three channels: def run(name, *args, **opts, &block) with public_send(name, *args, **opts, &block), or def run(name, ...) with public_send(name, ...). Collecting only *args turns keywords into a positional Hash since Ruby 3.0.
solid answer
~40 s`public_send` and `send` treat their first argument as the method name and pass everything else through unchanged: positionals, keywords as keywords, and the block attached to the call. So the forwarder only has to collect each channel: `def run(name, *args, **opts, &block)` and `@target.public_send(name, *args, **opts, &block)`, or the shorter `def run(name, ...)` with `@target.public_send(name, ...)`, which Ruby 3.0 allowed with a leading argument. Two ways it goes wrong: collecting only `*args` stores keywords as a Hash, so since Ruby 3.0's keyword separation a target like `def export(id, format:)` raises `ArgumentError`; and forgetting `&block` leaves the target with no block, which fails without any warning at the forwarder.
code
ruby · 17 linesclass Exporter
def export(id, format:)
rows = ["#{id}:#{format}"]
block_given? ? rows.map { yield it } : rows
end
end
class Router
def initialize(target) = @target = target
def call(name, ...)
@target.public_send(name, ...)
end
end
Router.new(Exporter.new).call(:export, 7, format: :csv) { it.upcase }
# => ["7:CSV"]go deeper
Recall the three channels, positionals, keywords and the block, and that ... forwards all of them.
Explain that send passes every channel through, so a lost keyword or block is the forwarder's fault, and why *args alone broke in Ruby 3.0.
Show that you review generic forwarders for dropped blocks and Hash-for-keyword mistakes, and choose ... with a named leading argument as the default shape.
Weigh forwarding helpers against explicit wrappers: forwarding hides the target's signature from readers and tooling.
## What "forwarding" means here A **forwarding method** receives a call and passes it on to another method whose name is chosen at run time with `public_send` or `send`. A command router, a dispatcher or a decorator all do this. The goal is that the target method sees **exactly** what the original caller passed. Ruby calls carry three separate channels, and in Ruby 4.0 each must be forwarded on purpose: - **positional arguments**; - **keyword arguments**, kept apart from positionals since Ruby 3.0; - **the block**, which is not part of either argument list. ## What send itself passes on `send` and `public_send` take the method name as their first argument and hand **everything else** to the target unchanged: positional arguments stay positional, keywords stay keywords (CRuby's `send` records whether keywords were given and passes that flag through), and a block attached to the `send` call is passed to the target. So the dispatch call is never the weak link; the forwarding method that **collects** the arguments is. ## The shapes that forward everything ```ruby class Router def initialize(target) = @target = target # explicit: every channel named def run(name, *args, **opts, &block) @target.public_send(name, *args, **opts, &block) end # ... with a leading name (leading arguments allowed since Ruby 3.0) def call(name, ...) @target.public_send(name, ...) end end ``` Both forward `run(:export, 42, format: :csv) { |row| ... }` intact. The `...` form is the shortest and keeps working if the target later gains a parameter. The method name is the **first positional** that `send` sees, so `def call(...)` with `@target.public_send(...)` also works: the caller's first argument becomes the name. ## How forwarders lose a channel | Forwarder body | What the target receives | |---|---| | `public_send(name, *args, **opts, &block)` | everything | | `public_send(name, ...)` | everything | | `public_send(name, *args)` with only `*args` collected | keywords as one trailing positional `Hash` | | `public_send(name, *args, **opts)` | positionals and keywords, **no block** | | `public_send(name, opts)` | a positional `Hash`, no keywords, no block | The third row is the classic Ruby 3 migration bug: ```ruby def run(name, *args) = @target.public_send(name, *args) # target: def export(id, format:) run(:export, 42, format: :csv) # ArgumentError: wrong number of arguments (given 2, expected 1; required keyword: format) ``` Because keywords were separated from positionals in Ruby 3.0, a method that collects only `*args` stores the keywords as a Hash inside `args`, and splatting `args` passes that Hash positionally. Ruby still has a `ruby2_keywords` marker for old-style `*args` delegation, but in Ruby 4.0-only code the fix is simply to collect `**opts` or use `...`. The fourth row fails quietly. Inside the target, `block_given?` is false, and a `yield` raises `LocalJumpError` ("no block given (yield)"). Nothing warns at the forwarding site. ## Names built at run time The name argument can be assembled from data, as a Symbol or a String: `public_send(:"export_#{format}", id, **opts, &block)`. The extra arguments do not change how the name is resolved; lookup, visibility and `method_missing` work exactly as for a call with no arguments. A forwarder that picks the name from outside input should check it against an explicit list before dispatch; the forwarding syntax does not provide that. ## Why the difference matters in practice Forwarders are usually generic: one method sits in front of many targets. A bug in it therefore shows up far away, in whichever target first takes a required keyword or yields to a block. The error message names the target (`export`, `each_row`), not the forwarder, so the first debugging step is to read the forwarding method's parameter list and check that each of the three channels is collected and re-passed. ## Checklist for a forwarding method 1. Collect positionals, keywords and the block, or use `...`. 2. Put the method name first; `send` treats its first argument as the name. 3. Expect `ArgumentError` at the target when a keyword arrived as a Hash. 4. Expect `LocalJumpError` or a silently skipped branch when the block was dropped. 5. Keep `public_send` unless reaching non-public methods is the intent. ## What an interviewer is listening for The expected answer names the three channels and shows either the explicit `*args, **opts, &block` signature or `...` with a leading name. A strong answer explains why `*args` alone broke in Ruby 3.0 and notes that a dropped block fails without any warning at the point where it was lost.
- If a block is attached to a send call itself, does the target method receive it?Yes. `obj.public_send(:each_row) { |r| ... }` hands the block to `each_row`, exactly as a direct call would; `send` passes the block it was given. The block is only lost when a forwarding method receives it and then calls `send` without re-passing it through `&block`, `&` or `...`.
- Does def call(...) with @target.public_send(...) still work, with no separate name parameter?Yes. `send` treats its first positional argument as the method name, so the caller's first argument becomes the name and the rest are forwarded. Naming it, as in `def call(name, ...)`, reads better and lets the forwarder check or log the name before dispatching.
saying these in an interview costs you the question
- *args alone forwards keyword arguments intact in Ruby 3 and 4
- public_send cannot pass a block, so it must be wrapped in a proc
- a block given to the forwarder reaches the target automatically
- send turns keyword arguments into a positional Hash
- ... forwarding cannot take a leading method-name argument