/ ruby

Passing Named Arguments to Ruby's Object Initialize

In Rails we can pass initial values to each Active Record instance and it'll know to assign them automatically to the newly created object. Here's a neat little trick to get the same functionality with normal ruby code.

A Simple Dog

As you remember we can use attr_accessor to produce a class with getter and setter methods. A first "generic" implementation could begin by passing a hash to initialize:

class Dog
  attr_accessor :name, :age

  def initialize(**opts)
    @name, @age = opts.values_at(:name, :age)
    validate!
  end

  def validate!
    raise 'Missing name' if name.nil?
    raise 'Missing age' if age.nil?
  end

  def to_s
    "#{name} is #{age} years old"
  end
end

d = Dog.new(name: 'rexy', age: 5)
puts d

The code works and prints "rexy is 5 years old" as expected. It's better than positional arguments especially when many arguments are used.

However there's still some annoyances: The attribute names are mentioned multiple times, and we'll need to write this code over and over again in every new class.

A Generic Superdog

When I started to think how to write a generic version of the code above the first challenge was to know which attribute accessors we need to make. Our generic module or base class has to work with many different concrete classes, but they each have different fields.

One ruby trick that helps here is that we can evaluate class related code at runtime. This means we can wait until initialize is called, and then create the attribute accessors right before control is passed to the concrete class.

Here's a simple module that implements this idea using class_eval:


module NamedInit
  def initialize(**opts)
    class <<self
      self
    end.class_eval do
      opts.keys.each {|k| attr_accessor k}
    end

    opts.keys.each do |k|
      self.send(:"#{k}=", opts[k])
    end

    validate!
  end
end

Now back to our Dog class, and it looks much better:

class Dog
  include NamedInit

  def validate!
    raise 'Missing name' if name.nil?
    raise 'Missing age' if age.nil?
  end

  def to_s
    "#{name} is #{age} years old"
  end
end

d = Dog.new(name: 'rexy', age: 5)
puts d

Rexy is still 5 years old, but now it doesn't have to define its own initialize of attr_accessor.

More Readable Dog

Although the above works provided a really easy way to write concrete classes, I don't think I'd be happy maintaining such code in the long term. The main problem is that you now can't look at a class to know which properties it has (Rails has that same problem).

Moving attr_accessor declaration back to the implementation fixes this, and also simplifies the module. Here's a final version of our dog's named arguments:

module NamedInit
  def initialize(**opts)
    opts.keys.each do |k|
      self.send(:"#{k}=", opts[k])
    end

    validate!
  end
end

class Dog
  include NamedInit
  attr_accessor :name, :age

  def validate!
    raise 'Missing name' if name.nil?
    raise 'Missing age' if age.nil?
  end

  def to_s
    "#{name} is #{age} years old"
  end
end

d = Dog.new(name: 'rexy', age: 5)
puts d

And this has the added benefit that only declared attributes can be passed to the constructor.