Saturday, August 18, 2007

Ruby: initialize_with

In April I wrote about assigning instance variables in a constructor. The code I provided with that entry supports initializing your classes positionally or with a hash.

Since then, I've been thinking that a nice alternative might be a constructor that takes one argument without a name, but any additional arguments must be named (via a hash). Yes, I basically stole this concept from Smalltalk.

class Person
initialize_with :employee_id, :first_name, :last_name
attr_reader :employee_id, :first_name, :last_name
end

class Money
initialize_with :amount
attr_reader :amount
end

Below is a solution to making the above code execute as expected.

class Module
def initialize_with(*args)
first_arg = args.shift
define_method :initialize do |*arg|
instance_variable_set "@#{first_arg}", arg.shift
required_args = args.inject(first_arg.to_s) { |result, attribute| result << ", #{attribute}" }
raise ArgumentError.new("initialize requires #{required_args}") if args.any? && arg.empty?
args.each do |attribute|
raise ArgumentError.new("initialize requires #{required_args}") unless arg.first.has_key?(attribute)
instance_variable_set "@#{attribute}", arg.first[attribute]
end
end
end
end

One other small change that you might notice is that I've made all the arguments required. That's a preference that I might blog about later, but I think it should be easy enough to remove the raises if you want your options arguments to be optional.

For those interested, here's the code with tests.

class Module
def initialize_with(*args)
first_arg = args.shift
define_method :initialize do |*arg|
instance_variable_set "@#{first_arg}", arg.shift
required_args = args.inject(first_arg.to_s) { |result, attribute| result << ", #{attribute}" }
raise ArgumentError.new("initialize requires #{required_args}") if args.any? && arg.empty?
args.each do |attribute|
raise ArgumentError.new("initialize requires #{required_args}") unless arg.first.has_key?(attribute)
instance_variable_set "@#{attribute}", arg.first[attribute]
end
end
end
end

require 'test/unit'
require 'rubygems'
require 'dust'

class Person
initialize_with :employee_id, :first_name, :last_name
attr_reader :employee_id, :first_name, :last_name
end

class Money
initialize_with :amount
attr_reader :amount
end

unit_tests do
test "verify Person requires all options" do
assert_raises ArgumentError do
Person.new(2, :first_name => 'mike')
end
end

test "verify Person requires first arg" do
assert_raises ArgumentError do
Person.new(:first_name => 'mike', :last_name => 'ward')
end
end

test "verify Person employee_id" do
mike = Person.new(2, :first_name => 'mike', :last_name => 'ward')
assert_equal 2, mike.employee_id
end

test "verify Person first_name" do
mike = Person.new(2, :first_name => 'mike', :last_name => 'ward')
assert_equal 'mike', mike.first_name
end

test "verify Person last_name" do
mike = Person.new(2, :first_name => 'mike', :last_name => 'ward')
assert_equal 'ward', mike.last_name
end

test "verify Money amount" do
money = Money.new(10)
assert_equal 10, money.amount
end
end

5 comments:

  1. Anonymous12:56 PM

    DRY it up! Move attr_reader to initialize_with :)

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete
  3. See my post from June about a similar feature:

    Creating a field-initializing 'new' method

    I agree it would be a useful feature, but why pass a number to initialize_with? Can't you get the size from the hash args anyway?

    ReplyDelete
  4. Anonymous9:53 AM

    Hello Charles,

    The 2 is the (fake) employee_id, not the number of args.

    Cheers, Jay

    ReplyDelete
  5. Anonymous1:34 PM

    Pratik, I guess the reason why Jay didn't move attr_reader to initialize_with is that for the attributes some people may want to use only attr_reader, or only attr_writer, or attr_accessor. initialize_with shouldn't be the one making these decisions for the programmers and forcing attr_reader.

    ReplyDelete

Note: Only a member of this blog may post a comment.