DRY Ruby Initialization with Hash Argument

I find myself using hash arguments to constructors quite a bit, especially when writing DSLs for configuration or other bits of API that the end user will be exposed to. What I end up doing is something like the following:

class Example

    PROPERTIES = [:name, :age]

    PROPERTIES.each { |p| attr_reader p }

    def initialize(args)
        PROPERTIES.each do |p|
            self.instance_variable_set "@#{p}", args[p] if not args[p].nil?
        end
    end

end

Is there no more idiomatic way to achieve this? The throw-away constant and the symbol to string conversion seem particularly egregious.


Solution 1:

You don't need the constant, but I don't think you can eliminate symbol-to-string:

class Example
  attr_reader :name, :age

  def initialize args
    args.each do |k,v|
      instance_variable_set("@#{k}", v) unless v.nil?
    end
  end
end
#=> nil
e1 = Example.new :name => 'foo', :age => 33
#=> #<Example:0x3f9a1c @name="foo", @age=33>
e2 = Example.new :name => 'bar'
#=> #<Example:0x3eb15c @name="bar">
e1.name
#=> "foo"
e1.age
#=> 33
e2.name
#=> "bar"
e2.age
#=> nil

BTW, you might take a look (if you haven't already) at the Struct class generator class, it's somewhat similar to what you are doing, but no hash-type initialization (but I guess it wouldn't be hard to make adequate generator class).

HasProperties

Trying to implement hurikhan's idea, this is what I came to:

module HasProperties
  attr_accessor :props
  
  def has_properties *args
    @props = args
    instance_eval { attr_reader *args }
  end

  def self.included base
    base.extend self
  end

  def initialize(args)
    args.each {|k,v|
      instance_variable_set "@#{k}", v if self.class.props.member?(k)
    } if args.is_a? Hash
  end
end

class Example
  include HasProperties
  
  has_properties :foo, :bar
  
  # you'll have to call super if you want custom constructor
  def initialize args
    super
    puts 'init example'
  end
end

e = Example.new :foo => 'asd', :bar => 23
p e.foo
#=> "asd"
p e.bar
#=> 23

As I'm not that proficient with metaprogramming, I made the answer community wiki so anyone's free to change the implementation.

Struct.hash_initialized

Expanding on Marc-Andre's answer, here is a generic, Struct based method to create hash-initialized classes:

class Struct
  def self.hash_initialized *params
    klass = Class.new(self.new(*params))
  
    klass.class_eval do
      define_method(:initialize) do |h|
        super(*h.values_at(*params))
      end
    end
    klass
  end
end

# create class and give it a list of properties
MyClass = Struct.hash_initialized :name, :age

# initialize an instance with a hash
m = MyClass.new :name => 'asd', :age => 32
p m
#=>#<struct MyClass name="asd", age=32>

Solution 2:

The Struct clas can help you build such a class. The initializer takes the arguments one by one instead of as a hash, but it's easy to convert that:

class Example < Struct.new(:name, :age)
    def initialize(h)
        super(*h.values_at(:name, :age))
    end
end

If you want to remain more generic, you can call values_at(*self.class.members) instead.

Solution 3:

There are some useful things in Ruby for doing this kind of thing. The OpenStruct class will make the values of a has passed to its initialize method available as attributes on the class.

require 'ostruct'

class InheritanceExample < OpenStruct
end

example1 = InheritanceExample.new(:some => 'thing', :foo => 'bar')

puts example1.some  # => thing
puts example1.foo   # => bar

The docs are here: http://www.ruby-doc.org/stdlib-1.9.3/libdoc/ostruct/rdoc/OpenStruct.html

What if you don't want to inherit from OpenStruct (or can't, because you're already inheriting from something else)? You could delegate all method calls to an OpenStruct instance with Forwardable.

require 'forwardable'
require 'ostruct'

class DelegationExample
  extend Forwardable

  def initialize(options = {})
    @options = OpenStruct.new(options)
    self.class.instance_eval do
      def_delegators :@options, *options.keys
    end
  end
end

example2 = DelegationExample.new(:some => 'thing', :foo => 'bar')

puts example2.some  # => thing
puts example2.foo   # => bar

Docs for Forwardable are here: http://www.ruby-doc.org/stdlib-1.9.3/libdoc/forwardable/rdoc/Forwardable.html

Solution 4:

Given your hashes would include ActiveSupport::CoreExtensions::Hash::Slice, there is a very nice solution:

class Example

  PROPERTIES = [:name, :age]

  attr_reader *PROPERTIES  #<-- use the star expansion operator here

  def initialize(args)
    args.slice(PROPERTIES).each {|k,v|  #<-- slice comes from ActiveSupport
      instance_variable_set "@#{k}", v
    } if args.is_a? Hash
  end
end

I would abstract this to a generic module which you could include and which defines a "has_properties" method to set the properties and do the proper initialization (this is untested, take it as pseudo code):

module HasProperties
  def self.has_properties *args
    class_eval { attr_reader *args }
  end

  def self.included base
    base.extend InstanceMethods
  end

  module InstanceMethods
    def initialize(args)
      args.slice(PROPERTIES).each {|k,v|
        instance_variable_set "@#{k}", v
      } if args.is_a? Hash
    end
  end
end

Solution 5:

My solution is similar to Marc-André Lafortune. The difference is that each value is deleted from the input hash as it is used to assign a member variable. Then the Struct-derived class can perform further processing on whatever may be left in the Hash. For instance, the JobRequest below retains any "extra" arguments from the Hash in an options field.

module Message
  def init_from_params(params)
    members.each {|m| self[m] ||= params.delete(m)}
  end
end

class JobRequest < Struct.new(:url, :file, :id, :command, :created_at, :options)
  include Message

  # Initialize from a Hash of symbols to values.
  def initialize(params)
    init_from_params(params)
    self.created_at ||= Time.now
    self.options = params
  end
end