Ruby - Keyword Arguments - Can you treat all of the keyword arguments as a hash? How?
Asked Answered
W

6

35

I have a method that looks like this:

def method(:name => nil, :color => nil, shoe_size => nil) 
  SomeOtherObject.some_other_method(THE HASH THAT THOSE KEYWORD ARGUMENTS WOULD MAKE)
end

For any given call, I can accept any combination of optional values. I like the named arguments, because I can just look at the method's signature to see what options are available.

What I don't know is if there is a shortcut for what I have described in capital letters in the code sample above.

Back in the olden days, it used to be:

def method(opts)
  SomeOtherObject.some_other_method(opts)
end

Elegant, simple, almost cheating.

Is there a shortcut for those Keyword Arguments or do I have to reconstitute my options hash in the method call?

Wilburwilburn answered 25/2, 2014 at 21:32 Comment(0)
D
23

Yes, this is possible, but it's not very elegant.

You'll have to use the parameters method, which returns an array of the method's parameters and their types (in this case we only have keyword arguments).

def foo(one: 1, two: 2, three: 3)
  method(__method__).parameters
end  
#=> [[:key, :one], [:key, :two], [:key, :three]]

Knowing that, there's various ways how to use that array to get a hash of all the parameters and their provided values.

def foo(one: 1, two: 2, three: 3)
  params = method(__method__).parameters.map(&:last)
  opts = params.map { |p| [p, eval(p.to_s)] }.to_h
end
#=> {:one=>1, :two=>2, :three=>3}

So your example would look like

def method(name: nil, color: nil, shoe_size: nil)
  opts = method(__method__).parameters.map(&:last).map { |p| [p, eval(p.to_s)] }.to_h
  SomeOtherObject.some_other_method(opts)
end

Think carefully about using this. It's clever but at the cost of readability, others reading your code won't like it.

You can make it slightly more readable with a helper method.

def params # Returns the parameters of the caller method.
  caller_method = caller_locations(length=1).first.label  
  method(caller_method).parameters 
end

def method(name: nil, color: nil, shoe_size: nil)
  opts = params.map { |p| [p, eval(p.to_s)] }.to_h
  SomeOtherObject.some_other_method(opts)
end

Update: Ruby 2.2 introduced Binding#local_variables which can be used instead of Method#parameters. Be careful because you have to call local_variables before defining any additional local variables inside the method.

# Using Method#parameters
def foo(one: 1, two: 2, three: 3)
  params = method(__method__).parameters.map(&:last)
  opts = params.map { |p| [p, eval(p.to_s)] }.to_h
end
#=> {:one=>1, :two=>2, :three=>3}

# Using Binding#local_variables (Ruby 2.2+)
def bar(one: 1, two: 2, three: 3)
  binding.local_variables.params.map { |p|
    [p, binding.local_variable_get(p)]
  }.to_h
end
#=> {:one=>1, :two=>2, :three=>3}
Diaphane answered 24/10, 2014 at 16:22 Comment(3)
Is the issue we don't want to restate it as follows? def foo(one: 1, two: 2, three: 3) params = {one: one, two: two, three: three)} end Whats the benefit of writing it out like your examples? It seems confusing. Also another question of how legitimate are these implementation? Is this a good way to use named_params ?Madonnamadora
@Madonnamadora that's right, we don't want to do params = { ... } because then we're hardcoding the implementation and it becomes very coupled. The benefit of doing it like in my examples is that you can change the method signature and still automatically capture all keyword parameters. I'm not sure I understand your other question about named_params.Diaphane
Better to use binding.local_variable_get(p) instead of eval(p.to_s) when using local_variables, just to avoid that evil eval ...Bulter
C
11

Of course! Just use the double splat (**) operator.

def print_all(**keyword_arguments)
  puts keyword_arguments
end

def mixed_signature(some: 'option', **rest)
  puts some
  puts rest
end

print_all example: 'double splat (**)', arbitrary: 'keyword arguments'
# {:example=>"double splat (**)", :arbitrary=>"keyword arguments"}

mixed_signature another: 'option'
# option
# {:another=>"option"}

It works just like the regular splat (*), used for collecting parameters. You can even forward the keyword arguments to another method.

def forward_all(*arguments, **keyword_arguments, &block)
  SomeOtherObject.some_other_method *arguments,
                                    **keyword_arguments,
                                    &block
end
Cryptogam answered 25/2, 2014 at 21:34 Comment(2)
No, I am interested in gathering all of the optional, named Keyword Parameters into a hash. I am not trying to create a new options hash. I want a hash of {:name => val, :color => val, etc.}, which are named in the method signature.Wilburwilburn
Regardless of what your interested in, this is the best answer BY FAR.Metonym
L
2

How about the syntax below?

For it to work, treat params as a reserved keyword in your method and place this line at the top of the method.

def method(:name => nil, :color => nil, shoe_size => nil) 
  params = params(binding)

  # params now contains the hash you're looking for
end

class Object
  def params(parent_binding)
    params = parent_binding.local_variables.reject { |s| s.to_s.start_with?('_') || s == :params }.map(&:to_sym)

    return params.map { |p| [ p, parent_binding.local_variable_get(p) ] }.to_h
  end
end
Lapidify answered 13/5, 2017 at 6:58 Comment(2)
Why do you reject variables starting with _?Asel
An argument name with a leading underscore is the recommended way to designate an argument that will be unused in the method or block, per the Ruby Style Guide. In fact, Ruby treats variables named _ differently than other variables. See po-ru.com/diary/rubys-magic-underscore for more info. That line in Abdo's solution simply filters them out so they aren't passed on.Vespertilionine
W
1

I had some fun with this, so thanks for that. Here's what I came up with:

describe "Argument Extraction Experiment" do
  let(:experiment_class) do
    Class.new do
      def method_with_mixed_args(one, two = 2, three:, four: 4)
        extract_args(binding)
      end

      def method_with_named_args(one:, two: 2, three: 3)
        extract_named_args(binding)
      end

      def method_with_unnamed_args(one, two = 2, three = 3)
        extract_unnamed_args(binding)
      end

      private

      def extract_args(env, depth = 1)
        caller_param_names = method(caller_locations(depth).first.label).parameters
        caller_param_names.map do |(arg_type,arg_name)|
          { name: arg_name, value: eval(arg_name.to_s, env), type: arg_type }
        end
      end

      def extract_named_args(env)
        extract_args(env, 2).select {|arg| [:key, :keyreq].include?(arg[:type]) }
      end

      def extract_unnamed_args(env)
        extract_args(env, 2).select {|arg| [:opt, :req].include?(arg[:type]) }
      end
    end
  end

  describe "#method_with_mixed_args" do
    subject { experiment_class.new.method_with_mixed_args("uno", three: 3) }
    it "should return a list of the args with values and types" do
      expect(subject).to eq([
        { name: :one,    value: "uno", type: :req },
        { name: :two,    value: 2,     type: :opt },
        { name: :three,  value: 3,     type: :keyreq },
        { name: :four,   value: 4,     type: :key }
      ])
    end
  end

  describe "#method_with_named_args" do
    subject { experiment_class.new.method_with_named_args(one: "one", two: 4) }
    it "should return a list of the args with values and types" do
      expect(subject).to eq([
        { name: :one,    value: "one", type: :keyreq },
        { name: :two,    value: 4,     type: :key },
        { name: :three,  value: 3,     type: :key }
      ])
    end
  end

  describe "#method_with_unnamed_args" do
    subject { experiment_class.new.method_with_unnamed_args(2, 4, 6) }
    it "should return a list of the args with values and types" do
      expect(subject).to eq([
        { name: :one,    value: 2,  type: :req },
        { name: :two,    value: 4,  type: :opt },
        { name: :three,  value: 6,  type: :opt }
      ])
    end
  end
end

I chose to return an array, but you could easily modify this to return a hash instead (for instance, by not caring about the argument type after the initial detection).

Wakerife answered 16/12, 2014 at 18:30 Comment(0)
M
1

@Dennis 's answer is useful and educational. However, I noticed that Binding#local_variables will return all the local variables, regardless of when local_variables is executed:

def locals_from_binding(binding_:)
  binding_.local_variables.map { |var|
    [var, binding_.local_variable_get(var)]
  }.to_h
end

def m(a:, b:, c:)
  args = locals_from_binding(binding_: binding)
  pp args

  d = 4
end

m(a: 1, b: 3, c: 5)
# Prints:
#   {:a=>1, :b=>3, :c=>5, :args=>nil, :d=>nil}
# Note the presence of :d

I propose a hybrid solution:

def method_args_from_parameters(binding_:)
  method(caller_locations[0].label)
  .parameters.map(&:last)
  .map { |var|
    [var, binding_.local_variable_get(var)]
  }.to_h
end

def m(a:, b:, c:)
  args = method_args_from_parameters(binding_: binding)
  pp args

  d = 4
end

m(a: 1, b: 3, c: 5)
# Prints:
#   {:a=>1, :b=>3, :c=>5}
# Note the absence of :d
Mireillemireles answered 27/2, 2020 at 15:40 Comment(0)
U
0

Here is an example to get both arguments and keyword arguments regardless of named or * or ** form.

def print_args_and_kwargs(a, *arguments, foo:, **options)
  method(__method__).parameters.each_with_object({ args: [], kwargs: {} }) do |(type, name), memo|
    case type
    when :req, :opt
      memo[:args] << binding.local_variable_get(name)
    when :rest
      memo[:args].concat binding.local_variable_get(name)
    when :keyreq, :key
      memo[:kwargs][name] = binding.local_variable_get(name)
    when :keyrest
      memo[:kwargs].merge! binding.local_variable_get(name)
    end
  end.tap { pp _1 }
end
print_args_and_kwargs(1, 2, 3, foo: 1, bar: 2)

# {:args=>[1, 2, 3], :kwargs=>{:foo=>1, :bar=>2}}

To make it reusable by mix-in:

module Args
  private

  def args_and_kwargs(binding)
    method(caller_locations(1, 1).first.label).parameters.each_with_object([[], {}]) do |(type, name), memo|
      case type
      when :req, :opt
        memo.first << binding.local_variable_get(name)
      when :rest
        memo.first.concat binding.local_variable_get(name)
      when :keyreq, :key
        memo.last[name] = binding.local_variable_get(name)
      when :keyrest
        memo.last.merge! binding.local_variable_get(name)
      end
    end
  end
end

class A
  include Args

  def a(a, b = 2, *arguments, foo:, bar: 4, **options)
    args, kwargs = args_and_kwargs(binding)
    pp args, kwargs
  end
end
A.new.a(1, foo: 3)
# [1, 2]
# {:foo=>3, :bar=>4}
Unalloyed answered 29/8, 2023 at 3:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.