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_missing
s 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.