Flashback to January 2016!
Deskstop

teaching machines

CS 330 Lecture 37 – Metaprogramming in Ruby

Dear students,

This last week of the semester we enter the crazy world of metaprogramming. What is metaprogramming? Well, there’s been a recurring them in our discussion this semester. C++ pushed very hard to make our data be treated just like builtin data. The classes we write are allowed to be virtually indistinguishable from the builtin types. We can use [], operator<<, and construction syntax for our classes just like the builtin types. Functional programming emphasized that functions could be data. We can pass them off to higher-order functions via method references or partial application or lambdas. We can assign functions to variables. Now with metaprogramming, we allow entire programs to be treated as data.

We will examine metaprogramming in both Ruby and Java. Today, we will concentrate on Ruby. In particular, we will try to make Ruby more like Javascript. Because whether you like Javascript or not, the equivalence between dictionary lookup and field access is fascinating:

var foo = Object.new;
foo.name = 'Scout';
console.log(foo['name']);

We’d like to make a Ruby dictionary behave more like an object. Instead of saying

config = {}
config[:key] = value

we’d like to be able to say

config = Autobject.new
config.key = value

Can we write a normal old class to support this? Well, when we run this code, we see that method key= cannot be found. Is there any way that we can write a class that has methods for every field/property that our clients may want to assign? No way. We can’t see that far into the future.

For this to happen, we need some metaprogramming. But first, let’s write our constructor:

def initialize value = {}
  @data = value
end

If the client provides no value, we’ll just wrap around an empty dictionary.

Now, how do we handle all these infinite methods that are impossible to write? Easy. A catch-all method that Ruby will call on our objects when they don’t support a method. It’s called method_missing:

def method_missing symbol, *args
  ...
end

What might be true when this method is called?

  1. The dictionary might have the referenced key.
  2. The method might be an assignment.
  3. The method might be illegal.

Let’s handle the first case:

if @data.has_key? symbol
  @data[symbol]
end

The second case is a bit more involved. We have to check if the method name suggests an assignment, but this needs to be done on the string version of the symbol:

elsif name =~ /^(\w+)=$/ && args.length == 1
  @data[$1.to_sym] = args[0]
end

Failing the above cases, we just let the normal error-handling happen:

else
  super name, *args
end

Okay, let’s test this out:

f = Autobject.new first: 'Roy', last: 'Biv'
f.middle = 'G'
puts "#{f.first} #{f.middle} #{f.last}"

Now let’s get this to work with JSON data. Let’s add a static method for loading an Autobject from some other source:

def self.load src
  ...
end

If we have a URI or File, we’ll open it and slurp up the JSON contents. Otherwise, we’ll assume we have a JSON string. Once we know we have JSON, we can parse it:

if src.is_a?(URI) || src.is_a?(File)
  json = open(src) do |io|
    io.read
  end
else
  json = src
end

Autobject.new JSON.parse(json, symbolize_names: true)

Now, let’s try reading some JSON:

g = Autobject.load '{"first": "Roy", "last": "Biv"}'
puts g.inspect

And some weather data:

weather = Autobject.load(URI("http://api.openweathermap.org/data/2.5/weather?q=Eau%20Claire,WI&APPID=#{KEY}"))
puts weather.inspect

Let’s try to pull out the current temperature:

puts weather.main.temp

This fails. Why? weather.main gives back a Hash. Calling temp on a Hash is bound to fail. We want main to also be an Autobject so we can access fields inside of it with the . operator. Let’s write a little recursive helper that goes and turns all the parts of our JSON structure into Autobjects:

def self.objectify data
  if data.is_a? Array
    data.map do |element|
      Autobject.objectify(element)
    end
  elsif data.is_a? Hash
    object = data.transform_values do |value|
      Autobject.objectify(value)
    end
    Autobject.new object
  else
    data
  end
end

And then in load, we can apply this method to the data that we parse:

data = JSON.parse(json, symbolize_names: true)
Autobject.objectify(data)

Our temperature reading should work now! How about the time of the sunset?

puts Time.at(weather.sys.sunset)

What’s really nice about metaprogramming is that the code is essentially writing itself. This Autobject should be able to handle any key and any dictionary-ish object. Let’s try getting the high and low temperatures:

weather = Autobject.load(URI("http://api.openweathermap.org/data/2.5/forecast/daily?units=imperial&q=Eau%20Claire,WI&APPID=#{KEY}"))
puts weather.list[0].temp.min
puts weather.list[0].temp.max

Let’s try dumping the object en masse to the console:

def inspect
  JSON.pretty_generate(@data)
end

This seems to only work for the top-level dictionary. The pretty_generate method recursively calls to_json on all the values nested inside our object, so we also need to provide that method:

def to_json generator
  JSON.pretty_generate(@data, generator)
end  

Of course, now that we have the ability to print an object, we should provide a method for dumping it out to disk:

def save path
  open(path, 'w') do |file|
    file.puts inspect
  end
end

Now we have automatic getters, setters, serialization, and deserialization for any sort of object we can dream up. Thanks, metaprogramming!

See you in a week!

Sincerely,

Comments

Leave a Reply

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

Past Posts

Categories