Rubyのモジュール機構では、既にincludeしてるモジュールに追加でincludeしても結果が反映されない

いや、既にincludeしてるモジュールに追加でincludeすると、追加が反映されず継承木の構造が期待と違う、という意味です。

Twitter / Yukihiro Matsumoto


いぜん次のような記述を見かけたので、てっきり Ruby のモジュールはどこまでもシンボリックなのかと思っていましたが、

ただ、traitは「a first-class collection of named methods」であり、 そのinclude(論文中ではuses:)は、そのtraitが(現時点で)持つメソッド集合を使うイメージのようだ。 Rubyのincludeはinheritと同様、 シンボリックな関係*2を維持している。 いっそmoduleもtraitのようにしてしまうとラクなんだが、多分許されないだろうなあ。

*2 includeしたmoduleにメソッドが追加されると追随すること

Matzにっき(2004-01-26)


実際には一段かました場合には何かの都合でそうはなっていなかったのですね。手元の環境で試してみるとたしかにそのようです。

module M1
  def m1; :M1m1 end
end

class C1
  include M1
end

C1.new.m1   #=> :M1m1
C1.new.m2   #=> NoMethodError: undefined method `m2'

module M2
  def m1; :M2m1 end
  def m2; :M2m2 end
end

module M1
  include M2
end

C1.new.m1   #=> :M1m1
C1.new.m2   #=> NoMethodError: undefined method `m2'

class C2
  include M1
end

C2.new.m1  #=> :M1m1
C2.new.m2  #=> :M2m2

[2020-03 追記] Rubyで長らく放置されてきたこの“不具合”は、最新の 2.8.0-dev で解消が試みられているようですので、互換性面からの強いクレームや既存機能との整合性等に特に目立った問題がなければ近々是正される…はず^^;

ややこしいことに、先の Matzにっきで言及されている通り、論文ではシンボリックではないとされている Traits ですが、動的性がウリの Smalltalk ではさすがにそれだと使えないということもあってかなくてか、少なくとも Squeak Smalltalk での実装においては、後からの変更も反映されるようになっています(この振る舞いを実現するためにはかなり面倒なことをしないといけないにも関わらず、プロトタイプの実装の時点からそうなっているので、件の論文の記述は静的言語に配慮したものに過ぎず、動的言語の実装では、このように動的にするのが本道であるとの穿った解釈を個人的にはしています)。その様子を、以下で実際に試してみましょう。


まずは、トレイトT1 を作成します。(註:Smalltalk システムでは、クラスやメソッドの定義はクラスブラウザを用いるのが普通ですが、ここでは簡単のため同等のことをするコードで示しています。なので、クラスを定義する式が長すぎるとか、メソッドを文字列で与えるとかダセーとか言うのは禁止の方向で。^^;)

Trait named: #T1 uses: {} category: 'Category-Name'.


そこにメソッド m1を定義し、その T1 を use した C1 を作成。

T1 compile: 'm1 ^#T1m1'.

Object subclass: #C1 uses: T1
    instanceVariableNames: ''
    classVariableNames: ''
    poolDictionaries: ''
    category: 'Category-Name'


結果として、当然、C1 のインスタンス(C1 new)の m1 のコールは出来ますが、どこにも定義されていない m2 は message not understood になります。

C1 new m1.   "=> #T1m1 "
C1 new m2.   "=> MessageNotUnderstood: C1>>m2 "


ここで新たにトレイトT2 を定義。そこに m2 と T1 に定義したのと同名の m1 を定義。

Trait named: #T2 uses: {} category: 'Category-Name'.
T2 compile: 'm1 ^#T2m1'.
T2 compile: 'm2 ^#T2m2'.


そして、T1 で T2 を use します(Ruby で M1 を C1 にinclude したあと、あらためて M1 に M2 を include したのとほぼ同じ状況になります)。すでに述べたとおり、Squeak Smalltalk の Traits では、動的性を維持するためにいろいろと頑張っているようで、Ruby の場合と違い、以降、問題なく m2 がコールできるようになります。

T1 uses: T2.
C1 new m1.   "=> #T1m1 "
C1 new m2.   "=> #T2m2 "


ただここで注意したいのは、m1 の扱いです。Traits のウリの一つとして、コンパイル時に同名メソッドの衝突を回避できる―というのがありますが、一見それが機能していないように見えます。

これは、use しているトレイトが持っているのと同名のメソッドをクラスで定義した場合、それは衝突とは見なされず、結果、トレイトの同名メソッドは遮蔽される―という Traits の仕様によります。同じことはトレイト同士についても言えるようで(実は私もこれを書いていて初めて気がついた)、T2 を use しても T1 の m1 は問題なく T1 のものが問題なくコールされるようです。

個人的にはこの振る舞いは気に入らない(クラスで定義されたメソッド群は無名のトレイトに含まれると考える方がスッキリするような気がする―)のですが、まあ、use と合成は別物と考えるしかしかたがないですね。


いちおう確認ため、T1 と T2 を合成(T1+T2)してから use すれば、m1 の衝突はきちんと認識されます。

Object subclass: #C2 uses: T1+T2
    instanceVariableNames: ''
    classVariableNames: ''
    poolDictionaries: ''
    category: 'Category-Name'
C2 new m1.   "=> Error: A class or trait does not properly resolve a conflict between multiple traits it uses. "
C2 new m2.   "=> #T2m2 "


ちなみにこの点で Ruby のモジュールは実質オーバーライド扱いになるため、先に示したとおり include順により無条件で M1#m1 がコールされています。ここが、継承機構と別の独自の機構(use と合成)を持つ Traits と、Ruby のモジュールのように疑似クラスとその継承機構を流用したMix-in(衝突は感知しない。あるいは静的言語での Traitsもどき のようにアノテーションという別の情報の助けを借りることで衝突を警告する)との大きな違いですね。

# RubyのモジュールやScalaのトレイト(もどき)などのMix-inは、疑似クラスを継承パスに参加させることで実現されている。
C1.ancestors   #=> [C1, M1, Object, Kernel, BasicObject]
C2.ancestors   #=> [C2, M1, M2, Object, Kernel, BasicObject]
"Smalltalkのトレイトは、クラスとは別のエンティティで、クラスに直接組み込むことで共有・再利用され、継承パスには現れない。"
C1 allSuperclasses asArray   "=> {Object . ProtoObject} "
C2 allSuperclasses asArray   "=> {Object . ProtoObject} "

なお、先の RubyConf 2010 におけるキーノートを含め、かねてより、Ruby でもこの Traits を模した機能がモジュール機構の拡張(新しい mix メソッド)として追加されることがほぼ決定されているようです。

おそらく両者の内部的な実装の違いにより、ユーザーの使い勝手に大きな違いは生じないはずですが、一方で、トレイトというエンティティを新たに設けることをせずにこの Traits という機構を従来の疑似クラスと継承機構の流用だけで忠実にシミュレートするのは、かなり手こずるのではないかな…と個人的にはちょっと意地悪な見方をしています。ここは Ruby のモジュール機構のポテンシャルの高さに期待―といったところでしょうか。