Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I add named parameters in a subclass or change their default in Ruby 2.2?

Tags:

ruby

ruby-2.2

This question is about Ruby 2.2.

Let's say I have a method which takes positional and named arguments.

class Parent
  def foo(positional, named1: "parent", named2: "parent")
    puts positional.inspect
    puts named1.inspect
    puts named2.inspect
  end
end

A subclass wants to both override some defaults, and add its own named arguments. How would I best do this? Ideally, it would not have to know details of the signature of the parent, in case the parent wants to add some optional positional arguments. My first attempt was this.

class Child < Parent
  def foo(*args, named1: "child", named3: "child" )
    super
  end
end

But that blows up because the unknown named3: is passed to the parent.

Child.new.foo({ this: 23 })

/Users/schwern/tmp/test.rb:10:in `foo': unknown keyword: this (ArgumentError)
        from /Users/schwern/tmp/test.rb:15:in `<main>'

I tried explicitly passing arguments to super, but that didn't work either. It seems the first positional argument is getting treated as a named one.

class Child < Parent
  def foo(*args, named1: "child", named3: "child" )
    super(*args, named1: "child")
  end
end

Child.new.foo({ this: 23 })

/Users/schwern/tmp/test.rb:10:in `foo': unknown keyword: this (ArgumentError)
        from /Users/schwern/tmp/test.rb:15:in `<main>'

I can make Child know about the first positional parameter, that works...

class Child < Parent
  def foo(arg, named1: "child", named3: "child" )
    super(arg, named1: "child")
  end
end

Child.new.foo({ this: 23 })
Parent.new.foo({ this: 23 })

{:this=>23}
"child"
"parent"
{:this=>23}
"parent"
"parent"

...until I pass in a named parameter.

Child.new.foo({ this: 23 }, named2: "caller")
Parent.new.foo({ this: 23 }, named2: "caller")

/Users/schwern/tmp/test.rb:10:in `foo': unknown keyword: named2 (ArgumentError)
        from /Users/schwern/tmp/test.rb:15:in `<main>'

How do I make this work and retain the benefit of named parameter checks? I'm open to turning the positional parameter into a named one.

like image 965
Schwern Avatar asked May 01 '15 19:05

Schwern


2 Answers

The problem here is that since the parent doesn't know anything about the child's arguments, it has no way of knowing whether the first argument you pass to it was meant to be a positional argument, or whether it was intended to provide keyword arguments to the parent method. This is because of a historical feature where Ruby allowed hashes to be passed as keyword-argument style parameters. For example:

def some_method(options={})
  puts options.inspect
end

some_method(arg1: "Some argument", arg2: "Some other argument")

Prints:

{:arg1=>"Some argument", :arg2=>"Some other argument"}

If Ruby disallowed that syntax (which would break backwards compatibility with existing programs), you could write your child method like this using the double splat operator:

class Child < Parent
  def foo(*args, named1: "child", named2: "child", **keyword_args)
    puts "Passing to parent: #{[*args, named1: named1, **keyword_args].inspect}"
    super(*args, named1: named1, **keyword_args)
  end
end

In fact, this works fine when you pass keyword arguments in addition to the positional one:

Child.new.foo({ this: 23 }, named2: "caller")

Prints:

Passing to parent: [{:this=>23}, {:named1=>"child"}]
{:this=>23}
"child"
"parent"

However, since Ruby can't tell the difference between positional arguments and keyword arguments when you only pass a single hash, Child.new.foo({ this: 23 }) results in this: 23 getting interpreted as a keyword argument by the child, and the parent method ends up interpreting both keyword arguments forwarded to it as a single positional argument (a hash) instead:

Child.new.foo({this: 23})

Prints:

Passing to parent: [{:named1=>"child", :this=>23}]
{:named1=>"child", :this=>23}
"parent"
"parent"

There are a few ways you can fix this but none of them are exactly ideal.

Solution 1

As you tried to do in your third example, you could tell the child that the first argument passed will always be a positional argument, and that the rest will be keyword args:

class Child < Parent
  def foo(arg, named1: "child", named2: "child", **keyword_args)
    puts "Passing to parent: #{[arg, named1: named1, **keyword_args].inspect}"
    super(arg, named1: named1, **keyword_args)
  end
end

Child.new.foo({this: 23})
Child.new.foo({this: 23}, named1: "custom")

Prints:

Passing to parent: [{:this=>23}, {:named1=>"child"}]
{:this=>23}
"child"
"parent"
Passing to parent: [{:this=>23}, {:named1=>"custom"}]
{:this=>23}
"custom"
"parent"

Solution 2

Switch entirely to using named arguments. This avoids the problem entirely:

class Parent
  def foo(positional:, named1: "parent", named2: "parent")
    puts positional.inspect
    puts named1.inspect
    puts named2.inspect
  end
end

class Child < Parent
  def foo(named1: "child", named3: "child", **args)
    super(**args, named1: named1)
  end
end

Child.new.foo(positional: {this: 23})
Child.new.foo(positional: {this: 23}, named2: "custom")

Prints:

{:this=>23}
"child"
"parent"
{:this=>23}
"child"
"custom"

Solution 3

Write some code to figure everything out programmatically.

This solution would likely be pretty complex and would depend a lot on exactly how you want it to work, but the idea is that you would use Module#instance_method, and UnboundMethod#parameters to read the signature of the parent's foo method and pass arguments to it accordingly. Unless you really need to do it this way, I'd recommend using one of the other solutions instead.

like image 59
Ajedi32 Avatar answered Sep 25 '22 04:09

Ajedi32


From what I can tell, you want to:

  • Use different defaults in the child's method for the same keyword arguments
  • Have the child's method have some separate keyword arguments which aren't passed to the parent
  • Not have to change the child's method definition when the signature of the parent's method definition changes

I think you problem can be solved by capturing keyword arguments which are to be passed directly to the parent's method in a separate variable in the child's method, kwargs, like this:

class Parent
  def foo(positional, parent_kw1: "parent", parent_kw2: "parent")
    puts "Positional: " + positional.inspect
    puts "parent_kw1: " + parent_kw1.inspect
    puts "parent_kw2: " + parent_kw2.inspect
  end
end

class Child < Parent
  def foo(*args, parent_kw1: "child", child_kw1: "child", **kwargs)
    # Here you can use `child_kw1`.
    # It will not get passed to the parent method.
    puts "child_kw1: " + child_kw1.inspect

    # You can also use `parent_kw1`, which will get passed
    # to the parent method along with any keyword arguments in
    # `kwargs` and any positional arguments in `args`.

    super(*args, parent_kw1: parent_kw1, **kwargs)
  end
end

Child.new.foo({this: 23}, parent_kw2: 'ABCDEF', child_kw1: 'GHIJKL')

This prints:

child_kw1: "GHIJKL"
Positional: {:this=>23}
parent_kw1: "child"
parent_kw2: "ABCDEF"
like image 31
Adrian Avatar answered Sep 23 '22 04:09

Adrian