Aaron Roh
Posted on May 7, 2024
What Happened?
on April 23, 2024, a PR #51640 was merged into main branch of Ruby On Rails.
This PR title is Use Module#include rather than prepend for faster method lookup
.
Previously, if you printed Integer.ancestors
in Rails, you would see the following:
[ActiveSupport::NumericWithFormat,
ActiveSupport::ToJsonWithActiveSupportEncoder,
Integer,
JSON::Ext::Generator::GeneratorMethods::Integer,
Numeric,
Comparable,
ActiveSupport::ToJsonWithActiveSupportEncoder,
Object,
PP::ObjectMixin,
JSON::Ext::Generator::GeneratorMethods::Object,
ActiveSupport::Tryable,
Kernel,
BasicObject]
After the PR was merged, Integer.ancestors
changed to:
[Integer,
ActiveSupport::NumericWithFormat,
ActiveSupport::ToJsonWithActiveSupportEncoder,
JSON::Ext::Generator::GeneratorMethods::Integer,
Numeric,
Comparable,
Object,
PP::ObjectMixin,
ActiveSupport::ToJsonWithActiveSupportEncoder,
JSON::Ext::Generator::GeneratorMethods::Object,
ActiveSupport::Tryable,
Kernel,
BasicObject]
The priority of the ActiveSupport::NumericWithFormat
module for Integer
, Float
, and BigDecimal
changed from prepend
to include
.
Additionally, the priority of the ActiveSupport::ToJsonWithActiveSupportEncoder
module changed from include to prepend. As a result, the order of ancestors for [Enumerable, Object, Array, FalseClass, Float, Hash, Integer, NilClass, String, TrueClass]
changed.
What is changed?
In summary, the order of the ancestors for core classes, where modules are defined in Rails, has been altered.
The order of ActiveSupport::NumericWithFormat
and ActiveSupport::ToJsonWithActiveSupportEncoder
has been moved to behind the core class.
Before this PR was merged, the method lookup order for Integer was as follows.
# Integer.ancestors
[ActiveSupport::NumericWithFormat,
ActiveSupport::ToJsonWithActiveSupportEncoder,
Integer,
JSON::Ext::Generator::GeneratorMethods::Integer,
Numeric,
Comparable,
ActiveSupport::ToJsonWithActiveSupportEncoder,
Object,
PP::ObjectMixin,
JSON::Ext::Generator::GeneratorMethods::Object,
ActiveSupport::Tryable,
Kernel,
BasicObject]
Now, the order of ancestors for Integer is as follows.
You can see that ActiveSupport::NumericWithFormat
and ActiveSupport::ToJsonWithActiveSupportEncoder
order are moved to after Integer.
Additionally, changes were made to the Integer, Float, BigDecimal, Enumerable, Object, NilClass and other core class.
# Integer.ancestors
[Integer,
ActiveSupport::NumericWithFormat,
ActiveSupport::ToJsonWithActiveSupportEncoder,
JSON::Ext::Generator::GeneratorMethods::Integer,
Numeric,
Comparable,
Object,
PP::ObjectMixin,
ActiveSupport::ToJsonWithActiveSupportEncoder,
JSON::Ext::Generator::GeneratorMethods::Object,
ActiveSupport::Tryable,
Kernel,
BasicObject]
In Rails, some core classes have mixed-in modules like ActiveSupport::NumericWithFormat
, ActiveSupport::ToJsonWithActiveSupportEncoder
.
These modules implement methods that are useful for the class, such as 12345678.to_fs(:delimited)
, which is not available in pure Ruby.
These implemented methods do not override pure Ruby's methods; they simply add new methods to the class. Therefore, there is no reason to use prepend for these mixed-in modules.
Why is that problem?
In TruffleRuby, certain interpreter optimizations are performed to enhance the execution speed of the code.
However, when prepend
is used on core classes such as String
and Integer
, these optimizations are undone.
This is because prepend alters the method lookup order, which can lead to more complex method resolution and hamper the performance benefits provided by VM optimization that include Method inlining and Method caching.
What is Ruby Method Lookup?
Ruby does not have multiple inheritance like C++ or Java.
But Ruby supports using mix-in modules and extend singleton classes at runtime.
So when you call a method on an object, Ruby will look for the method in the object's class, then in the included modules, and finally in the superclass.
This process is called Method Lookup.
I have prepared an example for easy explanation.
There are classes, String1
and String2
that inherit from String
class.
And CustomModule
module is included in String1
and prepended in String2
.
module CustomModule
def foo
'defined on CustomModule'
end
end
class String1 < String
include CustomModule
def foo
'defined on String1'
end
end
class String2 < String
prepend CustomModule
def foo
'defined on String2'
end
end
When we call foo
method on String1
and String2
, the result is like below.
p String1.new('foobar').foo
# => defined on String1
p String2.new('foobar').foo
# => defined on CustomModule
Result of String1.new('foobar').foo
and String2.new('foobar').foo
is different.
Because, include
and prepend
have different method lookup path.
include
will add the module after the methods of the class itself, while prepend
will add the module to the beginning of the method lookup path.
This means, when you defined prepend module and call a method on an object, Ruby will first look for the method in the prepended module. So prepend module will be called.
And when you defined include module and call a method on an object, Ruby will first look for the method in the object's class. So the class method will be called.
Method lookup path for String1
and String2
is like below.
It sounds reasonable when we call foo
method.
String1
Class Method Lookup Path
String2
Class Method Lookup Path
However, calling other common methods will be much more frequent than calling method foo on String1 and String2 class.
So, without special reason, it's better to use include
rather than prepend
for mixed-in modules in core classes.
Conclusion
Rails have changed the method lookup path for core classes.
This change is to improve the performance of the code execution in TruffleRuby (relates with method caching).
However, in MRI, I think this change will have little to no impact on the performance of code execution.
I Referenced below list.
Posted on May 7, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.