Module#include changes in Ruby 3

Are post-include includes included?

Posted by Owen Stephens on June 19, 2023

This post describes a subtle change that we encountered when upgrading from Ruby 2 to Ruby 3 last year. It describes how some methods that would be missing in Ruby 2 are not missing in Ruby 3, and how certain module include patterns can cause a difference in method implementation.

Module#include is one of Ruby's mechanisms for enabling code-reuse and avoiding duplication. As an example, the well-known Enumerable module provides many traversal/search methods that can be re-used by any class representing a "collection" that defines an each method.

Fewer method_missings in Ruby 3

In the Ruby 3.0 release, a change was made to the behaviour of Module#include when involving modules that are included in other modules. Specifically, a module M1 that is included in another module M2 after M2 has already been included in a class C now does affect C, whereas previously it would only affect classes that subsequently included M2. To unpack that somewhat dense statement, consider the following modules and class defined in ancestors.rb:

module M1
  def method_one
    puts "method_one"
  end
end

module M2
  def method_two
    puts "method_two"
  end
end

class C
  include M2

  def call_methods
    method_one
    method_two
  end

  def method_missing(m, *args)
    puts "method_missing: #{m}"
  end
end

We have two simple modules that each define a single (but different) method, and a class that includes only M2, but defines method_missing to catch (and log) any otherwise-missing methods called by call_methods.

Now consider this sequence of method calls (situated after the definition of C in ancestors.rb):

puts "Calling methods before including M1 in M2..."
C.new.call_methods

M2.include M1

puts "Calling methods after including M1 in M2..."
C.new.call_methods

C.include M1

puts "Calling methods after including M1 in C..."
C.new.call_methods

Notice how at the point when C was defined, M2 did not include M1. Later we include M1 in M2 and then also explicitly include M1 in C.

Running this with ruby ancestors.rb gives the following output on Ruby 2.7:

$ rbenv shell 2.7.6
$ ruby ancestors.rb
Calling methods before including M1 in M2...
method_missing: method_one
method_two
Calling methods after including M1 in M2...
method_missing: method_one
method_two
Calling methods after including M1 in C...
method_one
method_two

Notice that in Ruby 2, even after M1 has been included in M2, C's behaviour is not affected: C#method_one is still missing, and is only defined after when we have explicitly included M1 in C.

However, in Ruby 3, including M1 in M2 does immediately affect C's behaviour:

$ rbenv shell 3.0.0
$ ruby ancestors.rb
Calling methods before including M1 in M2...
method_missing: method_one
method_two
Calling methods after including M1 in M2...
method_one                                   # <-- A change vs 2.7 here!
method_two
Calling methods after including M1 in C...
method_one
method_two

Notice that C#method_one is not missing after including M1 in M2, and that including M1 explicitly in C has no additional effect.

As noted by the excellent Ruby changes website, this behaviour was changed in Ruby 3.0 to address a long-standing point of confusion and inconsistency between class and module inheritance. The underlying Ruby feature #9573 has the (long!) history of the issue and the eventual fix - it's great to see long-standing issues being resolved as Ruby evolves.

Changed override behaviour with include

In Ruby 3 certain module include patterns have no observable effect, whereas they did in Ruby 2. Specifically, an implementation override after a module is included in Ruby 2 no longer happens in Ruby 3. Consider this pair of modules that define the same method, and a class that initially includes one of the modules in override.rb:

module M1
  def my_method
    puts "my_method in M1"
  end
end

module M2
  def my_method
    puts "my_method in M2"
  end
end

class C
  include M2
end

Again, we call a method after originally including M2 in C, then after including M1 in M2, and finally after including M1 in C:

puts "Before including M1 in M2..."
C.new.my_method

M2.include M1

puts "After including M1 in M2..."
C.new.my_method

C.include M1

puts "After including M1 in C..."
C.new.my_method

In Ruby 2:

$ rbenv shell 2.7.6
$ ruby override.rb
Before including M1 in M2...
my_method in M2
After including M1 in M2...
my_method in M2
After including M1 in C...
my_method in M1

Notice that after including M1 in C the my_method lookup chain has changed such that we call M1#my_method instead of M2#my_method, because M1 is now ahead of M2 in the lookup chain[1].

However, in Ruby 3:

$ rbenv shell 3.0.0
$ ruby override.rb
Before including M1 in M2...
my_method in M2
After including M1 in M2...
my_method in M2
After including M1 in C...
my_method in M2            # <-- A change vs 2.7 here!

Even after including M1 in C, we continue calling M2#my_method. This change is because the include of M1 in C now short-circuits in Ruby 3, because M1 is considered to already be included in C, precisely because of the previous include of M1 in M2.

For completeness, if we instead include M1 in C (without including M1 in M2 first) as follows:

puts "Before including M1 in C..."
C.new.my_method

C.include M1

puts "After including M1 in C..."
C.new.my_method

then in both Ruby 2 and Ruby 3 the output is the same:

Before including M1 in C...
my_method in M2
After including M1 in C...
my_method in M1

since in both cases M1 has not already been (transitively) included and thus it is included in C and changes our method call to use M1#my_method.


[1]: Use C.ancestors to confirm this.