Bug #20440
closed`super` from child class duplicating a keyword argument as a positional Hash
Description
Apologies for the verbose title, but that's the specific set of conditions that AFAICT are required to reproduce the bug!
Here's the simplest setup I can reproduce:
class Base
def foo(*args, x: 1)
puts "Base: calling foo with args: #{args}, x: #{x}"
end
def foo!(x: 1)
puts "Base: calling foo! with x: #{x}"
foo(x: x)
end
end
class Child < Base
def foo(*)
puts "Child: calling foo"
super
end
end
When I call Child.new.foo!
, I expect it to call the base class method foo!
, which will use the default keyword arg x: 1
; then the child method foo
with x: 1
, and finally the base method foo
with x: 1
. However, this is not what I observe:
Child.new.foo!
Base: calling foo! with x: 1
Child: calling foo
Base: calling foo with args: [{:x=>1}], x: 1
So when the child foo
method called super
, it passed not only x: 1
as a keyword arg, but also {x: 1}
as a Hash positional arg to the super method.
This is breaking my upgrade to Ruby 3.0 as I have a similar setup but without the *args
param, this I am getting the error "wrong number of arguments (given 1, expected 0)".
Updated by ozydingo (Andrew Schwartz) 8 months ago · Edited
In fact it seems we can simplify this to just calling Child.new.foo(x: 1)
; no need for the base class foo!
method.
Child.new.foo(x: 1)
Child: calling foo
Base: calling foo with args: [{:x=>1}], x: 1
Apologies if I'm misunderstanding *
and super
, but my understanding is super
(without specifying arguments) should be passing all args as they are to the super method, and *
should accept any combination of args, is that correct?
For example, defining the child class as
class Child < Base
def foo(x: 1)
super
end
end
produces my expected behavior:
Base: calling foo with args: [], x: 1
It is surprising, then, that using *
in the parameter list instead of explicitly naming the keyword arg causes the same call to pass both a Hash and a keyword arg to super
.
Updated by nobu (Nobuyoshi Nakada) 8 months ago
You need to make foo
ruby2_keywords
to let it work as same as 2.7 or earlier.
class Child < Base
ruby2_keywords def foo(*)
puts "Child: calling foo"
super
end
end
Child.new.foo!
Base: calling foo! with x: 1
Child: calling foo
Base: calling foo with args: [], x: 1
Note that require 'ruby2_keyword'
is necessary before ruby 2.7.
Updated by Eregon (Benoit Daloze) 8 months ago
See https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/
it also explains (*, **)
and (...)
which are better if you don't need compatibility with Ruby < 2.7.
Updated by Eregon (Benoit Daloze) 8 months ago
- Status changed from Open to Closed
Updated by ozydingo (Andrew Schwartz) 8 months ago
Thanks both. I understand that Ruby 3 requires explicit handling of keyword arguments. What still seems off to me is that super
is modifying the arguments. The child method is being passed a keyword argument, and super
is forwarding keywords arguments and a Hash positional argument. Should it not be the case that either the method defined with only *
does not accept keyword arguments or that super
preserves the form of the arguments that were passed?
Updated by ozydingo (Andrew Schwartz) 8 months ago
- Subject changed from `super` from child class passing keyword arg as Hash if in a method with passthrough args called from base class to `super` from child class duplicating a keyword argument as a positional Hash
Updated by zverok (Victor Shepelev) 8 months ago
What still seems off to me is that super is modifying the arguments.
If I understand correctly, what “modifies” the argument is child’s foo
signature:
def foo(*) # <= this says “accept only positional args” (implicitly converting keyword ones to hash)
super # <= this implicitly has a signature same as foo: super(*), passing only positional ones to the parent
end
The way to “fix” the code (if you own it) is this:
class Child < Base
def foo(*, **) # the declaration that leaves keyword ones and positional ones separated
puts "Child: calling foo"
super # super is implicitly called as super(*, **), passing them separately
end
end
The printed output:
Base: calling foo! with x: 1
Child: calling foo
Base: calling foo with args: [], x: 1
Updated by ozydingo (Andrew Schwartz) 8 months ago
Ok I see it now; super
isn't passing the args as both forms, it's passing only as a positional Hash. The x: 1
is coming from my default kwarg, which I was blinded to as I attempted to reduce the example to a general form. Thanks all!