开发者

Initialize a Ruby class from an arbitrary hash, but only keys with matching accessors

Is there a simple way to list the accessors/readers that have been set in a Ruby Class?

开发者_StackOverflow
class Test
  attr_reader :one, :two

  def initialize
    # Do something
  end

  def three
  end
end

Test.new
=> [one,two]

What I'm really trying to do is to allow initialize to accept a Hash with any number of attributes in, but only commit the ones that have readers already defined. Something like:

def initialize(opts)
  opts.delete_if{|opt,val| not the_list_of_readers.include?(opt)}.each do |opt,val|
    eval("@#{opt} = \"#{val}\"")
  end
end

Any other suggestions?


This is what I use (I call this idiom hash-init).

 def initialize(object_attribute_hash = {})
  object_attribute_hash.map { |(k, v)| send("#{k}=", v) }
 end

If you are on Ruby 1.9 you can do it even cleaner (send allows private methods):

 def initialize(object_attribute_hash = {})
  object_attribute_hash.map { |(k, v)| public_send("#{k}=", v) }
 end

This will raise a NoMethodError if you try to assign to foo and method "foo=" does not exist. If you want to do it clean (assign attrs for which writers exist) you should do a check

 def initialize(object_attribute_hash = {})
  object_attribute_hash.map do |(k, v)| 
    writer_m = "#{k}="
    send(writer_m, v) if respond_to?(writer_m) }
  end
 end

however this might lead to situations where you feed your object wrong keys (say from a form) and instead of failing loudly it will just swallow them - painful debugging ahead. So in my book a NoMethodError is a better option (it signifies a contract violation).

If you just want a list of all writers (there is no way to do that for readers) you do

 some_object.methods.grep(/\w=$/)

which is "get an array of method names and grep it for entries which end with a single equals sign after a word character".

If you do

  eval("@#{opt} = \"#{val}\"")

and val comes from a web form - congratulations, you just equipped your app with a wide-open exploit.


You could override attr_reader, attr_writer and attr_accessor to provide some kind of tracking mechanism for your class so you can have better reflection capability such as this.

For example:

class Class
  alias_method :attr_reader_without_tracking, :attr_reader
  def attr_reader(*names)
    attr_readers.concat(names)
    attr_reader_without_tracking(*names)
  end

  def attr_readers
    @attr_readers ||= [ ]
  end

  alias_method :attr_writer_without_tracking, :attr_writer
  def attr_writer(*names)
    attr_writers.concat(names)
    attr_writer_without_tracking(*names)
  end

  def attr_writers
    @attr_writers ||= [ ]
  end

  alias_method :attr_accessor_without_tracking, :attr_accessor
  def attr_accessor(*names)
    attr_readers.concat(names)
    attr_writers.concat(names)
    attr_accessor_without_tracking(*names)
  end
end

These can be demonstrated fairly simply:

class Foo
  attr_reader :foo, :bar
  attr_writer :baz
  attr_accessor :foobar
end

puts "Readers: " + Foo.attr_readers.join(', ')
# => Readers: foo, bar, foobar
puts "Writers: " + Foo.attr_writers.join(', ')
# => Writers: baz, foobar


Try something like this:

class Test
  attr_accessor :foo, :bar

  def initialize(opts = {})
    opts.each do |opt, val|
      send("#{opt}=", val) if respond_to? "#{opt}="
    end
  end
end

test = Test.new(:foo => "a", :bar => "b", :baz => "c")

p test.foo # => nil
p test.bar # => nil
p test.baz # => undefined method `baz' for #<Test:0x1001729f0 @bar="b", @foo="a"> (NoMethodError)

This is basically what Rails does when you pass in a params hash to new. It will ignore all parameters it doesn't know about, and it will allow you to set things that aren't necessarily defined by attr_accessor, but still have an appropriate setter.

The only downside is that this really requires that you have a setter defined (versus just the accessor) which may not be what you're looking for.


Accessors are just ordinary methods that happen to access some piece of data. Here's code that will do roughly what you want. It checks if there's a method named for the hash key and sets an accompanying instance variable if so:

def initialize(opts)
  opts.each do |opt,val|
    instance_variable_set("@#{opt}", val.to_s) if respond_to? opt
  end
end

Note that this will get tripped up if a key has the same name as a method but that method isn't a simple instance variable access (e.g., {:object_id => 42}). But not all accessors will necessarily be defined by attr_accessor either, so there's not really a better way to tell. I also changed it to use instance_variable_set, which is so much more efficient and secure it's ridiculous.


There's no built-in way to get such a list. The attr_* functions essentially just add methods, create an instance variable, and nothing else. You could write wrappers for them to do what you want, but that might be overkill. Depending on your particular circumstances, you might be able to make use of Object#instance_variable_defined? and Module#public_method_defined?.

Also, avoid using eval when possible:

def initialize(opts)
  opts.delete_if{|opt,val| not the_list_of_readers.include?(opt)}.each do |opt,val|
    instance_variable_set "@#{opt}", val
  end
end


You can look to see what methods are defined (with Object#methods), and from those identify the setters (the last character of those is =), but there's no 100% sure way to know that those methods weren't implemented in a non-obvious way that involves different instance variables.

Nevertheless Foo.new.methods.grep(/=$/) will give you a printable list of property setters. Or, since you have a hash already, you can try:

def initialize(opts)
  opts.each do |opt,val|
    instance_variable_set("@#{opt}", val.to_s) if respond_to? "#{opt}="
  end
end
0

上一篇:

下一篇:

精彩评论

暂无评论...
验证码 换一张
取 消

最新问答

问答排行榜