Andrew
Andrew

Reputation: 238667

How to define a Ruby Struct which accepts its initialization arguments as a hash?

I have a situation where I would like to create a class which accepts many arguments and has setters and getters in the fewest lines of code possible (for maintainability). I thought that using a Struct would be a good idea for this:

Customer = Struct.new(:id, :username, :first_name, :last_name, :address1, ...etc...)

Customer.new(123, 'joe', 'Joe', ...etc...)

However, I don't like having to know the exact order of the attributes. I prefer Ruby 2's keyword arguments feature:

class Customer
  attr_accessor :id, :username, :first_name, ...etc...
  def initialize(id:, username:, first_name:, last_name:, address1:, ...etc...)
    @id = id
    @username = username
    @first_name = first_name
    ...etc...
  end
end

Customer.new(id: 123, username: 'joe', first_name: 'Joe', ...etc...)

However, writing this all out requires a lot more code and is more tedious. Is there a way to achieve the same thing in a short-hand like the Struct?

Upvotes: 16

Views: 12857

Answers (7)

Dennis
Dennis

Reputation: 776

In addition, you can rely on the constructor of Struct itself.

def initialize(*args)
  super(*args)
  # put your magic here!
end

This way you avoid the side-affects of named parameters etc.

Upvotes: 3

bbozo
bbozo

Reputation: 7301

Variation on the theme, but a bit more refined, works in any ruby

class Struct
  module HashConstructable
    def from_hash hash
      rv = new
      hash.each do |k, v|
        rv.send("#{k}=", v)
      end
      rv
    end
    # alias_method :[], :from_hash
  end
end

and then

class MyStruct < Struct.new(:foo, :boo)
  extend Struct::HashConstructable
end

and you have best of both worlds this way - no funny name clashes and side-effects and it's clear want you want to do when you do it:

MyStruct.from_hash(foo: 'foo')

does exactly what you think it does. With a bit more possible side-effects but nicer syntax you can add the alias_method :[], :from_hash part, this allows you:

MyStruct[foo: 'foo']

this is also nice because it reminds (me) of the Hash[] constructor which creates a hash out of something that isn't hash.

Upvotes: 1

schpet
schpet

Reputation: 10620

In ruby 2.5 you can do the following:

Customer = Struct.new(
  :id, 
  :username,
  :first_name, 
  keyword_init: true
)

Customer.new(username: "al1ce", first_name: "alice", id: 123)
=> #<struct Customer id=123, username="al1ce", first_name="alice">

references:

Upvotes: 34

Sandy McPherson
Sandy McPherson

Reputation: 91

You can use Struct and reduce the amount of code to a minimum by adding a factory method (called build here) and if necessary a validate method to your struct

Struct.new("Example",:a,:b) do
    def build(a:, b:nil)
        s = Struct::Example.new(a,b)
        s.validate
        return s
    end
    def validate
        unless a == 'stuff' || a == 'nonsense'
            raise ValidationError, "broken"
        end
    end
 end

 m = Struct.Example.build(a: 'stuff')

where validate is intended to so something like check strings have certain values, rather than just relying on the required parameters check.

Now you only have to remember the order once, when you write the build method

Upvotes: 1

hjing
hjing

Reputation: 4982


If you don't care about performance, you can use an OpenStruct.

require 'ostruct'

user = OpenStruct.new(id: 1, username: 'joe', first_name: 'Joe', ...)
user.first_name

=> "Joe"

See this for more details.


It's entirely possible to make it a class and define methods on it:

class Customer < Openstruct
  def hello
    "world"
  end
end

joe = Customer.new(id: 1, username: 'joe', first_name: 'Joe', ...)
joe.hello

=> "world"

But again, because OpenStructs are implemented using method_missing and define_method, they are pretty slow. I would go with BroiSatse's answer. If you care about required parameters, you should so something along the lines of

def initialize(params = {})   
    if missing_required_param?(params)
      raise ArgumentError.new("Missing required parameter")   
    end   
    params.each do |k,v|
      send("#{k}=", v)   
    end 
end

def missing_required_params?(params)
  ...
end

Upvotes: 7

BroiSatse
BroiSatse

Reputation: 44675

Cant you just do:

def initialize(hash)
  hash.each do |key, value|
    send("#{key}=", value)
  end
end

UPDATE:

To specify default values you can do:

def initialize(hash)
  default_values = {
    first_name: ''
  }
  default_values.merge(hash).each do |key, value|
    send("#{key}=", value)
  end
end

If you want to specify that given attribute is required, but has no default value you can do:

def initialize(hash)
  requried_keys = [:id, :username]
  default_values = {
    first_name: ''
  }
  raise 'Required param missing' unless (required_keys - hash.keys).empty?
  default_values.merge(hash).each do |key, value|
    send("#{key}=", value)
  end
end

Upvotes: 15

Cary Swoveland
Cary Swoveland

Reputation: 110675

This is one approach you could use.

class A
  def initialize(h)
    h.each do |k, v|
      instance_variable_set("@#{k}", v)
      create_method("#{k}=") { |v|instance_variable_set("@#{k}", v ) }
      create_method("#{k}")  { instance_variable_get("@#{k}") }
    end 
  end
end    

def create_method(name, &block)
  self.class.send(:define_method, name, &block)
end

a = A.new(apple: 1, orange: 2)
a.apple     #=> 1
a.apple = 3
a.apple     #=> 3
a.orange    #=> 2

create_method is straight from the documentation for Module#define_method.

Upvotes: 0

Related Questions