I've often heard that if you aren't using the metaprogramming capabilities in Ruby, there's no point in using Ruby to begin with. I don't know if I necessarily agree with that, but metaprogramming in Ruby is a very powerful tool that can lead to idiomatic Ruby. For Rails developers, even if you haven't directly used Ruby metaprogramming, you've reaped the benefits by using Rails.
This workshop was prepared by Christopher Lee for Boston.rb Project Night.
If you're interested in learning more about Ruby Metaprogramming, I recommend: Metaprogramming Ruby: Program Like the Ruby Pros (amazon affiliate link)
- Give you a hands on introduction to ruby metaprogramming
- Walk through different metaprogramming techniques using
send
,define_method
, andmethod_missing
- Expand your mind to Metaprogramming and get you thinking about where you can leverage it in your own code
The workshop was created with Ruby 1.9.3, but works through version 2.1.2. There's an assumption that you have at least a basic understanding of Ruby programming.
Clone the git repo:
$ git clone [email protected]:christopherslee/intro_to_metaprogramming.git
$ cd intro_to_metaprogramming
RVM (optional):
$ rvm use --create 1.9.3@intro_to_metaprogramming
Install Gems (assuming you have bundler):
$ bundle install
Run specs (you should see a bunch of failures):
$ rspec
Finished in 0.00112 seconds
5 examples, 5 failures
Failed examples:
rspec ./spec/customer_spec.rb:9 # Customer#initialize stores an id and a datasource
rspec ./spec/customer_spec.rb:16 # Customer#first gets the first name of a customer
rspec ./spec/customer_spec.rb:22 # Customer#last gets the last name of a customer
rspec ./spec/customer_spec.rb:28 # Customer#email gets the email address of a customer
rspec ./spec/customer_spec.rb:34 # Customer#age gets the age of a customer
Our workshop will start with developing a ruby class Customer
that interacts with our very own special, read-only, in-memory database. Our
CustomerDatastore
contains customer data for fields 'first', 'last',
'email', and 'age'. For each of these fields there is a matching method
to query for the value and the datatype, (ex: get_email_value
,
get_email_datatype
)
To help guide us along, there is a set of specs that we want to make pass. When they go green, we know we have completed the lesson. To test that we are setup correctly, run the following command to run rspec and see all our red tests. For convenience I've included a .rpsec configuration that uses the document formatter and colors the output red or green. To run the specs, simply run the rspec command from the root of the project.
Note: ideally, you don't have to understand rspec too much to get through this workshop.
+ lib/
- customer.rb # your code goes here
- customer_datasource.rb # an imaginary database
+ spec/
- customer_datasource_spec.rb # customer datasource tests
- customer_spec.rb # customer tests
- Gemfile
- Gemfile.lock
- README.md # this file
Define a Customer
class.
- It has an initializer that takes an
id
, and adatasource
- This class has methods
first
,last
,email
, andage
that query the datastore for the value. Each method returns the capitalized name of the field, and the value of the field. If the value is a string, it encloses the value in single quotes.
Example:
> customer.first
First: Yokohiro
> customer.age
Age: 48
You'll know when you are finished when all your tests are green!
Note: This isn't metaprogramming, but you may have written boilerplate getters and setters like this before.
Great, we finished Lesson 1, which was mostly just making sure you're
all setup properly for the next lessons. You probably are also slightly
peeved at the repetitive nature of the code in the Customer
class.
Good! That was part of the point!
You may recall that invoking methods in Ruby sends messages to objects to
query and manipulate them. Convenientely Object
has a send
method
we can use to send a message dynamically at runtime.
class Echo
def message(str)
str
end
end
> puts Echo.new.send 'message', "hi mom!"
hi mom!
Send
allows you to invoke any method on an object, both public and
private. Using it to invoke private methods should be used with caution,
after all, the author must have made the private for some reason, right?
In Ruby 1.9.1 and above, there is a public_send
method that
appropriately respects private methods.
Try it out using Send
in irb!
Let's DRY up our Customer
class by using send
. Define a private helper method
field
that takes a single argument fieldname
. Use this fieldname to
dynamically get the value, returning the appropriate result. As your refactor,
your tests should still be green!
def first
field :first
end
# refactor your other methods
private
def field(fieldname)
value = ? # use send to "send messages" to the datasource
# now finish up, keeping your tests green!
end
Wow, this is starting to feel a lot better, if we had to add new fields,
we could crank them out pretty quickly. We've done a great job cleaning
up our code, but now we have all these methods that just delegate to another
method. A powerful technique with Ruby metaprogramming is code that writes
code. We can use define_method
to define a new method on a class.
define_method
takes an argument and a block. We haven't covered
blocks in this workshop, but for now let's just assume it executes the
code we give it like so:
class Post
attr_reader :state
def self.has_state(name)
define_method name do
@state = name
end
end
has_state :success
has_state :failure
has_state :error
end
> post = Post.new
> post.success
> puts post.state
success
> post.error
> puts post.state
error
Ruby 1.9.3 also added a define_singleton_method
which defines methods on the eigenclass.
Remove all our manually defined methods first
, last
, email
,
and age
from the Customer
class. Instead, use define_method
to
create ourselves a handy has_field
method (Does this start to remind you
of anything in Rails?) This method will use define_method
to replace
the getter methods we deleted.
def self.has_field(fieldname)
define_method fieldname do
# your code here
end
end
# now use your has_field method to "write" your new methods without writing them.
You'll know when to stop when your tests are all green.
Hints: The ruby interpreter reads top down. Also thinking about when and where
has_field
is invoked may help you avoid an undefined method
error.
Very cool! Our customer class is getting shorter and shorter. Try doing that in Java!
Now we've all seen errors like this before:
NoMethodError: undefined method ‘bar’ for #<Foo:0x3c848>
In this example, we're trying to send a message bar
to an instance
of Foo
. Skipping some details, when we call a method that doesn't
exist on an object, Ruby invokes method_missing
on that object. By
default, method_missing
raises NoMethodError
.
Just like you'd expect, we can override method_missing
for our own
purposes by redefining it in our class. It takes two arguments, the
method name, and a splat.
class Customer
def method_missing(name, *args, &block)
super
end
end
Two things to watch out for here. You generally want to invoke the
default behavior of method_missing
somewhere to get the standard
behavior. You also want to be careful not to get into an infinite loop!
Refactor your Customer
class to use method_missing
instead of
define_method
.
Hint: Do you want all methods to delegate to the datasource? Try using
respond_to?
to make sure we don't inundate our datasource with
bogus messages.
def method_missing(methodname, *args, &block)
super unless datasource.respond_to? "<your string here>"
value = datasource.send("get_#{methodname}_value", id)
"#{methodname.capitalize}: #{value}"
end
Maybe we've gotten a little too clever for our own good. Search your repo for "age".
Then open up irb or pry and take a look at your Customer
class.
> require './lib/customer'
> require './lib/customer_datasource'
> c = Customer.new :one, CustomerDatasource.new
> c.respond_to? :first
false
Huh? Our computer class doesn't have the methods we expect to see on it. Even
though we can invoke our component methods dynamically, they aren't
available for introspection. This is a tradeoff we have to keep in mind
when using method_missing
, although we could override respond_to?
as well.
Override respond_to?
to return the methods that method_missing
handles for us.
Hint: Just like method_missing
we probably want to invoke the
default behavior with a call to super
if we don't match the case we
are specifically adding.
Now we have code that writes code, and that's pretty cool. Our schemaless datasource could change; wouldn't it be cooler if we automatically created methods based on the functionality the datasource provides us? We can!
Fire up your favorite ruby interpreter (irb/pry). Try out the following:
> require './lib/customer'
> Customer.methods
> Customer.methods(false)
> Customer.public_methods
> Customer.public_methods(false)
> Customer.ancestors
There are many methods for introspecting Ruby classes. They can be very
useful when debugging strange problems. In our case, we want to use them
to interrogate our datasource. To learn more about then, look at the
Object
and Basic Object
classes in the Ruby API
docs.
Change the Customer
class to introspect the datasource upon
initialization and dynamically define our fields.
Hint: You can use the grep
method to get the methods you want.
Visit the gist below to get a useful regex if you don't want to write
one yourself. This isn't a class on regular expressions, but can still
be fun to try to work through it yourself.
I hope that this gave you a feel for what can be accomplished with Ruby Metaprogramming. Metaprogramming is a powerful tool that contributes to Ruby's goal of optimizing for developer happiness. How far can you take these examples? Maybe you can identify places in your code that you can DRY up with these techniques, maybe you will create the replacement ActiveRecord.
Before you do so, what kind of security issues can you think of?
I'm sure there are many ways to solve the problems presented in this workshop, but for those who might get a little stuck, here are some answers to the problems:
I am currently a Lead Engineer at Jana Mobile. I was formerly a Team Lead at ConstantContact, where I also lead the Ruby on Rails best practice group. I was the CTO and co-founder of MobManager.com, which was acquired by Constant Contact in 2011.
I have a B.S. Computer Science from the University of Illinois at Urbana-Champaign, an M.S. Software Engineering w/distinction from DePaul University, and am an alumnus of Northwestern's Kellogg School of Management.
As a personal note, I used Rails quite successfully for at least 6 years without using metaprogramming myself. You don't have to know it, but I can say that once I was exposed to it, it opened my eyes and added another tool to my development arsenal.
Ruby and Rails have created a lot of opportunity for me, and I thought this workshop would be one way I could give back to the community. I am not a professional trainer or instructor, but I would value your feedback on how I could improve my delivery, or the material itself.
Email Me: christopher (dot) s (dot) lee (at) gmail (dot) com