eigenclass logo
MAIN  Index  Search  Changes  PageRank  Login

Not quite a SuperStruct, maybe a SuperClass? automatic attributes and initialization

ruby-talk:192757 reminded me of SuperStruct. I was going to reply with a pointer to that library, but I realized that the usual

class MyClass < Struct.new(:a,:b)
  def initialize(*a)
    super
    # stuff
  end
end

idiom and SuperStruct still left an important part of the solution space uncovered.

When you inherit from a Struct, the resulting class doesn't use plain instance variables, so you're forced to use the accessors. SuperStruct addresses that while emulating Struct quite closely, and also introduces OpenStruct-like features, so you get hash access with numeric, String and Symbol keys, #members, #to_a, pretty printing... But what if you don't need all that?

I'm guessing the OP would have wanted to do (at least that's what I wanted):

class Foo < InitializedClass.new(:a, :b, :c)
  def initialize(*a)
    super
    @c ||= 10       # this is how we manage default values
  end
  
  def bar
    a + b * c
  end
end

Foo.new(1,2,3).bar                                 # => 7

And while we're at it, why not get named arguments for free too?

Foo.new(:a => 1, :b => 2).bar                      # => 21

This (and a few additional features) takes 56 lines in my first implementation, including #instance_exec, which I already wrote about.

Inheritance

One thing neither Struct nor SuperStruct can do is create a class derived from an existing one. Well, of course module inclusion is but another form of inheritance (and multiple too), but some times you might want the real thing, with matz's blessing.

Say you have

class Baz
  def initialize(foo, bar)
    @foo = a
    @bar = b
  end
end

and want to create a subclass with a number of attributes, but you also need to be able to run Baz#initialize. If we were just subclassing manually,

 super(whatever)

would do, but then there's no automatic accessors nor named arguments. Is it possible at all to get both at once?

This seemed tricky at first, but I managed to implement it so that you can do

InitializedClass.new(:my, :args, TheSuperClass){|sup| sup.call(whatever, you, want)}

where sup is a Proc which can be given the arguments to be passed to the #initialize method of the class you're deriving from.

class Bar < InitializedClass.new(:a, :b, :c, Baz){|sup| sup[@a * 2, @b + @c]}
  def compute
    @foo * @a + (@b - @c) * @bar
  end
end

b = Bar.new(1,2,3)
b.compute                                          # => -1

class Bar; attr_accessor :b end

b.b = 10
b.compute                                          # => 15


Not bad.

Implementation

I first wrote a few tests

require 'initializedclass'

require 'test/unit'
class Test_InitializedClass < Test::Unit::TestCase
  def test_basic_usage
    kl = InitializedClass.new(:a,:b,:c)
    foo = kl.new(1,2,3)
    assert_kind_of(kl, foo)
    assert_equal([1,2,3], [foo.a, foo.b, foo.c])
  end

  def test_inheritance_is_respected
    kl1 = Class.new
    kl2 = InitializedClass.new(:a, :b, kl1)
    assert_equal(kl1, kl2.superclass)
  end

  def test_block_passed_to_initialize
    args = nil
    kl1 = Class.new{ define_method(:initialize){|*a| args = a } }
    kl2 = InitializedClass.new(:a, :b, kl1) {|sup| sup[@a, @b] }
    kl2.new(1,2)
    assert_equal([1,2], args)
  end

  def test_no_single_param_accepted
    assert_raise(ArgumentError){ InitializedClass.new(:a)}
  end

  def test_arity_is_checked
    kl1 = InitializedClass.new(:a, :b)
    assert_raise(ArgumentError){ kl1.new(1) }
    assert_raise(ArgumentError){ kl1.new(1,2,3) }
  end

  def test_allow_named_args
    kl1 = InitializedClass.new(:a, :b, :c)
    o = kl1.new :a => 1, :c => 2
    expected = [1,nil,2]
    assert_equal(expected, [o.a, o.b, o.c])
    assert_equal(expected, %w[@a @b @c].map{|x| o.instance_variable_get(x)})
  end
end

The implementation was rather easy

initializedclass.rb
# Copyright (C) 2006 Mauricio Fernandez <mfp@acm.org> http://eigenclass.org
# Use and distribution under the same terms as Ruby.
#
class Object
  module InstanceExecHelper; end
  include InstanceExecHelper
  def instance_exec(*args, &block)
    mname = "__instance_exec_#{Thread.current.object_id.abs}_#{object_id.abs}"
    InstanceExecHelper.module_eval{ define_method(mname, &block) }
    begin
      ret = send(mname, *args)
    ensure
      InstanceExecHelper.module_eval{ undef_method(mname) } rescue nil
    end
    ret
  end
end

class InitializedClass
  def self.new_class(accessor_type, *args, &block)
    parent = args.pop if Class === args.last
    parent ||= Object
    unless args.size > 1
      raise ArgumentError, "No point in using InitializedClass for a single argument!" 
    end
    kl = Class.new(parent) do
      case accessor_type
      when :ro : attr_reader(*args)
      when :rw : attr_accessor(*args)
      end
        
      define_method(:initialize) do |*a|
        args.each{|name| instance_variable_set("@#{name}", nil) }
        if a.size == 1 && Hash === a[0]
          args.each{|name| instance_variable_set("@#{name}", a[0][name.to_sym])}
        elsif a.size != args.size
          raise ArgumentError, 
                "wrong number of arguments (#{a.size} for #{args.size})"
        else
          args.each_with_index{|name, i| instance_variable_set("@#{name}", a[i])}
        end
        if block
          call_super = lambda{|*a| super(*a, &nil)}  # &b only in 1.9 :-|
          instance_exec(call_super, &block) 
        end
      end
    end
  end

  def self.new(*args, &block)
    new_class(:ro, *args, &block)
  end

  def self.new_rw(*args, &block)
    new_class(:rw, *args, &block)
  end
end

I'm quite satisfied with that; it feels much lighter than some of the metaprogramming I've done in the past. I might release it for real, maybe even under a boasting name:

class Foo < SuperClass.new(:a, :b, :c)
end

It does make sense, doesn't it?


Better implementation - mfp (2006-05-15 (Mon) 12:17:14)

I was tired when I wrote the first one: instance_exec and a Proc gets created per object... that sucks.

I changed the super initialization thing so it now works like

 klass = InitializedClass.new(:a, :b, Parent) { initialize_super(@a, @a + @b) }

The implementation is shorter too:

 class InitializedClass
   def self.new_class(accessor_type, *args, &block)
     parent = args.pop if Class === args.last
     parent ||= Object
     unless args.size > 1
       raise ArgumentError, "No point in using InitializedClass for a single argument!" 
     end
     Class.new(parent) do
       case accessor_type
       when :ro : attr_reader(*args)
       when :rw : attr_accessor(*args)
       end
        
       define_method(:initialize) do |*a|
         args.each{|name| instance_variable_set("@#{name}", nil) }
         if a.size == 1 && Hash === a[0]
           args.each{|name| instance_variable_set("@#{name}", a[0][name.to_sym])}
         elsif a.size != args.size
           raise ArgumentError, 
                 "wrong number of arguments (#{a.size} for #{args.size})"
         else
           args.each_with_index{|name, i| instance_variable_set("@#{name}", a[i])}
         end
         instance_eval(&block) if block
       end

       if block
         super_meth = parent.instance_method(:initialize)
         define_method(:initialize_super){|*a| super_meth.bind(self).call(*a) }
         private :initialize_super
       end
     end
   end

   def self.new(*args, &block); new_class(:ro, *args, &block) end
   def self.new_rw(*args, &block); new_class(:rw, *args, &block) end
 end
 SuperClass = InitializedClass

Hash equality - Christian Neukirchen (2006-05-15 (Mon) 10:54:09)

Now, add #hash and #eql? and it will kick ass.

mfp 2006-05-15 (Mon) 11:35:38

Something like this I guess?

 define_method(:hash){args.map{|name| instance_variable_get(name)}.hash } 
 define_method(:eql?){|other| other.class == self.class && args.all?{|name| self.instance_variable_get("@#{name}") == other.instance_variable_get("@#{name}") } }

or should it rather iterate over all of instance_variables, and not only the declared ones? And should that be class equality or just is_a? ? hmmm


Last modified:2006/05/15 09:36:56
Keyword(s):[blog] [ruby] [frontpage] [class] [initialize] [meta] [struct] [superstruct] [superclass] [snippet]
References:[Ruby]