简体   繁体   中英

Named arguments as local variables in Ruby

I find myself constantly writing what I see as unnecessary code in Ruby when using named arguments for methods.

Take for example the following code:

def my_method(args)
  orange = args[:orange]
  lemon = args[:lemon]
  grapefruit = args[:grapefruit]

  # code that uses 
  # orange, lemon & grapefruit in this format which is way prettier & concise than 
  # args[:orange] args[:lemon] args[:grapefruit]

  puts "my_method variables: #{orange}, #{lemon}, #{grapefruit}" 
end
my_method :orange => "Orange", :grapefruit => "Grapefruit"

What I really don't like about this code is that I am having to take the args and pass the values into local variables going against DRY principles and just generally taking up space in my methods. And if I don't use local variables and just refer to all variables with the args[:symbol] syntax then the code becomes somewhat illegible.

I have tried working up a solution to this but keeping hitting a brick wall as I don't know how to define local variables using eval in the scope of the method, or using any other technique. Here is one of many attempts below, which results in an error

def my_method_with_eval(args)
  method_binding = binding
  %w{ orange lemon grapefruit}.each { |variable| eval "#{variable} = args[:#{variable}]", method_binding; }

  # code that uses 
  # orange, lemon & grapefruit in this format which is way prettier & concise than 
  # args[:orange] args[:lemon] args[:grapefruit]

  puts "my_method_with_eval variables: #{orange}, #{lemon}, #{grapefruit}" 
end
my_method_with_eval :orange => "Orange", :grapefruit => "Grapefruit"

When running that code I simply get

NameError: undefined local variable or method 'orange' for main:Object method my_method_with_eval in named_args_to_local_vars at line at top level in named_args_to_local_vars at line 9

Anyone got any ideas how I could simplify this down somehow so that I don't have to start my named argument methods with loads of var=args[:var] code?

Thanks, Matthew O'Riordan

I don't believe there's any way to do this in Ruby (if anyone comes up with one, please let me know, and I'll update or delete this answer to reflect it!) - if a local variable hasn't been defined yet, there's no way to dynamically define it with the binding. You could conceivably do something like orange, lemon, grapefruit = nil before calling eval, but you may run into other problems - for instance, if args[:orange] is the string "Orange", you'll end up evaluating orange = Orange with your current implementation.

Here's something that could work, though, using the OpenStruct class from the standard library (by "could work", I mean "it's up to your sense of style whether a.orange is any nicer than args[:orange] "):

require 'ostruct'

def my_method_with_ostruct(args)
  a = OpenStruct.new(args)
  puts "my_method_with_ostruct variables: #{a.orange}, #{a.lemon}, #{a.grapefruit}"
end

If you don't need easy access to any state or methods on the receiver of this method, you could use instance_eval , as follows.

def my_method_with_instance_eval(args)
  OpenStruct.new(args).instance_eval do
    puts "my_method_with_instance_eval variables: #{orange}, #{lemon}, #{grapefruit}"
  end
end

You could even do something tricky with method_missing (see here for more) to allow access to the "primary" object, but the performance probably wouldn't be great.

All in all, I think it's probably most straightforward/readable to go with the less DRY initial solution that bothered you.

Merge of Greg's and Sand's answers:

require 'ostruct'

def my_method(args = {}) 
  with args do
    puts a
    puts b
  end 
end

def with(args = {}, &block)
  OpenStruct.new(args).instance_eval(&block)
end

my_method(:a => 1, :b => 2)

I found a discussion on this on ruby-talk-google and it seems to be an optimisation of the parser. Local variables are already figured out at runtime so that local_variables is already set at the beginning of the method.

def meth
  p local_variables
  a = 0
  p local_variables
end
meth
# =>
[:a]
[:a]

That way Ruby doesn't need to decide whether a is a method or a local variable or whatnot at runtime but can safely assume it is a local variable.

(For comparison: In Python locals() would be empty at the beginning of the function.)

At my blog (see link in user info), I just tried to address handling this problem neatly. I go into more detail there, but the core of my solution is the following helper method:

def collect_named_args(given, expected)
    # collect any given arguments that were unexpected
    bad = given.keys - expected.keys

    # if we have any unexpected arguments, raise an exception.
    # Example error string: "unknown arguments sonething, anyhting"
    raise ArgumentError,
        "unknown argument#{bad.count > 1 ? 's' : ''}: #{bad.join(', ')}",
        caller unless bad.empty?

    Struct.new(*expected.keys).new(
        *expected.map { |arg, default_value| 
            given.has_key?(arg) ? given[arg] : default_value
        }
    )
end # def collect_named_args

which is called as follows:

def foo(arguments = {})
    a = collect_named_args(arguments,
        something:  'nothing',
        everything: 'almost',
        nothing:     false,
        anything:    75)

    # Do something with the arguments 
    puts a.anything
end # def foo

I'm still trying to figure out if there is any way to get my results into local_variables or not - but as others have noted, Ruby doesn't want to do that. You could use the "with" trick, I suppose.

module Kernel
  def with(object, &block)
    object.instance_eval &block
  end
end

then

with(a) do
  # Do something with arguments (a)
  put anything
end

but that feels unsatisfactory for several reasons.

I like the above solution because it uses a Struct instead of an OpenStruct, which means one less require, and what you get back is set as far as what variables are being handled.

This doesn't solve the problem, but I tend to do

orange, lemon, grapefruit = [:orange, :lemon, :grapefruit].
map{|key| args.fetch(key)}

as it's pretty easy to copy and paste the orange lemon grapefruit bit.

If you find the colons too much work, you could do

orange, lemon, grapefruit = %w{orange, lemon, grapefruit}.
map{|str| str.gsub(",", "").to_sym}.map{|key| args.fetch(key)}

I found myself wondering how to do this myself today. Not only would I like to DRY up my code, but I'd like to have argument validation, too.

I came across a blog post by Juris Galang where he's explained a couple ways of handling it. He's has published a gem that encapsulates his ideas which looks interesting.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM