Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

method_missingで祖先チェーンを遡る:Rubyのメソッド探索の仕組みを徹底解説

機内持ち込みの手荷物をお忘れなく。今日はRubyの祖先チェーン(Ancestor Chain)を最上部まで旅してみましょう。メソッド呼び出しがどのようにチェーンを駆け上がっていくのか、そして目的のメソッドが見つからなかったときに何が起こるのかを追跡します。さらに、火遊びがお好きな方のために、私たちはそこで止まりません。火遊びの続きとして、BasicObject#method_missingのオーバーライドにも挑戦します。うまくいけば、実践的な活用例もご紹介できるかもしれません。それでは出発しましょう!

祖先チェーン(Ancestor Chain)とは

まずは、Rubyにおける祖先チェーンの基本的なルールから確認しましょう。

  • Rubyは単一継承のみをサポートしている
  • 同時に、オブジェクトに対して複数のモジュールをincludeすることもできる

Rubyにおいて、祖先チェーンとは、あるクラスが継承・取り込んだすべてのクラスとモジュールを辿った順序のことです。

実際に例を見てみましょう。

module Auth end
 
module Session end
 
module Iterable end
 
class Collection
  prepend Iterable
end
 
class Users < Collection
  prepend Session
  include Auth
end
 
p Users.ancestors

このコードの出力結果は次のとおりです。

[
  Session, Users, Auth,        # Users関連
  Iterable, Collection,        # Collection関連
  Object, Kernel, BasicObject  # Rubyオブジェクトモデル
]

まず、クラスメソッドancestorsを呼び出すことで、対象クラスの祖先チェーンにアクセスできます。

Users.ancestorsの呼び出し結果には、次の要素が順番に含まれていることがわかります。

  • Usersクラスにprependされたモジュール群
  • Usersクラス自身
  • Usersクラスにincludeされたモジュール群
  • Collectionクラスにprependされたモジュール群(Usersの直接の親クラス)
  • Collectionクラス自身
  • Collectionクラスにincludeされたモジュール群(今回はなし)
  • Objectクラス(すべてのクラスのデフォルトの継承元)
  • Kernelモジュール(Objectにincludeされ、主要なメソッドを提供)
  • BasicObjectクラス(Rubyのルートクラス)

つまり、祖先チェーン上の各クラスやモジュールの出現順序は、常に次のパターンになります。

  • prependされたモジュール群
  • そのクラスまたはモジュール自身
  • includeされたモジュール群

祖先チェーンは主に、オブジェクトやクラスに対してメソッドが呼び出されたときに、Rubyによって辿られます。

メソッド探索パス(Method Lookup Path)

あるオブジェクトにメッセージ(メソッド呼び出し)が送られると、Rubyはそのレシーバーの祖先チェーンを辿り、いずれかがそのメッセージに応答できるかどうかをチェックします。

祖先チェーン上のいずれかのクラスやモジュールがそのメッセージに応答できれば、対応するメソッドが実行され、そこで探索は終了します。

class Collection < Array
end
 
Collection.ancestors # => [Collection, Array, Enumerable, Object, Kernel, BasicObject]
 
collection = Collection.new([:a, :b, :c])
 
collection.each_with_index # => :a

ここでは、collection.each_with_indexというメッセージをEnumerableモジュールが受け取ります。そして、このメッセージに対してEnumerable#each_with_indexメソッドが呼び出されます。

collection.each_with_indexが呼び出されるとき、Rubyは次の順序でチェックを行います。

  • Collectioneach_with_indexに応答するか? → NO
  • Arrayeach_with_indexに応答するか? → NO
  • Enumerableeach_with_indexに応答するか? → YES

ここでRubyは祖先チェーンの探索を停止し、このメッセージに関連付けられたメソッド、つまりEnumerable#each_with_indexを呼び出します。

Rubyでは、この仕組みをメソッド探索パス(Method Lookup Path)と呼びます。

さて、もしレシーバーの祖先チェーンを構成するどのクラスやモジュールも、そのメッセージに応答できなかったらどうなるのでしょうか?

BasicObject#method_missing

いい子でいるのはもう十分です!開発者らしく、例外を投げて物を壊してみましょう。Collectionクラスを実装し、そのインスタンスに対して未知のメソッドを呼び出してみます。

class Collection
end
 
c = Collection.new
c.search('item1') # => NoMethodError: undefined method `search` for #<Collection:0x123456890>

ここでCollectionクラスはsearchメソッドを実装していないため、NoMethodErrorが発生します。しかし、このエラーは一体どこから発生しているのでしょうか?

実は、このエラーはBasicObject#method_missingメソッドの中で発生しています。このメソッドは、メソッド探索パスが特定のメッセージに対応するメソッドを見つけられなかったときに呼び出されるのです。

なるほど…でも、このメソッドは単にNoMethodErrorを発生させるだけです。ならば、Collectionクラスの文脈でこのメソッドをオーバーライドできれば便利そうですよね。

BasicObject#method_missingのオーバーライド

驚くかもしれませんが、method_missingのオーバーライドはまったく問題ありません。このメソッドもまた、メソッド探索パスの仕組みの対象だからです。通常のメソッドとの唯一の違いは、メソッド探索パスが必ず少なくとも一度はこのメソッドを見つけるという点です。

実際、RubyのあらゆるクラスのルートであるBasicObjectクラスは、このメソッドの最小限のバージョンを定義しています。まさに古典的なRubyマジックですね。

それでは、Collectionクラスでこのメソッドをオーバーライドしてみましょう。

class Collection
  def initialize
    @collection = {}
  end
 
  def method_missing(method_id, *args)
    if method_id[-1] == '='
      key = method_id[0..-2]
      @collection[key.to_sym] = args.first
    else
      @collection[method_id]
    end
  end
end
 
collection = Collection.new
collection.obj1 = 'value1'
collection.obj2 = 'value2'
 
collection.obj1 # => 'value1'
collection.obj2 # => 'value2'

ここでは、Collection#method_missingがインスタンス変数@collectionへの委譲(デリゲート)として機能しています。実はこれこそ、Rubyが内部的にオブジェクトの委譲を大まかに処理する方法でもあります(参考:delegateライブラリ)。

存在しないメソッドがセッターメソッド(collection.obj1 = 'value1')の場合、メソッド名(:obj1)がキーとして、引数('value1')が値として@collectionハッシュに格納されます(@collection[:obj1] = 'value1')。

HTMLタグジェネレーターを作ってみよう

method_missingメソッドの裏側の仕組みがわかったところで、再現可能なユースケースを実装してみましょう。

目標は、次のようなDSLを定義することです。

HTML.p    'hello world'             # => <p>hello world</p>
HTML.div  'hello world'             # => <div>hello world</div>
HTML.h1   'hello world'             # => <h1>hello world</h1>
HTML.h2   'hello world'             # => <h2>hello world</h2>
HTML.span 'hello world'             # => <span>hello world</span>
HTML.p    "hello #{HTML.b 'world'}" # => <p>hello <b>world</b></p>

これを実現するために、HTMLタグごとに個別のメソッドを定義せずに済むよう、HTML.method_missingメソッドを実装します。

まずHTMLモジュールを定義し、その中にmethod_missingクラスメソッドを定義します。

module HTML
  def HTML.method_missing(method_id, *args, &block)
    "<#{method_id}>#{args.first}</#{method_id}>"
  end
end

このメソッドは、呼び出されなかったメソッド名であるmethod_idを使って、シンプルにHTMLタグを組み立てます。たとえばHTML.divへの呼び出しであれば、:divが使われます。

なお、クラスメソッドもまたメソッド探索パスの対象となることに注意してください。

このHTMLタグジェネレーターは、次のような拡張も可能です。

  • ブロック引数を使ったネストしたタグの処理
  • <br/>のような単一タグ(閉じタグなし)の処理

しかし何より注目すべきは、わずか数行のコードで膨大な種類のHTMLタグを生成できるという点です。

まとめると:

method_missingは、ほとんどのコマンドが共通のパターンを共有するようなDSLを作成するための、優れたエントリーポイントとなります。

まとめ

今回はRubyの祖先チェーンを最上部まで辿り、BasicObject#method_missingの世界に飛び込みました。BasicObject#method_missingRubyフックメソッド(Ruby Hook Methods)の一つであり、オブジェクトのライフサイクルにおける特定のタイミングで介入するために使われます。他のフックメソッドと同様、このフックメソッドも慎重に扱う必要があります。「慎重に」というのは、Rubyオブジェクトモデルの挙動を決して変更してはならない、ということです。ただし、遊んでみたり、ブログ記事を書いたりするときは別ですが ;-)

Voilà!

  1. Rubyのtransposeメソッドで行を列に変換する方法

    Rubyでグリッド状のデータ(多次元配列)を扱うときに便利なのが、Arrayクラスのtransposeメソッドです。この記事では、行と列を入れ替える「転置」の基本から、三目並べ(○×ゲーム)のような実践的な活用例までをわかりやすく解説します。 たとえば、3×3の正方形グリッドを多次元配列として持っているとしましょう。ここから「行を列に変換したい」という場面は意外とよくあります。 なぜそんなことが必要になるのでしょうか? 代表例が、古典的なゲームである三目並べです。 盤面をグリッドとして保存し、勝利判定を行うには、行・列・斜めのすべてをチェックしなければなりません。 ところが、グリッドを普通の配

  2. メタプログラミングの隠れたコストとは?知っておくべき3つのデメリット

    「メタプログラミング」という言葉は、とても高度で洗練された技術のように聞こえますが、果たして本当に良いものなのでしょうか? 結論から言えば、使いどころ次第で非常に有用です。しかし多くの開発者は、メタプログラミングの利用には一定のコストが伴うことを理解していません。 まずは認識を合わせるために… メタプログラミングとは何か? ここでは、メタプログラミングを以下のような手法全般と定義します。 コードの構造そのものを変更する(例:define_method) 文字列を、あたかも実際のRubyコードの一部であるかのように実行する(例:instance_eval) 何らかのイベントへの反応として動作す