Ruby

【Rails】ActiveSupport::Concernのしくみについて

最近メタプログラミングを勉強していて、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の柔軟性がゆえにできるトリックでしたが、理解するのに中々骨が折れました。メタプログラミングは奥が深いですが、使いこなすにはまだまだ時間がかかりそうです。

ABOUT ME
sakai
東京在住の30歳。元々は車部品メーカーで働いていてましたが、プログラミングに興味を持ちスクールに通ってエンジニアになりました。 そこからベンチャー → メガベンチャー → 個人事業主になりました。 最近は生成 AI 関連の業務を中心にやっています。 ヒカルチャンネル(Youtube)とワンピースが大好きです!