29 April 2008 Eloy Duran and Manfred Stienstra

Eloy shows how to set up a class with default attributes in a single line of code.

Download episode

struct_with_default_attrs.rb

def Object(*attrs)
  attrs.flatten!
  
  superklass = attrs.shift if attrs.first.is_a?(Class)
  klass = (superklass.nil? ? Class.new : Class.new(superklass))
  
  default_values, attrs = attrs.first, attrs.first.keys if attrs.first.is_a? Hash
  
  # if the klass already contains @__attrs then create a new array, add the existing ones and then the new ones
  attrs = (klass.instance_variable_get(:@__attrs).dup + attrs).flatten unless klass.instance_variable_get(:@__attrs).nil?
  default_values = klass.instance_variable_get(:@__default_values).merge(default_values) unless klass.instance_variable_get(:@__default_values).nil?
  
  klass.instance_variable_set(:@__attrs, attrs)
  klass.instance_variable_set(:@__default_values, default_values)
  
  klass.class_eval do
    attr_accessor *attrs
    def initialize(*args)
      if args.empty?
        if default_values = self.class.instance_variable_get(:@__default_values)
         default_values.each { |attr, value| send("#{attr}=", value) }
       end
      else
        self.class.instance_variable_get(:@__attrs).each_with_index { |attr, index| send("#{attr}=", args[index]) }
      end
    end
    
    def self.inherited(klass)
      # inherit a clone of the @__attrs & @__default_values ivars
      klass.instance_variable_set(:@__attrs, @__attrs.dup)
      klass.instance_variable_set(:@__default_values, @__default_values.dup) if @__default_values
      
      eval "def #{klass.name}(*attrs); Object(#{klass.name}, attrs); end" unless klass.name.empty?
    end
  end
  klass
end

if $0 == __FILE__
  require 'rubygems'
  require "test/unit"
  require 'test/spec'
  
  class Foo < Object(:foo, :bar)
  end

  class Bar < Foo(:baz, :bla)
  end

  class Baz < Object(:foo => 'foo', :bar => 'bar')
  end

  class Daz < Baz(:baz => 'baz')
  end
  
  describe "Foo" do
  
    before do
      @klass = Object(:foo, :bar)
    end
  
    it "should return a new class which holds the attributes, that should be defined, in an array" do
      @klass.instance_variable_get(:@__attrs).should == [:foo, :bar]
    end
  
    it "should take the attributes as initialization params and assign them as instance variables" do
      should_respond_to_and_work(@klass, :foo, :bar)
    end
  
    it "should inherit the attributes defined in a superclass" do
      Foo.instance_variable_get(:@__attrs).should == [:foo, :bar]
    end
  
    it "should create a new method to create a subclass with extra attributes" do
      Bar.instance_variable_get(:@__attrs).should == [:foo, :bar, :baz, :bla]
    end
  
    it "should take the attributes as initialization params and assign them as instance variables in a subclass with extra attributes" do
      should_respond_to_and_work(Bar, :foo, :bar, :baz, :bla)
    end
  
    it "should also take a hash as default values" do
      baz = Baz.new
      [baz.foo, baz.bar].should == ['foo', 'bar']
    end
  
    it "should also take a hash as default values and add them to the ones of the superclass" do
      daz = Daz.new
      [daz.foo, daz.bar, daz.baz].should == ['foo', 'bar', 'baz']
    end
  
    private
  
    def should_respond_to_and_work(klass, *attrs)
      string_attrs = attrs.map { |a| a.to_s }
      obj = klass.new(*string_attrs)
    
      attrs.each_with_index do |attr, index|
        obj.should.respond_to(attr)
        obj.send(attr).should == string_attrs[index]
      end
    end
  end
end