Rails Core Classes Method Lookup Changes: A Deep Dive into Include vs Prepend

roharon

Aaron Roh

Posted on May 7, 2024

Rails Core Classes Method Lookup Changes: A Deep Dive into Include vs Prepend

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

String1 Class Method Lookup Path - using String1, String1 class didn't found foo method, so step to next sequence. this sequence is CustomModule. and found method here.

String2 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.

💖 💪 🙅 🚩
roharon
Aaron Roh

Posted on May 7, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related