attr_accessor_with_history

The Engineering SaaS textbook for the CS169.1x edX course I’ve been taking has a list of exercises and “suggested projects” for each chapter. I’ve been working through them, along with the Ruby Koans, and came upon the following question:

Project 3.6: Define a method attr_accessor_with_history that provides the same functionality as attr_accessor but also tracks every value the attribute ever had.

class Foo
  attr_accessor_with_history :bar
end
f = Foo.new # => #
f.bar = 3 # => 3
f.bar = :wowzo # => :wowzo
f.bar = 'boo!' # => 'boo!'
f.history(:bar) # => [3, :wowzo, 'boo!']

This is a great question to demonstrate Ruby’s metaprogramming. As I sat down, I got a creeping sensation that I’d done this before. Of course: the corresponding edX online course used this as a homework question, except…

Except the requirements were slightly different.

The online version of the question asked for f.bar_history whereas the book version asks for f.history(:bar).

This was enough of a difference to throw me.

Here’s the general framework for my solution:

First, we’re opening up the “Class” class, which will allow any class definition to declare an attr_acceessor_with_history.

class Class
  def attr_accessor_with_history(attr_name)
  ...
  end
end

This is truly mind-bending. What the hell is going on?

Well, in Ruby, there are no static class declarations like you’d see in Java or C#. A class declaration in Ruby is really just a call to a method named “class”, which creates an instance of the Class class with whatever behavior you choose to bestow to that class’s type. Your class is just an object, but it’s an object that helps define what the behavior of that class type’s instances will be.

Let’s look at an example:

class Foo
  attr_accessor :foo
end

Inside of your Foo object — which is a Class instance — you’ve invoked a call to the attr_accessor method with :foo as a parameter. This creates a getter and setter method named after :foo.

What we want to do is make it so that Foo can call the attr_accessor_with_history method.

Our first attempt might do something like this:

class Class
  def attr_accessor_with_history(attr_name)
  ...

but if you look at the Ruby docs for attr_accessor, attr_accessor is defined in the Module class, so let’s modify it there:

class Module
  def attr_accessor_with_history(attr_name)
  ...

We want the instance of our class to respond to the getter method so that we can do this:

f = Foo.new # => #
f.foo = 3 # => 3

We can do this by leveraging a method that already exists, which is attr_reader.

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
  ...

At this point, you can actually do this:

f = Foo.new # => #
f.foo # => nil

But doing this will fail:

f.foo = 3
NoMethodError: undefined method `foo=' for #

Of course, we haven’t created a setter. Let’s do that.

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    attr_writer attr_name # or attr_accessor attr_name
  ...

No! Don’t do this! I mean, well, yeah, you will then be able to assign values to foo, but we need to do more than just that: we need to keep track of its history too.

To do that, we need to provide a custom definition for the assignment method. To do this, we will leverage another method called class_eval, which allows a class to add an instance method to itself:

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    class_eval %Q{
    ...
    }
  ...

In this example, we’ve passed it a string (read up on %Q if you’re confused how that’s a string). That string needs to contain the method definition:

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    class_eval %Q{
      def #{attr_name}=(value)
      ...
    }
  ...

Remember that “def #{attr_name}=(value)” once interpolated will become “def foo=(value)”.

We need to save the value into the instance variable (which is already created by attr_reader):

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    class_eval %Q{
      def #{attr_name}=(value)
        @#{attr_name} = value
      end
    }
  ...

But now we need to add the history functionality. Let’s review the possible cases:

  • foo has not been set: foo_history should return nil
  • foo has been set to 1: foo_history should return [nil]
  • foo has been set to :bar: foo_history should return [1]
  • etc.

Let’s save the history of values in an array, which we stash into an instance variable named @#{attr_name}_history:

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    class_eval %Q{
      def #{attr_name}=(value)
        @#{attr_name}_history = [] if @#{attr_name}_history.nil?
        @#{attr_name}_history << @#{attr_name}
        @#{attr_name} = value
      end
    }
  ...

We need to user to be able to call f.foo_history to get this instance variable:

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    attr_reader "#{attr_name}_history"
    class_eval %Q{
      def #{attr_name}=(value)
        @#{attr_name}_history = [] if @#{attr_name}_history.nil?
        @#{attr_name}_history << @#{attr_name}
        @#{attr_name} = value
      end
    }
  ...

Note that wrapping up attr_name into an interpolated string makes sure that the name of the attr_reader gets properly converted.

When we run this code, it totally works!

foo.foo_history works, but we need to get foo.history(:foo) to work.

This is where things got really difficult for me. Okay, let’s take a swing at it!

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    attr_reader "#{attr_name}_history"
    class_eval %Q{
      def #{attr_name}=(value)
        @#{attr_name}_history = [] if @#{attr_name}_history.nil?
        @#{attr_name}_history << @#{attr_name}
        @#{attr_name} = value
      end
      def history(name)
        @#{name}_history
      end
    }
  ...

This won’t work, sadly. Why not? Because when we call @#{name}_history, it won’t work since the scope of attr_accessor_with_history doesn’t have a variable “name”.

What we need is to somehow create an interpolated string with the history argument’s value.

The solution is to use the version of class_eval that accepts a block, and use a method called instance_variable_get to return the instance variable:

class Module
  def attr_accessor_with_history(attr_name)
    attr_reader attr_name
    attr_reader "#{attr_name}_history"
    class_eval %Q{
      def #{attr_name}=(value)
        @#{attr_name}_history = [] if @#{attr_name}_history.nil?
        @#{attr_name}_history << @#{attr_name}
        @#{attr_name} = value
      end
    }

    class_eval do
      def history(name)
        instance_variable_get("@#{name}_history")
      end
    end
  end
end

Note how instance_variable_get makes it possible to get a variable from its name string only.

Leave a Reply

Your email address will not be published. Required fields are marked *