Search code examples
rubyhashmetaprogrammingclass-eval

How to use a hash in a class_eval statement in Ruby


I was working on a homework assignment when I ran into a frustrating issue. The assignment is an exercise in Ruby metaprogramming and the goal is to define an 'attr_accessor_with_history' that does all the same things as 'attr_accessor', but also provides a history of all values that an attribute has ever been. Here is the provided code from the assignment along with some code I added in an attempt to complete the assignment:

    class Class

  def attr_accessor_with_history(attr_name)
    attr_name = attr_name.to_s
    attr_hist_name = attr_name+'_history'
    history_hash = {attr_name => []}

    #getter
    self.class_eval("def #{attr_name} ; @#{attr_name} ; end")
    #setter
    self.class_eval %Q{
      def #{attr_name}=(val)
        # add to history
        @#{attr_hist_name} = [nil] if @#{attr_hist_name}.nil?
        @#{attr_hist_name} << val
        history_hash[@#{attr_name}] = @#{attr_hist_name}

        # set the value itself
        @#{attr_name} = val
      end

      def history(attr) ; @history_hash[attr.to_s] ; end
    }
  end
end

class Foo
  attr_accessor_with_history :bar
  attr_accessor_with_history :crud
end
f = Foo.new     # => #<Foo:0x127e678>
f.bar = 3       # => 3
f.bar = :wowzo  # => :wowzo
f.bar = 'boo!'  # => 'boo!'
puts f.history(:bar) # => [3, :wowzo, 'boo!']
f.crud = 42
f.crud = "Hello World!"
puts f.history(:crud)

I wanted to use a hash to store different histories for different attributes but I cannot access that hash in the class_eval statement for the setter. No matter how I try to set it up I always either seem to get a NoMethodError for the []= method because 'history_hash' somehow becomes type NilClass, or a NameError occurs because it sees 'history_hash' as an undefined local variable or method. How do I use the hash in the class_eval statements?


Solution

  • or a NameError occurs because it sees 'history_hash' as an undefined local variable or method

    I'd say you can't, because it is a local variable, one that is inaccessible in the context you want it. However, why do you even need it? I'm reasonably sure it's in the "some code I added in an attempt to complete the assignment", and not the original assignment code (which, I assume, expects you to store the history of @bar in @bar_history - or else what is attr_hist_name all about?)

    I'm also uncomfortable about string evals; it's generally not necessary, and Ruby can do better, with its powerful metaprogramming facilities. Here's how I'd do it:

    class Class
      def attr_accessor_with_history(attr_name)
        attr_setter_name = :"#{attr_name}="
        attr_getter_name = :"#{attr_name}"
        attr_hist_name = :"@#{attr_name}_history"
        attr_name = :"@#{attr_name}"
    
        self.class_eval do
          define_method(attr_getter_name) do
            instance_variable_get(attr_name)
          end
    
          define_method(attr_setter_name) do |val|
            instance_variable_set(attr_name, val)
            history = instance_variable_get(attr_hist_name)
            instance_variable_set(attr_hist_name, history = []) unless history
            history << val
          end
        end
      end
    end
    
    class Object
      def history(attr_name)
        attr_hist_name = :"@#{attr_name}_history"
        instance_variable_get(attr_hist_name)
      end
    end
    

    Finally, as it's monkey-patching base classes, I'd rather use refinements to add it where needed, but that's probably an overkill for an assignment.