Ruby 3.0 で変わる Module#include の挙動

今日の RubyKaigi Takeout 2020Ruby Committers vs the World でチラッと話が出たのでちょっとまとめでみました。

注意!!

これはまだ開発版の機能になり Ruby 3.0 がリリースされるタイミングで挙動が変わっている可能性があるので注意してください。

Module#include の変更点

今までは M.include M2 を行ってもすでに include M しているクラス/モジュールの継承リストには反映されませんでした。

module M; end

class X
  include M
end
# ここでは M のみ反映されている
p X.ancestors
# => [X, M, Object, Kernel, BasicObject]

module M2; end

# M に対して include してもすでに M を include しているクラス/モジュールの継承リストには反映されない
M.include M2

# M2 は反映されない
p X.ancestors
# => [X, M, Object, Kernel, BasicObject]

これが Ruby 3.0 からではすでに include されているクラス/モジュールにも反映されるようになります。

module M; end

class X
  include M
end
# ここでは M のみ反映されている
p X.ancestors
# => [X, M, Object, Kernel, BasicObject]

module M2; end

# M に対して include すると M を include しているクラス/モジュールにも反映される
M.include M2

# M2 も反映されるようになる
p X.ancestors
# => [X, M, M2, Object, Kernel, BasicObject]

これは prepend でも同じ挙動になります。
また、これにより(当然ですが)メソッド呼び出しにも影響はあります。

module M1
  def hoge; end
end

class X
  include M1
end

# OK
X.new.hoge

module M2
  def foo; end
end
M1.include M2

# 2.7 : error
# 2.8 : OK
X.new.foo

この変更自体は結構うれしくて、例えば汎用的なモジュールをあとから Kernel に対して include できるようになります。

module M
  def twice
    self + self
  end
end

Kernel.include M

p "hoge".twice
# 2.7 => error: undefined method `twice' for "hoge":String (NoMethodError)
# 3.0 => "hogehoge"

これは便利。

Module#include の変更に対する弊害

弊害というか期待する挙動ではあるんですが以下のようなケースの場合、継承リストに同じモジュールが複数含まれるようになります。

class Super
  def hoge
    ["Super#hoge"]
  end
end

module Included
  def hoge
    ["Included#hoge"] + super
  end
end

module Prepended
  def hoge
    ["Prepended#hoge"] + super
  end
end

class X < Super
  include Included
  prepend Prepended

  def hoge
    ["X#hoge"] + super
  end
end

# このあたりは今までどおり期待する挙動
p X.ancestors
# => [Prepended, X, Included, Super, Object, Kernel, BasicObject]

p X.new.hoge
# => ["Prepended#hoge", "X#hoge", "Included#hoge", "Super#hoge"]


module Included2
  def hoge
    ["Included2#hoge"] + super
  end
end

# Ruby 3.0 の変更であとから include した場合でも反映されるようになる
Prepended.include Included2
Included.include Prepended

# Ruby 3.0 だと同じモジュールが複数含まれるようになる…
p X.ancestors
# => [Prepended, Included2, X, Included, Prepended, Included2, Super, Object, Kernel, BasicObject]

# 当然 super 経由で同じめそっどが 複数回呼ばれるようになる
p X.new.hoge
# => ["Prepended#hoge", "Included2#hoge", "X#hoge", "Included#hoge", "Prepended#hoge", "Included2#hoge", "Super#hoge"]

これは期待する挙動ではあるんですが今までの挙動を知っていると結構ギョッとする挙動になりますねえ。
実際、これに関するバグ報告も来ていました(期待する挙動なのですでに閉じられています。

と、言う感じで Ruby 3.0 では Module#include の挙動が変わる(かもしれない)ので注意してください。