RailsConf 2022—Lies About Metaprogramming
During my talk at RailsConf 2022, I presented a simplified version of
Active Record’s belongs_to
and has_many
methods.
These methods involve some metaprogramming to generate instance methods for the
association, and I demonstrated that using Ruby’s
define_method
:
def self.belongs_to(name)
define_method(name) do
association(name).reader
end
end
define_method
takes an argument with the name of the method you want to
define, and a block containing the method body.
But generating these association methods with define_method
is a bit of a lie.
The real Active Record association methods aren’t generated with
define_method
. Instead, they use class_eval
:
def self.belongs_to(name)
class_eval(<<~RUBY)
def #{name}
association("#{name}").reader
end
RUBY
end
class_eval
takes a string of valid Ruby code (in this example a heredoc),
parses it, and evaluates it in the context of the current class. (If that sounds
scary, you are correct. You’ve got to be rather careful with any of the eval
methods—accidentally passing user input to eval
would be a serious security
vulnerability.)
I personally find that using class_eval
makes the belongs_to
method rather
more difficult to read. So what gives? Why is Active Record using this ugly
class_eval
approach instead of define_method
?
It turns out methods defined with class_eval
will dispatch faster than methods
defined with define_method
(I’m assuming this is related to define_method
’s
block closure, but somebody please correct me if I am wrong about that). Let’s
benchmark method dispatch with these two approaches:
class Testing
define_method("defined_with_define_method") do
end
class_eval(<<~RUBY)
def defined_with_class_eval
end
RUBY
end
testing = Testing.new
require "benchmark/ips"
Benchmark.ips do |bm|
bm.report("define_method") { testing.defined_with_define_method }
bm.report("class_eval") { testing.defined_with_class_eval }
bm.compare!
end
And the results:
Warming up --------------------------------------
define_method 1.017M i/100ms
class_eval 1.698M i/100ms
Calculating -------------------------------------
define_method 10.495M (± 2.1%) i/s - 52.881M in 5.040862s
class_eval 16.747M (± 2.6%) i/s - 84.902M in 5.073308s
Comparison:
class_eval: 16746855.8 i/s
define_method: 10495230.2 i/s - 1.60x (± 0.00) slower
Wow! Calling the method defined with class_eval
is quite a bit faster! So
Active Record uses a bit of ugly code to speed up your calls to its generated
association methods. That’s not so ugly after all!