One of Ruby's most interesting - and useful! - features is being able to change how classes and objects behave while your app is running.
If you use Rails, then certainly you have used many runtime defined methods. Let's look at an example. Imagine a one to many association between two models: Author and Post.
If the author is already instantiated, then we can get the posts for this author by invoking
@author = Author.find(1)
@author.posts
=> #<ActiveRecord::Associations::CollectionProxy [...]>
But where does the posts
method come from? This method is actually defined during runtime by the has_many
method.
# app/models/author.rb
class Author < ApplicationRecord
has_many :posts
end
When reading the author model file, Ruby will invoke the has_many
methods passing the argument :posts
to it. Under the hood, has_many
will change the class that invoked it and add the association methods.
This is possible thanks to the magic of class_eval
, a Ruby method designed to change the behavior of classes during runtime.
It's a remarkable mean to provide shared functionality to classes in an elegant way that feels almost like you're just changing a configuration file.
How class_eval works
There are two ways of using class_eval
: passing it a block or passing it a string with the changes we'd like to execute to the class. Let's look at them separately.
Passing a block
When passing a block to class_eval
, it will evaluate that block within the context of the class that is invoking it.
When passing blocks, it is very common to use define_method
for defining methods during runtime. It is a method itself that
- Accepts the name of the new method as the argument
- Accepts a block to define the new method's body
- Yields the new method's regular arguments and block arguments to the body
define_method("name_of_new_method") do |arg1, arg2, &block|
# Define the behavior of the new method.
# arg1, arg2 and &block are accessible here in the body.
end
Now, let's analyze an example where we want to add a sing
method to our classes. When invoked, it will define a method with the music genre for that class that just prints a string.
# Define the method sing. This method can be put
# wherever is convenient (e.g.: a module or a parent class)
def sing(genre)
# "Open" the class for changes
class_eval do
# Use the define_method method to define "rock"
# in runtime
define_method(genre) do |band|
puts "I love #{band}!"
end
end
end
class Person
sing :rock
end
# After evaluating the class, the new rock method exists
Person.new.rock("AC/DC")
=> I love AC/DC!
We have our rock
method working!
If we wanted to add methods to a class that do not depend on the argument passed to sing
, then a simple def inside the class_eval
block is another option.
def sing
class_eval do
# This def is evaluate from the context of the class
# so this will be defined in any class that invokes
# sing
def rock(band)
puts "I love #{band}!"
end
end
end
class Person
# Notice the absence of the argument
sing
end
Person.new.rock("Queen")
=> I love Queen!
Passing a string
The second manner of using class_eval
is by passing it three arguments: a string with the Ruby that will be evaluated in the class, the file where it should be evaluated and the line where it should be evaluated.
These two last arguments will typically always be the same, but they can be changed to achieve other types of behavior.
The Ruby methods __FILE__
and __LINE__
are commonly used here and they return the current file and line being evaluated, respectively.
Let's use this case as an example of how we could write has_many
using class_eval
. Notice that this is not the real implementation that Rails uses, but the general idea is similar.
The usual manner to use the string version of class_eval
is to define a Ruby string with a HEREDOC and interpolate whatever data you need.
def has_many(resource)
# Turn current class name into name_id
# E.g.: NewsLetter -> news_letter_id
# Evaluated before opening class for modification
id_attribute_name = "#{self.name.underscore}_id"
class_eval(<<~ASSOCIATION_METHODS, __FILE__, __LINE__ + 1)
def #{resource}
# Turn the argument (e.g.: :posts) into the class
# name Post
#{resource.to_s.singularize.camelize}.where(#{id_attribute_name}: self.id)
end
ASSOCIATION_METHODS
end
Now let's use this in a class and understand what the HEREDOC is evaluated to.
class Author
# Using our version of has_many
has_many :posts
end
# In this case, the resource argument is :posts.
# The string resulting from the HEREDOC is the one below.
# Notice that this will get evaluated within the context
# of the Author class and then will define
# the instance method posts, allowing us to
# invoke
# author.posts
def posts
Post.where(author_id: self.id)
end
It's important to point out that many changes can be applied in a single invocation of class_eval
. For instance, the real implementation of has_many
in Rails would also provide the class with a setter. Both getter and setter can be defined at the same time.
@author = Author.find(1)
@author.posts = [...]
Conclusion
Using class_eval
enables developers to define behavior in runtime. This technique can be used to create methods with arguments and options passed in at a class level which results in an elegant organization of code.
Have you used this before? Let me know some use cases in the comments!
Top comments (3)
Nice post, Vini!
I've only used it once before a long time ago, to open up a class that didn't belong to me with an extension (at runtime). I generally avoid meta-programming in Ruby. It's sort of the source of "Rails magic" as some people like to call it. It does make things a lot more elegant. However, I do a ton of it in my own code in Elixir, though.
I've bookmarked this as reference should I ever reach for it again in Ruby. I've learned a couple of neat things from your post. Thank you!
Also, I know that @tenderlove just joined you guys. Have you had a chance to work with him?
Thank you for the kind words! I'm glad the post taught you something. I find Elixir to be super interesting, but didn't get my hands dirty with it yet.
Yes, he did join us, but we are in different teams so I haven't had the chance yet.
I learned about class_eval and instance_eval the hard way π€¦ββοΈ
You can't avoid it when building Redmine plugins...