Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 254
Kyle E. Mitchell Avatar asked Apr 21 '10 05:04

Kyle E. Mitchell


4 Answers

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>
like image 171
4 revs Avatar answered Nov 17 '22 09:11

4 revs


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.

like image 29
Marc-André Lafortune Avatar answered Nov 17 '22 09:11

Marc-André Lafortune


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

like image 11
Graham Ashton Avatar answered Nov 17 '22 10:11

Graham Ashton


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
like image 3
hurikhan77 Avatar answered Nov 17 '22 10:11

hurikhan77