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.