4

Is there a better way to write this Expando class? The way it is written does not work. I'm using Ruby 1.8.7

starting code quoted from https://gist.github.com/300462/3fdf51800768f2c7089a53726384350c890bc7c3

class Expando
    def method_missing(method_id, *arguments)
        if match = method_id.id2name.match(/(\w*)(\s*)(=)(\s*)(\.*)/)
              puts match[1].to_sym # think this was supposed to be commented 
              self.class.class_eval{ attr_accessor match[1].to_sym } 
              instance_variable_set("#{match[1]}", match[5])
        else
              super.method_missing(method_id, *arguments)
        end  
    end    
end

person = Expando.new 
person.name = "Michael"
person.surname = "Erasmus"
person.age = 29 
BuddyJoe
  • 69,735
  • 114
  • 291
  • 466

2 Answers2

9

The easiest way to write it is to not write it at all! :) See the OpenStruct class, included in the standard library:

require 'ostruct'

record = OpenStruct.new
record.name    = "John Smith"
record.age     = 70
record.pension = 300

If I was going to write it, though, I'd do it like this:

# Access properties via methods or Hash notation
class Expando
  def initialize
    @properties = {}
  end
  def method_missing( name, *args )
    name = name.to_s
    if name[-1] == ?=
      @properties[name[0..-2]] = args.first
    else
      @properties[name]
    end
  end
  def []( key )
    @properties[key]
  end
  def []=( key,val )
    @properties[key] = val
  end
end

person = Expando.new
person.name = "Michael"
person['surname'] = "Erasmus"
puts "#{person['name']} #{person.surname}"
#=> Michael Erasmus
Phrogz
  • 296,393
  • 112
  • 651
  • 745
  • @tyndall Indexing a String using negative integers comes from the end of the string, so `-1` is the last character. In Ruby 1.8 this returned the integer code of that character, while in Ruby 1.9 it returns a string with a single character. The syntax `?x` in Ruby 1.8 evaluates to the integer code of character `x`, while in Ruby 1.9 it is a shorthand for a string with just the character `"x"`. The end result is that I am checking to see if the last character in the method name is an equals sign in a simple way that is compatible with both 1.8 and 1.9. – Phrogz Jan 18 '11 at 04:30
  • awesome. thanks for pointing out the change in 1.9. Good to know. – BuddyJoe Jan 19 '11 at 20:18
2

If you're just trying to get a working Expando for use, use OpenStruct instead. But if you're doing this for educational value, let's fix the bugs.

The arguments to method_missing

When you call person.name = "Michael" this is translated into a call to person.method_missing(:name=, "Michael"), so you don't need to pull the parameter out with a regular expression. The value you're assigning is a separate parameter. Hence,

if method_id.to_s[-1,1] == "="     #the last character, as a string
   name=method_id.to_s[0...-1]     #everything except the last character
                                   #as a string
   #We'll come back to that class_eval line in a minute
   #We'll come back to the instance_variable_set line in a minute as well.
else
   super.method_missing(method_id, *arguments)
end

instance_variable_set

Instance variable names all start with the @ character. It's not just syntactic sugar, it's actually part of the name. So you need to use the following line to set the instance variable:

instance_variable_set("@#{name}", arguments[0])

(Notice also how we pulled the value we're assigning out of the arguments array)

class_eval

self.class refers to the Expando class as a whole. If you define an attr_accessor on it, then every expando will have an accessor for that attribute. I don't think that's what you want.

Rather, you need to do it inside a class << self block (this is the singleton class or eigenclass of self). This operates inside the eigenclass for self.

So we would execute

class << self; attr_accessor name.to_sym ; end

However, the variable name isn't actually accessible inside there, so we're going to need to single out the singleton class first, then run class_eval. A common way to do this is to out this with its own method eigenclass So we define

  def eigenclass
    class << self; self; end
  end

and then call self.eigenclass.class_eval { attr_accessor name.to_sym } instead)

The solution

Combine all this, and the final solution works out to

class Expando
  def eigenclass
    class << self; self; end
  end

  def method_missing(method_id, *arguments)
    if method_id.to_s[-1,1] == "=" 
      name=method_id.to_s[0...-1]
      eigenclass.class_eval{ attr_accessor name.to_sym }
      instance_variable_set("@#{name}", arguments[0])
    else
      super.method_missing(method_id, *arguments)
    end      
  end    
end
Ken Bloom
  • 57,498
  • 14
  • 111
  • 168
  • +1 thanks for the answer. how is self.eigenclass.class_eval different from self.class.class_eval? – BuddyJoe Jan 18 '11 at 01:11
  • In Ruby, every class is represented by a `Class` object. The `Expando` class is one such class object. `self.class` refers to `Expando`. However, in Ruby, every object can have its own, unique class associated with it -- this is the eigenclass, or the singleton class. If you create a method on `Expando`, then every object of class `Expando` gets that method. If you create a method on the eigenclass, the only that object has the method. – Ken Bloom Jan 18 '11 at 01:34
  • Even if your object has an eigenclass, `self.class` still returns `Expando` (which is shared by all Expando instances). So you need a different method to get eigenclass, and that's the method that I created here. – Ken Bloom Jan 18 '11 at 01:35
  • See chapter 24 of Pickaxe (Programming Ruby, the Pragmatic Programmer's guide) – Ken Bloom Jan 18 '11 at 01:38