Rails Core Classes Method Lookup Changes: A Deep Dive into Include vs Prepend
Tags: rails, ruby
Published: 2024-05-07 09:04:54

### What Happened?

on April 23, 2024, a PR [#51640](https://github.com/rails/rails/pull/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:
```ruby
[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:
```ruby
[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.
```ruby
# 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.

```ruby
# 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`.

```ruby
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.

```ruby
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

![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. ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wn5qr7mteyg0sx6nvheu.png)


`String2` Class Method Lookup Path
![String2 Class Method Lookup Path](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nic5qndxza27xmv7zrsk.png)




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.
> - https://github.com/rails/rails/pull/51640
> - https://github.com/oracle/truffleruby/issues/3546