MetaRuby - Building Classes Dynamically
Last time, we created our own version of attr_reader
using define_method
. Today we will learn how to dynamically build classes in Ruby.
Keywords
Ruby defines a number of keywords that have special meaning. You probably use them so often, you don’t even think about them, but many of them have programatic equivalents:
Keyword | Ruby Method |
---|---|
class | Class.new |
module | Module.new |
def | define_method |
alias | alias_method |
We can use these to do some pretty neat things. Let’s define a Class in a Module with an aliased method, and then see how we would do that programmatically without using Ruby’s keywords:
module Example
class Monkey
def initialize(name)
@name = name
end
def greet
"Hi I'm #{@name}"
end
alias introduce greet
end
end
monkey = Example::Monkey.new("Hubert")
monkey.introduce #=> "Hi I'm Hubert"
Now let’s do the same thing, but replace the keywords with Ruby methods:
Example = Module.new
Example::Monkey = Class.new do
define_method :initialize do |name|
@name = name
end
define_method :greet do
"Hi I'm #{@name}"
end
alias_method :introduce, :greet
end
monkey = Example::Monkey.new("Hubert")
monkey.introduce #=> "Hi I'm Hubert"
There are a few minor differences, for one, the modules and classes are anonymous, hence the Example = Module.new
. The second obvious difference is that we use blocks for the module, class, and method bodies. Otherwise this is quite similar to the previous example.
Why might we want to do this? One common use case is to generate classes dynamically based on some sort of external schema.
A Class Factory
To illustrate this, let’s make a method that will generate classes to decode binary data based on simple, declarative format. We’ll use what we saw above, and leverage String#unpack, a method that converts a String of bytes into an Array of Ruby objects.
def data_class(fields)
names = fields.keys
pattern = fields.values.join
Class.new do
attr_reader :values
define_method :initialize do |byte_string|
@values = byte_string.unpack(pattern)
end
names.each_with_index do |name, i|
define_method name do
values[i]
end
end
end
end
RGBColorData = data_class(red: "C", green: "C", blue: "C")
binary = [255, 128, 192].pack("CCC")
color = RGBColorData.new(binary)
color.red #=> 255
color.green #=> 128
color.blue #=> 192
We just wrote a method that let us read a color from binary data, now let’s figure out how it works.
The data_class
method takes a Hash of fields
. The keys are names
and the values define the pattern
Ruby will use to unpack the data.
Next we define a new Class. Follow the indentation, and you’ll see that the Class definition is the last expression in this method, so that’s what will be returned.
Calling define_method :initialize
creates a typical initialization method, but there’s a slight twist. Notice that we reference pattern
from outside the method definition. Since define_method
is just a method, and its body is just a block, the methods you define this way can reference values outside the block.
Next we define an accessor method for each of the field names passed in.
Pack / Unpack
What about those calls to pack
and unpack
? pack
will convert an Array of values into a binary representation based on a pattern. pack("CCC")
generates the example binary data that RGBColorData consumes. In data_class
the hash keys, ['C', 'C', 'C']
, are joined into a single String, "CCC"
. That pattern is used to parse RGBColorData’s input, which results in an Array of values, such as [255,128,192]
. The dynamically defined accessor methods get an index from each_with_index
, which lets each named field pluck its value out of the Array.
Recap
Ruby doesn’t just let you define methods programmatically, you can define whole classes.
- Classes and Modules can be created by calling
new
. - Blocks define Class and Method bodies, and have lexical scope.
- Ruby has helpers for packing and unpacking binary data.
I hope this has piqued your curiosity a bit. Have you used Ruby to generate Classes before, or seen some particularly good examples of this? Let me know.