最近メタプログラミングを勉強していて、ActiveSupport::Concernの仕組みがRubyを理解するのに大事だと感じたので、今回はそれをまとめていきたいと思います。
includeの連鎖の問題
まずはActiveSupport::Concernが必要になった背景から書いていこうと思います。
インクルードするモジュールが、また別のモジュールをインクルードしているとします。
module SecondLevelModule
def self.included(base)
base.extend ClassMethods
end
def second_level_instance_method; 'ok'; end
module ClassMethods
def second_level_class_method; 'ok'; end
end
end
module FirstLevelModule
def self.included(base)
base.extend ClassMethods
end
def first_level_instance_method; 'ok'; end
module ClassMethods
def first_level_class_method; 'ok'; end
end
include SecondLevelModule
end
class BaseClass
include FirstLevelModule
end
BaseClass.new.first_level_instance_method #=> ok
BaseClass.first_level_class_method #=> ok
BaseClass.second_level_class_method #=> undefined method `second_level_class_method' for BaseClass:Class (NoMethodError)
これはFirstLevelModuleからインクルードした際、baseがFirstLevelModuleになっているからです。なので、second_level_class_methodはFirstLevelModuleのクラスメソッドになっています。
FirstLevelModule.second_level_class_method #=> ok
この問題を解決するためにRailsで導入されたのが、ActiveSupport::Concernです。
ActiveSupport::Concernのソースコード概要
モジュールがConcernをエクステンドすると、Rubyがフックメソッドのextendedを呼び出します。そして、その中でクラスインスタンス変数@_dependenciesをセットします。
module ActiveSupport
module Concern
def self.extended(base) # :nodoc:
base.instance_variable_set(:@_dependencies, [])
end
# 略
end
もう一つActiveSupport::Concernを語る上で重要なRubyのコアメソッドであるModule#append_featuresがあります。これはincludeしたときに呼ばれてModule#includedとは別に実際にインクルードするためのメソッドです。インクルードされたモジュールがインクルーダーの継承チェーンに含まれているかを確認して、なければモジュールを追加します。ActiveSupport::Concernではこれをオーバーライドしています。
module ActiveSupport
module Concern
def self.extended(base) # :nodoc:
base.instance_variable_set(:@_dependencies, [])
end
def append_features(base) # :nodoc:
if base.instance_variable_defined?(:@_dependencies)
# base(インクルーダー)がconcernの場合、自身を継承チェーンに追加する代わりに依存関係のリストに追加している
base.instance_variable_get(:@_dependencies) << self #=> [MyConcern2]
false
else
# インクルーダーがconcernでない場合、すでにインクルーダーの継承チェーンに自身が入っているか確認している
return false if base < self
p "MyConcernの@_dependenciesは#{@_dependencies}"
@_dependencies.each { |dep| base.include(dep) }
super
p "const_getは #{const_get(:ClassMethods)}"
base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
end
end
end
end
# MyConcern2の定義
module MyConcern2
extend ActiveSupport::Concern
def an_instance_method_of_my_concern2
'MyConcern2のインスタンスメソッド'
end
module ClassMethods
def a_class_method_of_my_concern2
'MyConcern2のクラスメソッド'
end
end
end
# MyConcernの定義
module MyConcern
extend ActiveSupport::Concern
include ::MyConcern2
# @_dependencies => [MyConcern2]
# フックメソッドでクラスのインスタンス変数@_dependencies = []がセットされる
def an_instance_method; 'インスタンスメソッド'; end
module ClassMethods
def a_class_method; 'クラスメソッド'; end
end
end
class MyClass
include MyConcern
end
まずはMyConcern2の定義が行われて、ActiveSupport::Concernがエクステンドされているので、クラスインスタンス変数@_dependenciesがセットされます。
次にMyConcernの定義が行われて、そこでも@_dependenciesがセットされます。その後すぐに、MyConcern2をインクルードしているので、オーバーライドされたappend_featuresメソッドが実行されますが、base(MyConcern)はconcernなので、自身を継承チェーンに追加する代わりに依存関係のリスト(@dependencies)に追加しています。
MyConcern.instance_variable_get(:@_dependencies) #=> [MyConcern2]
最後にMyClassの定義が行われて、そこでMyConcernがインクルードされますが、MyClassはActiveSupport::Concernをエクステンドしてないので、append_featureメソッドのelse文の方にいきます。
そこではまず、すでにインクルーダーの継承チェーンに自身が入っているか確認しています。入っていなければ次に行き、インクルーダーに依存関係を再帰的にインクルードしていきます。ここで再帰的にインクルードを行うため、上のコードを実行するとelseからの出力が二回されます。
"MyConcernの@_dependenciesは[MyConcern2]"
"MyConcernの@_dependenciesは[]"
"const_getは MyConcern2::ClassMethods"
"const_getは MyConcern::ClassMethods"
再帰的に実行したあと、superでModule#append_featureを呼び出し、自分自身をインクルードします。そして最後にClassMethodsモジュールをエクステンドして終了です。
こうすることで、インクルードしたモジュールがさらにインクルードしているモジュールのクラスメソッドも問題なく使えるようになっています。
MyClass.a_class_method_of_my_concern2 #=> "MyConcern2のクラスメソッド"
さいごに
Rubyの柔軟性がゆえにできるトリックでしたが、理解するのに中々骨が折れました。メタプログラミングは奥が深いですが、使いこなすにはまだまだ時間がかかりそうです。