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?
- The dictionary might have the referenced key.
- The method might be an assignment.
- 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 Autobject
s:
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!