14
1

【Rails】include と extend と ActiveSupport::Concern

Last updated at Posted at 2023-12-04

includeextend の違いってなんだ?
内部では何が起きているのか..?

ActiveSupport::Concern を使うにも included 使うけど何をしているのか

ActiveSupport::Concernを使う際に学んだことをメモします。

extendについて

  • extend したクラスのシングルトンクラス(特異クラス)のインスタンスメソッドとして追加できる
  • extendしたモジュールのメソッドはクラスメソッドのように呼び出せる

順を追うと...

シングルトンクラスとは...

あるオブジェクト専用のクラスであり、そのオブジェクトだけに属する
こちらが参考になります
ちなみにobject.singleton_classで確認することができる

class Bar; end

p Bar.singleton_class # => #<Class:Bar>
bar = Bar.new
p bar.singleton_class # => #<Class:#<Bar:0x0000000102846720>>

BarクラスClassクラス のオブジェクトなので、
Classクラス のシングルトンクラスが生成されます。

また、「そのオブジェクトのみに属する」という特徴があるため、オブジェクトを別々で生成した場合も、それぞれでシングルトンクラスが生成されます。(ややこしい...)

bar = Bar.new
bar_1 = Bar.new

p bar.singleton_class # => #<Class:#<Bar:0x00000001007a9670>>
p bar_1.singleton_class # => #<Class:#<Bar:0x00000001007a95d0>>

特異メソッドとクラスメソッドの関連性

クラスメソッドは特異メソッドの一種と見なすことができます。

というのも
クラスメソッドは特異クラスのインスタンスメソッドになるからです。
実際に確認してみましょう。

class Bar
  # クラスメソッドを定義
  def self.good
    p 'いいね!!'
  end
end

p Bar.singleton_class.instance_methods # => [:good,...]

上記のように Bar のクラスメソッドは、
シングルトンクラスのインスタンスメソッドに定義されていそうです。

シングルトンメソッドを追加できる

define_singleton_methodでシングルトンメソッドを後で追加することができます。

class Bar
  def self.good
    p 'いいね!!'
  end
end

Bar.define_singleton_method :nice do
  p '素敵!'
end

p Bar.singleton_class.instance_methods # => [:nice, :good,...]
p Bar.singleton_methods # => [:nice, :good]

さて、extendについて

  • extendしたモジュールのメソッドはクラスメソッドのように呼び出せる

上記のように説明できる理由として

クラスメソッドは
シングルトンクラスのインスタンスメソッド(特異メソッド)であり、

extendは
特異メソッドに新しくメソッドを追加できる

からです。

module Foo
  def hello(name)
    p "こんにちは、#{name}さん"
  end
end

class Bar
  extend Foo
end

# シングルトンクラスに hello メソッドが追加されている
p Bar.singleton_methods  # => [:hello]
# クラスメソッドのように hello メソッドを呼び出す
Bar.hello('太郎')  # => こんにちは、太郎さん

# インスタンスメソッドとして呼び出そうとすると失敗する
# Bar クラスのインスタンスである bar のシングルトンメソッドには hello が定義されていないから...!
bar = Bar.new
p bar.hello  # => NoMethodError: undefined method `hello' for #<Bar:0x...

include について

include とは、指定されたモジュールの定義 (メソッド、定数) を引き継ぐことです。
モジュールによる機能追加は、そのクラスとスーパークラスとの継承関係の間にそのモジュールが挿入されることで実現されます。
そのため多重継承の代わりに用いられており、 Mix-in とも呼ばれます。

module Foo
  def hello(name)
    puts "こんにちは、#{name}さん"
  end
end

class Hoge; end

class Bar < Hoge
  include Foo
end

# includeされるクラスとそのスーパークラスとの間に差し込まれる
p Bar.ancestors # => [Bar, Foo, Hoge, Object, Kernel, BasicObject]

# includeしたモジュールのインスタンスメソッドを引き継げる
Bar.new.hello('太郎') # => こんにちは、太郎さん

さて、ActiveSupport::Concernですよ...

ActiveSupport::Concernのソースを参照しながら見ていきたいと思います。

普通にextend, includeではダメなのか?

こちらの記事に分かりやすく書かれていました。
[Rails] ActiveSupport::Concern の存在理由

Rubyの mix-in は通常以下のようなルールで記述します

  • 切り出した機能を module として作成
  • 共通メソッドを module 内に記述
  • クラスメソッドや組み込み先クラスの内部処理を module に入れたい場合は特殊なメソッドを使う必要がある

ActiveSupport::Concern はこの3番目にある 特殊なメソッド の記述を簡単にしてくれます。

簡単にいうと、include する側のクラスのコンテキストで実行したい特殊な処理を、スッキリいい感じに書けるようになる。

これに関してはコードを見た方が早いです。

ActiveSupport::Concern なし

  • base はこのモジュールが取り込まれる先のクラスになる。こちらを参照
  • extendは言わずもがな。modele内で定義したクラスメソッドを、include 先のシングルトンメソッドとして追加している。
  • class_evalについてはこちらの記事を参照されたい。要するにメタプログラミング的に、base のインスタンスメソッドにメソッドを追加することでbaseクラスのコンテキストでメソッドを実行できるようになる。
  • self.included(base)については、moduleincluded メソッド を定義しておくと、moduleinclude された時に実行されるようになる。こちらを参照
module M
  def self.included(base)
    base.extend ClassMethods
    base.class_eval do
      scope :disabled, -> { where(disabled: true) }
    end
  end

  module ClassMethods
    ...
  end
end

ちなみに、
concern で追加したインスタンスメソッドと、追加先のクラスのインスタンスメソッドの命名がバッティングした場合は、追加先のクラスのインスタンスメソッドが優先的に実行される。

module M
  def self.included(base)
    base.class_eval do
      def example_method
        p "追加されたメソッドです"
      end
    end
  end
end

class MyClass
  include M
  def example_method
    p '元々あったメソッドです'
  end
end

MyClass.new.example_method # => '元々あったメソッドです'

というのもincludeされたmoduleは継承関係的に上位に来るので、命名が被った場合は小クラスのメソッドが優先的に実行されるからです。

p MyClass.ancestors #=> [MyClass, M, Object, Kernel, BasicObject]

ActiveSupport::Concern あり

  • include 先のコンテキストで実行したいものは include do; end内に記述
  • クラスメソッド的に使用したいものは class_method に記述
module M
  extend ActiveSupport::Concern

  included do
    scope :disabled, -> { where(disabled: true) }
  end

  class_methods do
    ...
  end
end

シンプルな書き方でいい感じに処理できるようになりました。🎉

他にも 依存関係の解消までやってくれる などの利点があるのだが、
ここでは触れないでおく。

内部ではどんな処理をしているのか

concern

module Concern
    class MultipleIncludedBlocks < StandardError #:nodoc:
      def initialize
        super "Cannot define multiple 'included' blocks for a Concern"
      end
    end

    class MultiplePrependBlocks < StandardError #:nodoc:
      def initialize
        super "Cannot define multiple 'prepended' blocks for a Concern"
      end
    end

    def self.extended(base) #:nodoc:
      base.instance_variable_set(:@_dependencies, [])
    end

    def append_features(base) #:nodoc:
      if base.instance_variable_defined?(:@_dependencies)
        base.instance_variable_get(:@_dependencies) << self
        false
      else
        return false if base < self
        @_dependencies.each { |dep| base.include(dep) }
        super
        base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
        base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
      end
    end

    def prepend_features(base) #:nodoc:
      if base.instance_variable_defined?(:@_dependencies)
        base.instance_variable_get(:@_dependencies).unshift self
        false
      else
        return false if base < self
        @_dependencies.each { |dep| base.prepend(dep) }
        super
        base.singleton_class.prepend const_get(:ClassMethods) if const_defined?(:ClassMethods)
        base.class_eval(&@_prepended_block) if instance_variable_defined?(:@_prepended_block)
      end
    end

    def included(base = nil, &block)
      if base.nil?
        if instance_variable_defined?(:@_included_block)
          if @_included_block.source_location != block.source_location
            raise MultipleIncludedBlocks
          end
        else
          @_included_block = block
        end
      else
        super
      end
    end

    def prepended(base = nil, &block)
      if base.nil?
        if instance_variable_defined?(:@_prepended_block)
          if @_prepended_block.source_location != block.source_location
            raise MultiplePrependBlocks
          end
        else
          @_prepended_block = block
        end
      else
        super
      end
    end

    def class_methods(&class_methods_module_definition)
      mod = const_defined?(:ClassMethods, false) ?
        const_get(:ClassMethods) :
        const_set(:ClassMethods, Module.new)

      mod.module_eval(&class_methods_module_definition)
    end
  end
end

はい、意味がわかりませんね😭
一つずつ噛み砕いていく

self.extended(base)

モジュールをextendした時に呼ばれ、extended を呼び出してインスタンス変数である@_dependenciesをセット。

    def self.extended(base) #:nodoc:
      base.instance_variable_set(:@_dependencies, [])
    end

実際に使われる、included, class_methodsについて見てみる

included

    def included(base = nil, &block)
      if base.nil?
        if instance_variable_defined?(:@_included_block)
          if @_included_block.source_location != block.source_location
            raise MultipleIncludedBlocks
          end
        else
          @_included_block = block
        end
      else
        super
      end
    end

base の初期値は nil が確定
続いて if instance_variable_defined?(:@_included_block) では、初回実行時はfalse が確定なので @_included_block = block が実行される。

class_methods

def class_methods(&class_methods_module_definition)
    mod = const_defined?(:ClassMethods, false) ?
    const_get(:ClassMethods) :
    const_set(:ClassMethods, Module.new)

    mod.module_eval(&class_methods_module_definition)
end

const_defined?(:ClassMethods, false) で、 ClassMethods という名前の module が存在するかを確認する。
(falseオプションで継承関係を無視してそのクラス内のみで確認する)

もし、見つかればそれを取得して、なければ module として定義する。

その後、module_eval によって ClassMethods のインスタンスメソッドとしてブロック引数である &class_methods_module_definition を追加している。

つまり

module Sample
  extend ActiveSupport::Concern

  class_methods do
    def class_method
      p 'クラスメソッドです'
    end
  end
end

これが

こうなっているのと同じ状態

module Sample
  module ClassMethods
    def class_method
      'クラスメソッドです'
    end
  end
end

ClassMethodsモジュールを作成し、その内部には引数で受けたメソッドがインスタンスメソッドとして定義される。

閑話休題

現状
included : @_dependenciesをセットする
class_methods : ClassMethods モジュールを作成し、ブロックで指定したメソッドを持たせる

この段階では include されるクラスにクラスメソッドを追加したり、include される側のコンテキストで処理を実行することはできない。

そこで、重要となってくるのが append_features メソッドらしい。

append_features

このメソッドは Module#include の実体であり、...

と説明がある通り、includedされた時に呼ばれることがわかります
試してみる🧐

module M
  def self.append_features(_base)
    p 'includeされた'
  end
end

class MyClass; include M; end

MyClass.new => # 'includeされた'

確かにincludeされる時に実行される。

つまり、 ActiveSupport::Concernextend したモジュールを include すると、内部で実行されるはずの append_features メソッド を独自のメソッドでオーバーライドしているようだ。

    def append_features(base)
      if base.instance_variable_defined?(:@_dependencies)
        base.instance_variable_get(:@_dependencies) << self
        false
      else
        return false if base < self
        @_dependencies.each { |dep| base.include(dep) }
        super
        base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
        base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
      end
    end
  • base とは include される側のクラスになる。

  • if base.instance_variable_defined?(:@_dependencies) の条件分岐では、
    includeされる側のクラスが concern でない場合、@_dependenciesがないためfalseになる。

  • super
    によって既存の append_features が継承されモジュールが include される。
    ちなみに super がないと、include できないようだ(こちらを参照)

  • base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
    によって
    ClassMethods モジュールを extend することで ClassMethods に定義されたメソッドを base クラスのクラスメソッドとして追加することができる。

  • base.class_eval(&@_included_block)
    によって
    class_eval により base クラスのコンテキストで @_included_block を実行できるようになる。

以上、included, class_methods, append_featuresによってActiveSupport::Concernの大枠の仕組みが実現できているようだ。

難しい(;´д`)

余談

prependedModule#prepend されたときに実行されるので、今回はスルーします。
prepend_featuresModule#prepend から呼び出されるメソッドで、 prepend の処理の実体であるため、こちらも同様にスルーします。

最後に

ここまで見ていただきありがとうございます。m(._.)m

extendincludeの内部挙動について知れたのは個人的に良かったです。

ActiveSupport::Concernが開発された背景である 「モジュールの依存関係を解消する」 については今回ノータッチだったので、別の機会に勉強できたらと思います。

参考

14
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
1