ActiveRecord の歩き方 - Association 編(4)

前回までの復習

この「ActiveRecord の歩き方 - Association 編」も今回で4回目を迎える。そもそもの疑問の始まりは、

▼コード 1
class Entry < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
end

というモデルにおいて、

▼コード 2
entry = Entry.find(1)
entry.comments # <= ここに注目

の entry.comments の "comments" は何?ということだった。


いままで分かったことをまとめると、

  • entry.comments の "comments" は Entry クラスのインスタンスメソッド(Entry#comments)
  • associations.rb の has_many() から間接的に呼びだされた collection_reader_method() が Entry#comments を定義している。
  • collection_reader_method() では define_method() を使って Entry#comments を定義している。
  • その定義を見ると、Entry#comments の戻り値は HasManyAssociation オブジェクトである。
  • HasManyAssociation は Entry#comments でインスタンス化されるが、このとき @finder_sql が作られる。@finder_sql は comments をデータベースから引き出すときの SQL 文の WHERE 節に当たる。

ということであった。

そしていま残っている疑問は、

  • Entry#comments が HasManyAssociation オブジェクトを返すのにもかかわらず、なぜ p entry.comments とすると Array オブジェクトが表示されるのか?

ということである。今日はこの点を見て行きたい。

AssociationProxy

前回見たように、アソシエーション関係のクラスには次のような継承関係がある。



図1:Association クラス群の継承関係


AssociationProxy は他のすべてのクラスのスーパークラスになっているので一番初めに読み込まれるものと考えられる。その AssociationProxy のクラス定義の冒頭に面白い部分がある。

▼コード 3: メソッド呼び出し禁止
module ActiveRecord
  module Associations
  class AssociationProxy #:nodoc:
 ...
    instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?$|^send$|proxy_)/ } # <= 注目
 ...
(associations/association_proxy.rb)
  • Module#instance_methods は、そのモジュールで定義されているメソッド名の一覧を配列で返す。
  • Module#undef_method は、インスタンスに対してそのメソッドを呼び出すことを禁止する。

つまり、ここでやっていることは、AssociationProxy のスーパークラス(Kernel と Object) で定義されているインスタンスメソッドをいくつかの例外を除いて、すべて呼び出し禁止にしているのだ!まったく思いきったことをする。こういうことがいとも簡単にできてしまう Ruby はおそろしい。


ここまで見たところで、

▼コード 4
p entry.comments 

とした場合に、何が起こっているか詳細に見てみよう。まず entry.comments が評価される。この結果は HasManyAssociation オブジェクトである。これを仮に assoc と呼ぶことにしよう。すると上行は次行と等価である。

▼コード 5
assoc = entry.comments
p assoc # assoc は HasManyAssociation インスタンス

次に、p とはどんなメソッドか? refe すると、次のような結果が得られた。

▼refe の出力
Kernel#p
--- p(obj, [obj2, ...])

    obj を人間に読みやすい形で出力します。以下のコードと同じです。
    (Object#inspect [Object/inspect]参照)

      print obj.inspect, "\n", obj2.inspect, "\n", ...

    nil を返します。

Object#inspect はオブジェクトの内容について人間に読みやすい文字列を返す。
したがって、上行はこう変形できる。

▼コード 6
assoc = entry.comments
print assoc.inspect, "\n"

ところで、assoc.inspect を評価したとき何が起こるだろうか? 普通 inspect は Object クラスで定義されるインスタンスメソッドなのだが、実は assoc で inspect は呼び出しが禁止されている。「▼コード 3: メソッド呼び出し禁止」で見たように assoc は ProxyAssociation のサブクラスのインスタンスだからだ。assoc はあたかも初めから inspect() が無かったように振る舞い、代わりに method_missing() が呼び出される。*1

▼コード 7: ActiveRecord::Associations::HasManyAssociation#method_missing
def method_missing(method, *args, &block)
  if @target.respond_to?(method) || (!@reflection.klass.respond_to?(method) && Class.respond # (A)
_to?(method))
    super # (B)
  else
    (中略)
  end
end

@target とは何か?先回りして言うと、これは配列(Array)である。entry.comments の例を使うと、この @target に検索結果のComment インスタンスの配列が入っている。ここで、assoc.inspect を評価する。すると、assoc.method_missing(:inspect) が呼び出される。@target(=Array) は当然 inspect メソッドを呼び出しうるから、

  • (A) @target.respond_to?(method = :inspect) は true になる
  • (B) スーパークラスの method_missing が呼び出される。実際にはこの super が呼び出すメソッドは、AssociationProxy#method_missing である。
▼コード 8: ActiveRecord::Associations::AssociationProxy#method_missing
def method_missing(method, *args, &block)
  if load_target                        # (A)
    @target.send(method, *args, &block) # (B)
  end
end

これは短い。

  • (A) load_target() できたら(後述)
  • (B) @target に method というメッセージを送る。

ということである。load_target は名前のとおり @target をデータベースから読み込んでいるのだろう。いろいろゴチャゴチャ見てきたが、要するに self に送られてきたメッセージを基本的には @target に転送しているのである。だから、

assoc.inspect == @target.inspect

と考えてよい。だから p entry.comments の出力結果は Array になるのである。

まとめ

HasManyAssociation を Array に見せかけるテクニックは、

  • まず HasManyAssociation のインスタンスメソッドをすべて剥ぎ取り、
  • 定義されてないメソッド呼び出しについては、HasManyAssociation のインスタンス変数 @target(=Array) にそのメッセージを転送する。

の2段階で成り立っている。


まったくグタグタの文章になってしまった。目指す青木峰郎氏の文章のような明晰さは望むべくもない。まあいいや。これが私の実力である。次回は、「▼コード 8: ActiveRecord::Associations::AssociationProxy#method_missing」で出てきた load_target() について中身を見てみたい。乞うご期待。

*1:Rubyインスタンスに対して定義されていないメソッドを呼び出した場合、method_missing() という名前のメソッドがあれば、それを代わりに呼び出す。