An Exercise in Metaprogramming with Ruby - ' Metaprogramming Intro ' (
Page 3 of 3 )
I would argue there is. In a dynamic language like Ruby, you can do metaprogramming — that is, you can "write code that writes code" —
and your external data items become first-class citizens, even at runtime.
How would we do something like this? Let's take it slowly.
ADVERTISEMENT
First, I'll assume that we want to create a class with a name derived from the filename. I'll further assume that the first line of the file has a comma-separated list of attribute names. (For simplification, we do little error checking in this exercise; for instance, I won't check that each name is a legal Ruby method name, and I won't check for badly formed or missing data.)
Let's say we're given a file name and we want to create a class from its contents. Furthermore, we want to treat the file as an array of data items, reading it into an array of objects.
First things first. Let's create a new class and give it a suitable name.
class_name = File.basename(file_name,".txt").capitalize
# e.g. "foo.txt" => "Foo"
klass = Object.const_set(class_name,Class.new)
The Class.new operation creates a new class (technically still anonymous). The const_set operation sets a constant equal to that new value. (All classes in Ruby, in fact all top-level constants, are part of Object; for example, Object::Fixnum is equivalent to simply Fixnum.)
The variable klass refers to our new class. If the file was called people.txt, the class will be named People.
Now, let's start to add attributes to it. The first line of data is a list of names. Let's turn it into a simple array of strings by splitting on the comma character.
data = File.new(file_name)
names = data.gets.chomp.split(",") # an array of strings
Now we can use a class_eval in the context of our new class klass. At the same time, we'll define an initialize method.
klass.class_eval do
attr_accessor *names
define_method(:initialize) do |*values|
names.each_with_index do |name,i|
instance_variable_set("@"+name, values[i])
end
end
# more...
end
Now our class will have a list of accessors (read/write attributes) that is the same as the list of fields. The initialize method ensures that when we call new, we can pass in the values and they will be assigned in the expected order.
Take note of the fact that names is first used outside the block. Since a Ruby block is a closure, that variable will still be available to this block after it would normally have gone out of scope and been garbage-collected.
I say "more..." near the bottom of this code fragment. What else might we want to do to ensure a usable class? I'd suggest a smart to_s method so that we can use puts; and let's also alias that to inspect for convenience.
# still inside class_eval...
define_method(:to_s) do
str = "<#{self.class}:"
names.each {|name| str << " #{name}=#{self.send(name)}" }
str + ">"
end
alias_method :inspect, :to_s
What else could we do? I'd suggest a class-level method that does a read of an entire file and returns an array of objects. Because it's a class method, we don't have to do a class_eval; we're just adding a singleton onto an object klass which happens to be a class.
def klass.read
array = []
data = File.new(self.to_s.downcase+".txt")
data.gets # throw away header
data.each do |line|
line.chomp!
values = eval("[#{line}]")
array << self.new(*values)
end
data.close
array
end
This method uses its own name to determine the name of the data file (e.g., People will map to the filename people.txt). Our code reads the first line of the file and throws it away when it is invoked. This is in contrast to the first time we read it, while we were building this class and needed its information; at the time this class method will be run, the class obviously already exists. Though there may not yet be any instances of it, after read is called, there will be an array of such instances returned.
For the sake of reusability, let's wrap this whole thing in a class of its own and stick it in a file unimaginatively named my-csv.rb. We'll call the class DataRecord, which sounds generic enough for our purposes. And we'll define a class method called make which will take a filename as a parameter and build a class from it.
# file: my-csv.rb
class DataRecord
def self.make(file_name)
# all prior code...
klass
end
end
Within a class definition, self is the class itself. So our line def self.make could also have been written def DataRecord.make with no difference in behavior. This way is slightly better in the event we should change the name of DataRecord.
At the very bottom of the code fragment, we see klass appearing by itself. Why is this? Because we want to return that value to the caller. That is, if we create a class called People, we will also return that class so that it can be stored in a variable and used that way rather than by its "proper name."
We could have said return klass, of course. In Ruby, the last evaluated expression is the return value of a method.
Here's a listing that shows our entire little my-csv.rb library.
# file: my-csv.rb
class DataRecord
def self.make(file_name)
data = File.new(file_name)
header = data.gets.chomp
data.close
class_name = File.basename(file_name,".txt").capitalize
# "foo.txt" => "Foo"
klass = Object.const_set(class_name,Class.new)
names = header.split(",")
klass.class_eval do
attr_accessor *names
define_method(:initialize) do |*values|
names.each_with_index do |name,i|
instance_variable_set("@"+name, values[i])
end
end
define_method(:to_s) do
str = "<#{self.class}:"
names.each {|name| str << " #{name}=#{self.send(name)}" }
str + ">"
end
alias_method :inspect, :to_s
end
def klass.read
array = []
data = File.new(self.to_s.downcase+".txt")
data.gets # throw away header
data.each do |line|
line.chomp!
values = eval("[#{line}]")
array << self.new(*values)
end
data.close
array
end
klass
end
end
Now let's make a little program that uses it. Let's read our people.txt file and get an array of objects representing its contents. Then let's print out the first item in the file.
require 'my-csv'
data = DataRecord.make("people.txt") # Capture return value and
list = data.read # call a class method on it.
puts list[0]
# Output:
# <People: name=Smith, John age=35 weight=175 height=5'10>
Here we made use of the fact that the new class was returned from the make method. But the new class is also given the appropriate name, so that it can be accessed directly. The following code is exactly the same in effect.
require 'my-csv'
DataRecord.make("people.txt") # Ignore the return value and
list = People.read # refer to the class by name.
puts list[0]
# Output:
# <People: name=Smith, John age=35 weight=175 height=5'10>
All right, then. What has this accomplished that we couldn't have done otherwise?
First of all, the attributes are "first-class citizens" in the program, as shown here. They are not strings in a hash or a lookup table. The convenience is significant.
person = list[0]
puts person.name # Smith, John
if person.age < 18
puts "under 18"
else
puts "over 18" # over 18
end
kg = person.weight / 2.2 # kilograms
Secondly, and I've already stressed this, this is a dynamic technique. To bring this point home, let's look at a totally different data file. (The data are completely fake.)
file: places.txt
----------------
latitude,longitude,description
47.23,59.34,Omaha
32.17,39.24,New York City
73.11,48.91,Carlsbad Caverns
Now let's run our original program that didn't use the class by name. It should run "as is" and produce the output we expect.
The astute reader, of course, will protest that if we use the attribute names in our code (which is the point of making them first-class) then we have coupled our code to those names anyway. That is true enough; but we still have coded it as fast or faster this way, and we also have a generic solution which allows us to control the coupling as much as we want. For example, if we added another field to the data file, none of our code would have to change until we wanted to start actually using that field.
But the real point here is this. This is only an exercise. This is an example of the kind of metaprogramming that Ruby allows. I encourage you to take this power and use it in ways we haven't thought of yet.