今更聞けない! Ruby の継承と mixin の概念を継承リストから学ぶ

Ruby を学ぶ上で継承と mixin の概念を理解することはとても重要である。
しかし、このあたりの仕組みを学ぼうとすると、includeprepend、特異クラスや特異メソッドなどという様々な機能を理解する必要がありとても複雑である。
そこで本記事は『継承リスト』という観点から継承を mixin を学んでみたいと思う。
また、この記事は Ruby 2.4 時点での仕様になる。

継承と mixin

まず、はじめに Ruby での継承と mixin の仕方をみてみよう。
Ruby では継承で既存のクラスを、mixin でモジュールをそれぞれクラスに取り込むことが出来る

継承

class Super
    def super_method
        :super_method
    end
end

# Super クラスの機能(メソッド)を Sub クラスで継承(取り込む)する
class Sub < Super
end

# Super クラスのメソッドが利用できる
p Sub.new.super_method
# => :super_method

mixin

module Mod
    def mod_method
        :mod_method
    end
end

class X
    # Mod モジュールの機能(メソッド)を X クラスで取り込む
    include Mod
end

# Mod モジュールのメソッドが利用できる
p X.new.mod_method
# => :mod_method

他にも #extend#prepend といったメソッドでモジュールを mixin することが出来る。
継承も mixin も『外部の機能をクラスに取り込む』という意味では共通だが、

  • 1つのクラスしか継承する事が出来ない(継承してるクラスを継承するのは可)
  • 複数のモジュールを mixin することが出来る

という点で少し異なる。
また、継承と mixin は同時に利用することも可能である。

class Super
    def super_method
        :super_method
    end
end

module Mod
    def mod_method
        :mod_method
    end
end

class X < Super
    include Mod
end

x = X.new
p x.super_method
# => :super_method

p x.mod_method
# => :mod_method

Module#ancestors

ここまで読んで『継承と mixin が複数あってややこしい…』と思うかもしれない。
そこでまずは継承関係を『継承リスト』という形で視覚化してみてみよう。
継承リストは Module#ancestors メソッドで取得する事が出来る。
Module#ancestors はクラスオブジェクトをレシーバとして呼び出すことができ『クラス、モジュールのスーパークラスとインクルードしているモジュール を優先順位順に配列に格納して返す』というメソッドである。

see:https://docs.ruby-lang.org/ja/latest/method/Module/i/ancestors.html

説明を聞いてもよくわからないと思うので実際に試してみよう。
例えば、Integer であれば

p Integer.ancestors
# => [Integer, Numeric, Comparable, Object, Kernel, BasicObject]

となり、String であれば

p String.ancestors
# => [String, Comparable, Object, Kernel, BasicObject]

となる。
これは単純に

BasicObject
↑
Kernel
↑
Object
↑
Comparable
↑
Numeric
↑
Integer

というような『継承関係にある』と考えれば理解しやすいだろう(厳密に『継承』と言ってしまうと正しくはないが…。
また継承リストには『レシーバのクラス』も含まれている。
ちなみにユーザが定義したクラスは暗黙的に Object を継承することになる。

class X
end

p X.ancestors
# => [X, Object, Kernel, BasicObject]

更に継承を行った場合は Object と定義したクラスの間に継承したクラスが挿入される。

class Super
end

class Sub < Super
end

p Sub.ancestors
# => [Sub, Super, Object, Kernel, BasicObject]

このように Module#ancestors を使うことでそのクラスの『継承リスト』を確認する事が出来る。

メソッドを呼び出す優先順位

ここで少し継承と mixin から離れて『メソッドの探査』について考えてみよう。
と、言っても実はそんなに難しくない。メソッドの探査は
『継承リストの先頭から走査して最初に定義されているクラスのメソッドを実行する』
というような形になる。

class Super
    def mami
        "mami"
    end

    def homu
        "homu"
    end
end

class Sub < Super
    # Super クラスのメソッドを上書きする
    def homu
        "homuhomu"
    end
end

# Super よりも Sub のほうが優先順位が高い
p Sub.ancestors
# => [Sub, Super, Object, Kernel, BasicObject]

sub = Sub.new

# Sub では定義されていないので次の Super のメソッドが呼び出される
p sub.mami
# => "mami"

# Sub で定義されているので Sub のメソッドが呼び出される
p sub.homu
# => "homuhomu"

# Sub でも Super でも定義されていないので、Kernel で定義されているメソッドを呼び出す
p sub.class
# => Sub

ここで重要なのが『継承リストがそのままメソッド探査の優先順位になる』という点である。
逆にいえば『メソッド探査は継承リストに依存してる』ともいえる。
このあたりは重要なので覚えておくとよい。
余談だが、Method#owner でメソッドが定義されているクラス/モジュールを確認する事が出来る。

sub = Sub.new
p sub.method(:mami).owner
# => Super

p sub.method(:homu).owner
# => Sub

p sub.method(:class).owner
# => Kernel

mixin したクラスの #acestors をみてみる

これまでの流れで、

  • 継承リストは Module#ancestors で取得する事が出来る
  • 継承リストには自身も含まれている
  • メソッド探査の優先順位は継承リストに依存する
  • 継承を行うと継承したクラスが継承リストに挿入される

と、いうことがわかったと思う。
では、#include でモジュールを mixin をするとどうなるか。
実際に試してみよう。

module Mod
end

class X
    include Mod
end

# Mod がいる!!
p X.ancestors
# => [X, Mod, Object, Kernel, BasicObject]

あれ、 mixin しても継承リストに追加されてる!?
そう、mixin も実は単に継承リストに追加されるだけなのだ。
ちなみに先程から継承リストに存在している Kernel も実はクラスではなくて『Object に mixin されているモジュール』なのである。

p Kernel.class
# => Module

継承と mixin を両方使ってみると

では、継承と mixin の両方を行うとどうなるのか試してみよう。

class Super
end

module Mod
end

class X < Super
    include Mod
end

p X.ancestors
# => [X, Mod, Super, Object, Kernel, BasicObject]

上のコードの実行結果を見てもらうと Mod モジュールとと Super クラスが継承リストに追加されていることがわかる。
また、継承リストから見てわかる通り、継承と mixin を行った場合は mixin のほうが前に挿入されていることがわかる。
これにより『mixin を行ったモジュールのメソッドを優先して』呼び出されることになる。

class Super
    def homu
        "Super#homu"
    end
end

module Mod
    def homu
        "Mod#homu"
    end
end

class X < Super
    include Mod
end

p X.new.homu
# = > "Mod#homu"

ちなみに複数のモジュールを mixin した場合は『あとから mixin したモジュール』の優先順位が高くなる。

module Mod1
end

module Mod2
end

class X
    include Mod1
    include Mod2
end

p X.ancestors
# = > [X, Mod2, Mod1, Object, Kernel, BasicObject]

このように継承も mixin も『継承リストに挿入されているだけ』に過ぎないのである。

prepend する

ところで Ruby 2.0 で Module#prepend というメソッドが追加された。
これは #include と同様にモジュールを mixin する機能であるが、『優先順位が mixin されたクラスよりも高い』という特性がある。

module Mod
    def homu
        "Mod#homu > #{super()}"
    end
end

class X
    prepend Mod

    def homu
        "X#homu"
    end
end

# X#homu ではなくて Mod#homu が呼ばれる
p X.new.homu
# "Mod#homu > X#homu"

このように #prepend したモジュールのメソッドが自身のクラスのメソッドよりも優先して呼ばれる。
では、これも継承リストを見てみよう。

module Include
end

module Prepend
end

class X
    include Include
    prepend Prepend
end

p X.ancestors
# => [Prepend, X, Include, Object, Kernel, BasicObject]

#include したモジュールと比較すると #prepend したモジュールが『自身のクラスよりも前に』継承リストに挿入されている事がわかる。
このようにして継承リストを確認することで『自身よりも #prepend したモジュールが優先して呼ばれる理由』も納得すると思う。
こうしてみると難しそうな #prepend も単純に『継承リストに挿入する位置が違うだけ』なのがわかると思う。
まとめると

  • #include はクラスよりも前に継承リストに挿入される
  • #prepend はクラスよりも後に継承リストに挿入される

という形になる。

singleton_class.ancestors をみている

ここまで来たら特異メソッドについても考えみよう。
Ruby ではクラスだけではなくて『インスタンスに対しても』メソッドを定義することができる。
また、そのメソッドはクラスで定義されているメソッドよりも優先して呼ばれる。

class X
    def homu
        "X#homu"
    end
end

x = X.new

def x.homu
    "x.homu"
end

p x.homu
# => "x.homu"

このように『インスタンスに対して定義されるメソッド』のことを『特異メソッド』と呼ぶ。
また、特異メソッドは『特異クラス』という特殊なクラスに定義される。
この特異クラスは『各オブジェクトが必ず1つ持っている』クラスになる。

class X
end

# X クラスの特異クラス
p X.singleton_class
# => #<Class:X>

x = X.new

# x インスタンスの特異クラス
p x.singleton_class
# => #<Class:#<X:0x00000001275e10>>

ちょっと複雑になってきたので特異メソッドと特異クラスはこのあたりにしてメソッド探査の話をしよう。
メソッド探査が継承リストに依存しているのはこれまで話したとおりである。
と、いうことで特異クラスの継承リストを確認してみよう。

class X
end

x = X.new

p x.singleton_class
# => #<Class:#<X:0x00000001828790>>
p x.singleton_class.ancestors
# => [#<Class:#<X:0x00000001828790>>, X, Object, Kernel, BasicObject]

上のコードを見てもらうと『特異クラスが自身の継承リストに追加されている』事がわかる。
なのでメソッド探査に依存する継承リストというのは正確にいえば『自身の特異クラスの継承リスト』ということになる。
これにより『クラスで定義されたメソッド』よりも『特異クラスで定義されたメソッド=特異メソッド』が優先されるのである。

#extend してみる

最後に #extend について説明しておく。
これも #include#prepend と同様にモジュールを mixin する機能であるが『特異クラスに mixin する』という点が他のメソッドと異なる。

module Mod
    def homu
        "Mod#homu"
    end
end

class X
end

x = X.new

# Mod のメソッドが x オブジェクトに定義される
x.extend Mod

p x.homu
# => "Mod#homu"

# 特異クラスの継承リストに extend したモジュールが追加されている
p x.singleton_class.ancestors
# => [#<Class:#<X:0x000000017a51b0>>, Mod, X, Object, Kernel, BasicObject]

このように #extend は『特異クラスの継承リスト』に追加される。
逆にいえば

x.extend Mod

x.singleton_class.include Mod

と同等ともいえるだろう。

おまけ:特異メソッドよりも優先するメソッドを定義する

おまけ。
特異クラスに対して #prepend することで特異メソッドよりも優先されるメソッドを定義することが出来る。

module Mod
    def homu
        "Mod#homu"
    end
end

class X
end

x = X.new
def x.homu
    "x.homu"
end
p x.homu
# => "x.homu"

p x.singleton_class.prepend Mod
p x.homu
# => "Mod#homu"

# 特異クラスよりも前に挿入されている事がわかる
p x.singleton_class.ancestors
# => [Mod, #<Class:#<X:0x00000001094dd0>>, X, Object, Kernel, BasicObject]

滅多にないと思うが一番優先したいメソッドを定義したい場合はこういう手段が考えられる。

まとめ

  • メソッド呼び出しの優先順位は継承リストに依存する
  • 継承リストは Module#ancestors で確認できる
  • 継承も mixin も継承リストにクラス/モジュールを挿入する機能に過ぎない
  • #include はクラスよりも前に継承リストに挿入される
  • #prepend はクラスよりも後に継承リストに挿入される
  • 特異メソッドは特異クラスに定義される
  • 特異クラスは継承リストの一番手間に挿入されるのでメソッド探査の優先順位が一番高い
  • メソッドの探査はレシーバの『特異クラスの』継承リストに依存する
  • #extend は特異クラスの継承リストに挿入される

このように継承や mixin は継承リストが中心となって機能していることがみてとれる。
メソッド探査のまとめは以下のようになる。

スーパークラス
↑
include したモジュール
↑
自身のクラス
↑
prepend したモジュール
↑
インスタンスが extend したモジュール
↑
インスタンスの特異クラス(特異メソッド)

Ruby では外部で定義されているメソッドを取り込む機能として継承や includeprepend など手段が分散されており複雑なので理解するのが難しい。
しかし、継承リスト(#ancestors)を参照することでなんとなく仕組みがわかったのではないだろうか。
もし、意図しないメソッドが呼び出される問題が発生した場合は継承リストを確認してみるとよいだろう。
ちなみに特異クラスでも #include#prepend を行うことができるので、継承リストがどうなるのか気になるのであれば手元で試してみるとよいだろう。

参照