Rubyで他のクラスを拡張したい場合
いろんな言語で既存のクラスを機能拡張することはあると思いますが、Rubyでももちろん存在します。
ですが、Rubyの場合比較的自由度が高いのでいろんな方法で拡張できます。
昔からある方法を使うのもいいですが、Ruby2以降で入った機能なども考慮するとなかなかパターンが合ったので自分用に整理したかったのと、きっと他の方法もあるんだろうとおもってそういうのがあれば是非教えてもらいたくて記事を書こうと思いました。
そもそもいい、わるいは別として同じ結果を取得する方式でもいろいろあって面白いのでそういう意味でも他の方の意見も聞きたいなぁと。
あと、それぞれの方法のメリット・デメリットなど一般的にいわれているものがあればそれも教えてほしいです。
さて、それでは今回拡張するクラスです。
拡張対象のクラス
class C1
  def m(act, *arg)
    case act
    when :sym1
      p [:sym1, arg]
    when :sym2
      p [:sym2, arg]
    else
      raise StandardError.new("unknown action : #{act}")
    end
  end
end
mというメソッドが「第一引数actの値によって、いろいろ異なる振る舞いをする」として与えられていて、それを使う側がactのパターンを増やしたいという場合です。
だいぶ恣意的な感じですが、実は最近触っていたseleniumのwebdriverクラスのfind_elementがこんな引数になってるんですね(実装はもちろん違いますが)
find_element(:xpath, "xpathの式"), find_element(:css, "cssのセレクタ式")みたいな感じでどんな方式でエレメントを取得するか第一引数で指定して、その実際のパスを第二引数で渡す、というような処理です。
さて、ここで、actに:sym3を渡して何か処理したい場合、クラスC1を拡張したいですね。
特に今回の元ネタの処理、webdriverでいうと、実際はactがsym1,sym2で事足りるんだけど、実際に使う上でのショートカットとしてsym1やsym2を使って別のsym3を使えるようにしたいというケースです。
まったくC1クラスとは独立したユーティリティクラスを作る
単純ですね。どこのクラスのメソッドとかは関係なく、C1クラスのオブジェクトを受け取って目的の処理をする処理を作ればいいわけです。
def m_ext(c1_obj)
  c1_obj.find_element(:sym1, :hogehoge_arg)
end
ただし、この方法にしてしまうと、sym3の場合のみ別のクラスのメソッドになってしまうので元のC1クラスのメソッドと合わせて使うとなんか見た目がおかしくなります。
c1 = C1.new
c1.m(:sym1, :hoge)
c1.m(:sym2, :piyo)
m_ext(c1)  # <= これだけ書き方が違う
特異メソッドを定義する
上のが嫌な場合(嫌じゃなければそれでもいいんですが)、次に簡単なのは、作ったc1オブジェクトに対して特異メソッドを定義してしまうことでしょうか。
c1 = C1.new
def c1.m(act, arg)
  case act
  when :sym3
    p [:sym3, arg]
  else
    super
  end
end
c1.m(:sym1, :arg1)
c1.m(:sym3, :arg1)
特異メソッドを直接定義するんではなく、特異クラスに専用のモジュールを取り込む
特異メソッドを定義してもいいんですが、じゃぁ、この他にもいろいろ拡張したいメソッドが出てきたらそれごとにdef c1.xxxを追加していくことになりますね。それでいいことも多いですが、なんかまとまりが無いですね。拡張した部分だけをまとめて定義してc1に注入できるといい感じです。
module C1Ex
  def m(act, arg)
    case act
    when :sym3
      p [:sym3, arg]
    else
      super
    end
  end
end
c1 = C1.new
c1.extend(C1Ex)
c1.m(:sym1, :arg1)
c1.m(:sym3, :arg1)
拡張したいところがmoduleにまとまったので個人的にはさっきまでのより綺麗な気がします。
C1クラスのサブクラスを作る
c1オブジェクトだけでなく、C1クラスから作られたすべてのオブジェクトでそういう振る舞いにしたい場合(というか多くの場合そうでしょう)C1自体を拡張したいですね。というか普通のオブジェクト指向言語ならこの方式ですかね。
class MyC1 < C1
  def m(act, arg)
    case act
    when :sym3
      p [:sym3, arg]
    else
      super
    end
  end
end
MyC1.new.m(:sym1, :arg1)
MyC1.new.m(:sym3, :arg1)
C1クラス自体を変更する
他の多くの言語と異なりRubyはオープンクラスという仕組みがあり、既存のクラスを再度定義(追加定義?)することができます。
その方法を使えば、拡張というよりもそもそも元のクラスを書き換えられます。いわゆるモンキーパッチというやつですね。
class C1
  def m(act, arg)
    case act
    when :sym1
      p [:sym1, arg]
    when :sym2
      p [:sym2, arg]
    when :sym3
      p [:sym3, arg]
    else
      raise StandardError.new("unknown action : #{act}")
    end
  end
end
c1 = C1.new
c1.m(:sym1, :arg1)
c1.m(:sym3, :arg1)
これやれば何でもありですね。ですが、もともとのC1#mメソッドが複雑だったりするとなかなか大変ですし、C1を使う他のクラスがいたりするとそこに影響もいろいろ出てきます。
c1クラス自体を変更するが、モジュールで既存メソッドを拡張する
既存のクラスに無いメソッドを既存のモジュールを使って拡張するのは昔からあるincludeでよかったんですが、
既存のクラスに有るメソッド自体を拡張するのはいろいろトリッキーなことをする必要がありました(railsのalias_method_chainとかですね)
そういったことをするために、prependという処理が加えられました。これを使えば、モジュールでをprependすることで既存のメソッドを変更することができます。
module C1Ex
  def m(act, arg)
    case act
    when :sym3
      p [:sym3, arg]
    else
      super
    end
  end
end
class C1
  prepend C1Ex
end
C1.new.m(:sym1, :arg1)
C1.new.m(:sym3, :arg1)
ちなみに、これをprepend C1Exでなく、昔ながらのinclude C1Exにすると、m(:sym3, :arg1)は実行できるのですが、m(:sym1, :arg1)が動かなくなります。理由は、includeの場合C1Exが継承階層でいうと、C1より親に来るからです.
というかそうならないように継承階層に組み込もう!というのがprependということですね。
c1クラス自体を変更するが、影響範囲はrefineで抑える
モンキーパッチでもprependでもC1クラスを使うすべての箇所に影響しますが、refineを使えばその箇所を限定できます。
module C1Ex
  refine C1 do
    def m(act, arg)
      case act
      when :sym3
        p [:sym3, arg]
      else
        super
      end
    end
  end
end
using C1Ex
C1.new.m(:sym1, :arg1)
C1.new.m(:sym3, :arg1)
refineは比較的最近の機能で2.0ではトップレベルにしかかけませんでした。2.1以降はclass定義の中でも使えます。ご注意くださいませ。
class UserClass
  using C1Ex #あるクラスの中だけでC1クラスを拡張する
  def initialize
    C1.new.m(:sym1, :arg1)
    C1.new.m(:sym3, :arg1)
  end
end
UserClass.new
一律既存クラスの動きを変えたいのではなく、特定のクラス内だけ動きを変えたい場合はruby 2.1以降はこの方法が一番いいんじゃないかと思っています。
ちなみによく分かってないのですが、refine内で定義したメソッドたちの検索順序はこれを見る限り、prependのようにC1よりも先にくるんですかね??
まとめ
いやぁ、いろいろありますね。
2016/4/27 追記
コメントでrefinementとprependなどを使った時のメソッドの検索順序の資料を教えてもらいました。こんなの有るんですね。
When looking up a method for an instance of class C Ruby checks:
- 
If refinements are active for C, in the reverse order they were activated:
- 
The prepended modules from the refinement for C
 - 
The refinement for C
 - 
The included modules from the refinement for C
 
 - 
 - 
The prepended modules of C
 - 
C
 - 
The included modules of C
 - 
If no method was found at any point this repeats with the superclass of C.
 
だそうです。こんなの日常使うプログラムで全部同時につかったら混乱してしまいそうですが、試してみます。
class B
  def m
    p :B_m
  end
end
module PrependedModuleOfC
  def m
    p :PrependedModuleOfC_m
    super
  end
end
module IncludedModuleOfC
  def m
    p :IncludedModuleOfC
    super
  end
end
class C < B
  prepend PrependedModuleOfC
  include IncludedModuleOfC
  def m
    p :C_m
    super
  end
end
module IncludedModuleFromRefinementForC
  def m
    p :IncludedModuleFromRefinementForC_m
    super
  end
end
module PrependeModuleFromRefinementForC
  def m
    p :PrependeModuleFromRefinementForC_m
    super
  end
end
module RefinementForC
  refine C do
    include IncludedModuleFromRefinementForC
    prepend PrependeModuleFromRefinementForC
    def m
      p :RefinementForC_m
      super
    end
  end
end
using RefinementForC
p C.ancestors
C.new.m
実行結果
$ ruby -v search_order.rb
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin15]
[PrependedModuleOfC, C, IncludedModuleOfC, B, Object, Kernel, BasicObject]
:PrependeModuleFromRefinementForC_m
:RefinementForC_m
:IncludedModuleFromRefinementForC_m
:PrependedModuleOfC_m
:C_m
:IncludedModuleOfC
:B_m
おぉ、資料のとおりに呼ばれてますね。
refineの中でincludeやprependが使えるのは知りませんでした。
refineの中の分はacestorsには現れないんですね。
確かにこの動きを見ると
ちなみによく分かってないのですが、
refine内で定義したメソッドたちの検索順序はこれを見る限り、prependのようにC1よりも先にくるんですかね??
と疑問に思っていたところは:RefinementForC_mが:C_mより先に表示されているので、そうなっているようですね。