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

ActiveSupportの#descendantsメソッド徹底解説:Rubyの継承とモジュールの仕組みから実践的な活用法まで

Railsは、Rubyの組み込みオブジェクトに多くの機能を追加しています。これは「Rubyの方言」とも呼ばれ、Rails開発者が1.day.agoのようなコードを書ける理由でもあります。

こうした追加メソッドのほとんどはActiveSupportに含まれています。今回は、その中でもやや知名度の低い、ActiveSupportがClassクラスに直接追加しているdescendantsメソッドについて掘り下げていきます。このメソッドは、呼び出したクラスのすべての子孫クラスを返します。たとえばApplicationRecord.descendantsを実行すると、アプリケーション内でそれを継承しているクラス(つまりアプリ内のすべてのモデル)が取得できます。本記事では、このメソッドの仕組み、使うとどのようなメリットがあるのか、そしてRubyが標準で持つ継承関連メソッドをどう補完するのかを見ていきます。

オブジェクト指向言語における継承

まず、Rubyの継承モデルをおさらいしましょう。他のオブジェクト指向(OO)言語と同様に、Rubyではオブジェクトが階層構造の中に配置されます。クラスを作成し、そのサブクラスを作り、さらにそのサブクラスを作る…という具合です。この階層を上方向に辿ると「祖先」の一覧が得られます。またRubyには、クラスや整数、さらにはnilまで含めてすべてがオブジェクトであるという特徴があります。一方、Javaなど一部の言語ではパフォーマンス上の理由から、真のオブジェクトではない「プリミティブ型」(整数、浮動小数点数、真偽値など)を使うことがあります。

Rubyをはじめとするオブジェクト指向言語は、メソッドをどこから探し、どれが優先されるべきかを判断するために、祖先の情報を追跡し続ける必要があります。

class BaseClass
  def base
    "base"
  end

  def overridden
    "Base"
  end
end

class SubClass < BaseClass
  def overridden
    "Subclass"
  end
end

ここでSubClass.new.overriddenを呼び出すと"SubClass"が返ります。しかしbaseメソッドはSubClassには定義されていないため、Rubyは祖先を順番に辿り、どのクラスがそのメソッドを実装しているか(実装されていれば)を探します。祖先の一覧はSubClass.ancestorsを呼び出すだけで確認できます。Rails環境では、結果は次のようになります。

[SubClass,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ActiveSupport::ToJsonWithActiveSupportEncoder,
 Object,
 PP::ObjectMixin,
 JSON::Ext::Generator::GeneratorMethods::Object,
 ActiveSupport::Tryable,
 ActiveSupport::Dependencies::Loadable,
 Kernel,
 BasicObject]

この一覧を細かく分解することはしませんが、重要なのはSubClassが先頭にあり、その下にBaseClassが続いているという点です。また、リストの末尾にはBasicObjectがあります。これはRubyにおける最上位のオブジェクトであり、常に階層の一番下に位置します。

モジュール(いわゆる「Mixin」)

ここにモジュールが加わると、話は少し複雑になります。モジュールはクラス階層上の「祖先」ではありませんが、クラスに「include」することで、Rubyはいつモジュール内のメソッドを探すべきか、複数のモジュールがincludeされている場合にどちらを先にチェックすべきかを把握しなければなりません。

この種の「多重種の「多重継承」を許さない言語もありますが、Rubyはさらに一歩進んで、includeするかprependするかによって、モジュールを祖先チェーンのどの位置に挿入するかを選択できます。

モジュールのprepend

prependされたモジュールは、名前が示唆するとおり、クラスよりに祖先リストへ挿入されます。そのため、クラス側のメソッドを基本的に上書きすることになります。さらに、prependしたモジュールのメソッド内でsuperを呼ぶと、元のクラスのメソッドを呼び出せます。

module PrependedModule
  def test
    "module"
  end

  def super_test
    super
  end
end

# 先ほどの`BaseClass`を再利用
class SubClass < BaseClass
  prepend PrependedModule

  def test
    "Subclass"
  end

  def super_test
    "Super calls SubClass"
  end
end

このときSubClassの祖先は次のようになります。

[PrependedModule,
 SubClass,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ...
]

新しい祖先リストではPrependedModuleが最初に来ています。つまり、SubClassに対して呼び出されたメソッドは、まずこのモジュール内から探索されます。これは同時に、PrependedModule内でsuperを呼ぶとSubClassのメソッドが呼ばれることも意味します。

> SubClass.new.test
=> "module"
> SubClass.new.super_test
=> "Super calls SubClass"

モジュールのinclude

一方、includeされたモジュールは、クラスの後ろに挿入されます。これにより、基底クラスで処理されるはずだったメソッドを「横取り」するのに適した位置になります。

class BaseClass
  def super_test
    "Super calls base class"
  end
end

module IncludedModule
  def test
    "module"
  end

  def super_test
    super
  end
end

class SubClass < BaseClass
  include IncludedModule

  def test
    "Subclass"
  end
end

この構成でのSubClassの祖先は次のとおりです。

[SubClass,
 IncludedModule,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ...
]

今度はSubClassが探索の起点となるため、RubyはIncludedModule内のメソッドを、SubClassに存在しない場合にのみ実行します。superについては、SubClass内からのsuper呼び出しはまずIncludedModuleへ向かい、IncludedModuleからのsuper呼び出しはBaseClassへ向かいます。

言い換えれば、includeされたモジュールは祖先階層において、サブクラスと基底クラスの「間」に位置します。これにより、本来なら基底クラスが処理するメソッドをインターセプト(横取り)できるのです。

> SubClass.new.test
=> "Subclass"
> SubClass.new.super_test
=> "Super calls BaseClass"

このような「指揮系統」があるため、Rubyはクラスの祖先を追跡する必要があります。しかし逆は成り立ちません。ある特定のクラスが与えられたとき、Rubyはその子クラス(子孫クラス)を追跡する必要はありません。メソッドの実行に子孫の情報は不要だからです。

祖先の順序

鋭い読者の方ならお気づきかもしれませんが、1つのクラスで複数のモジュールを使う場合、それらをinclude(またはprepend)する順序によって結果が変わることがあります。たとえば、メソッドの内容次第で、次のクラス定義と

class SubClass < BaseClass
  include IncludedModule
  include IncludedOtherModule
end

こちらのクラス定義では、まったく異なる振る舞いをする可能性があります。2つのモジュールが同名のメソッドを持っている場合、どちらが優先され、super呼び出しがどこに解決されるかは、この記述順序で決まるからです。筆者としては、モジュールのinclude順序などを気にしなくて済むよう、そもそもメソッド名が重複する設計は極力避けることをおすすめします。

実践での活用例

モジュールのincludeprependの違いを知っておくことは大切ですが、実際にどちらを選べばよいかは、現実的な例で見たほうが分かりやすいでしょう。筆者がこれらのモジュールを使う主なケースは、Railsエンジンのカスタマイズです。

Railsエンジンの中でおそらく最も人気があるのがdeviseです。ここでは、パスワードダイジェストのアルゴリズムを変更したい場面を想定してみましょう。ただし、その前にひとつ注意点があります。

筆者が日常的にモジュールを使うのは、デフォルトのビジネスロジックを保持するRailsエンジンの挙動をカスタマイズする場合です。つまり、自分たちが管理しているコードの振る舞いを上書きしています。もちろん同じ手法は任意のRubyコードに適用できますが、自分が管理していないコード(他人がメンテナンスしているgemなど)を上書きすることはおすすめしません。外部コードに変更が入った際、自分の変更と互換性がなくなる恐れがあるからです。

deviseのパスワードダイジェスト処理は、Devise::Models::DatabaseAuthenticatableモジュール内の以下のコードで行われています。

  def password_digest(password)
    Devise::Encryptor.digest(self.class, password)
  end

  # パスワード検証も同様:
  def valid_password?(password)
    Devise::Encryptor.compare(self.class, encrypted_password, password)
  end

deviseは、独自のDevise::Encryptable::Encryptorsを作成することでこのアルゴリズムをカスタマイズでき、これが正攻法です。ただし今回は説明のため、あえてモジュールを使ってみます。

# app/models/password_digest_module
module PasswordDigestModule
  def password_digest(password)
    # deviseのデフォルトであるbcryptの方がパスワードには適しているが、
    # ここでは説明のためにsha1を使用
    Digest::SHA1.hexdigest(password)
  end

  def valid_password?(password)
    Devise.secure_compare(password_digest(password), self.encrypted_password)
  end
end

begin
  User.include(PasswordDigestModule)
# プロのヒント:ここでUserを参照すると、クラス読み込み時に
# ActiveRecordがデータベースへのアクセスを試みます。
# その結果、`rails db:create`などのコマンドが失敗することがあります。
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
end

このモジュールを読み込むには、開発環境でRails.application.eager_load!を呼び出すか、ファイルを読み込むRailsイニシャライザを追加する必要があります。実際に試してみると、期待どおりに動作することが確認できます。

> User.create!(email: "one@test.com", name: "Test", password: "TestPassword")
=> #<User id: 1, name: "Test", created_at: "2021-05-01 02:08:29", updated_at: "2021-05-01 02:08:29", posts_count: nil, email: "one@test.com">
> User.first.valid_password?("TestPassword")
=> true
> User.first.encrypted_password
=> "4203189099774a965101b90b74f1d842fc80bf91"

このケースではincludeでもprependでも結果は同じですが、ここにひとつひねりを加えてみましょう。Userモデルが独自のpassword_saltメソッドを実装しており、それをモジュール側で上書きしたい場合はどうでしょうか。

class User < ApplicationRecord
  # デフォルトのdeviseモジュールをinclude。他に利用可能なもの:
  # :confirmable, :lockable, :timeoutable, :trackable, :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_many :posts

  def password_salt
    # 非常に悪いパスワードソルトの生成方法。
    # 純粋に説明のためのものです
    Base64.encode64(email)[0..-4]
  end
end

そして、パスワードダイジェストの生成時にモジュール独自のpassword_saltメソッドを使うよう更新します。

  def password_digest(password)
    # deviseのデフォルトであるbcryptの方がパスワードには適しているが、
    # ここでは説明のためにsha1を使用
    Digest::SHA1.hexdigest(password + "." + password_salt)
  end

  def password_salt
    # さらに悪いパスワードソルトの生成方法
    "salt"
  end

これで、includeprependの挙動が異なる状況になりました。どちらを使うかで、Rubyが実行するpassword_saltメソッドが変わるからです。prependを使うとモジュールが優先され、結果は次のようになります。

> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.salt"

モジュールをincludeに変更すると、今度はUserクラス側の実装が優先されます。

> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.dHdvQHRlc3QuY2"

筆者は一般的にprependを第一候補にします。モジュールを書くときは、それをサブクラスのように扱い、「モジュール内のメソッドはクラス側のバージョンを上書きする」と考えるほうが直感的だからです。もちろん、それが望ましくないケースもあるため、Rubyにはincludeという選択肢も用意されているわけです。

descendantsメソッド

ここまで、Rubyがメソッド実行時の優先順位を決めるためにクラスの祖先を追跡していること、そしてモジュールによってそのリストに要素を挿入できることを見てきました。しかしプログラマーにとっては、あるクラスの子孫をすべて列挙できると便利なことがあります。そこで登場するのがActiveSupportの#descendantsメソッドです。このメソッドは非常に短く、必要ならRails外でも簡単に再現できます。

class Class
  def descendants
    ObjectSpace.each_object(singleton_class).reject do |k|
      k.singleton_class? || k == self
    end
  end
end

ObjectSpaceは、現在メモリ上に存在するすべてのRubyオブジェクトの情報を保持している、とても興味深いRubyの機能です。ここで深掘りはしませんが、アプリケーション内で定義された(ロード済みの)クラスは必ずObjectSpaceに存在する、ということだけ押さえておきましょう。ObjectSpace#each_objectにモジュールを渡すと、そのモジュールと一致するか、そのサブクラスであるオブジェクトのみが返されます。ブロック内では最上位のクラス自身を除外しています(たとえばNumeric.descendantsを呼んだとき、結果にNumeric自身が含まれるのは期待外れですよね)。

このコードの詳細がすぐにピンと来なくても心配いりません。本当に理解するにはObjectSpaceについての追加の学習が必要になるでしょう。今回の目的にとって重要なのは、このメソッドがClassに定義されていて、子孫クラスのリストを返すということです。言い換えれば、そのクラスの「家系図」——子、孫、ひ孫——を取得できると考えてください。

#descendantsの実践的な使い方

2018年のRailsConfで、Ryan Laughlin氏が「checkups(点検)」に関する講演を行いました。動画は一見の価値がありますが、ここではその中のひとつのアイデアだけ取り上げます。それは、データベース内の全レコードを定期的に走査し、モデルのバリデーションを通過するかどうかをチェックするというものです。実は、#valid?を通過できないレコードがデータベースにどれほど残っているか、その多さに驚かされるかもしれません。

では、モデルのリストを手動で管理することなく、このチェックをどう実装すればよいのでしょうか。答えが#descendantsです。

# すべてのモデルをロードしておく(productionでは通常不要)
Rails.application.load! if Rails.env.development?

ApplicationRecord.descendants.each do |model_class|
  # 実運用ではバックグラウンドジョブに投げるとよい
  model_class.all.each do |record|
    if !record.valid?
      HoneyBadger.notify("Invalid #{model.name} found with ID: #{record.id}")
    end
  end
end

ここでApplicationRecord.descendantsは、標準的なRailsアプリケーション内のすべてのモデルのリストを返します。ループ内のmodel_classは各モデルのクラス(UserProductなど)です。この実装はかなりシンプルですが、結果として、すべてのモデル(正確にはApplicationRecordのすべてのサブクラス)を走査し、全レコードに対して.valid?を呼び出すことができます。

まとめ

ほとんどのRails開発者にとって、モジュールは日常的に使うものではありません。それには正当な理由があります。自分が所有しているコードなら、もっと簡単にカスタマイズする方法があるのが普通ですし、所有していないコードであれば、モジュールによる挙動の変更にはリスクが伴います。それでもモジュールには明確なユースケースがあり、別ファイルからクラスを変更できるだけでなく、祖先チェーンのどこにモジュールを差し込むかまで選べるというのは、Rubyの柔軟性を物語っています。

そしてActiveSupportは、#ancestorsの逆の働きをする#descendantsを提供しています。筆者の経験上、このメソッドはあまり使われているのを見かけませんが、その存在を知ってしまえば、使い道はどんどん見つかるはずです。筆者自身、モデルのバリデーションチェックだけでなく、スペック内ですべてのモデルにattribute_aliasメソッドが正しく追加されているか検証する用途にも使ったことがあります。


  1. Rubyのエイリアス(別名定義)完全ガイド:aliasキーワードとalias_methodの違い

    Rubyでは、既存のメソッドに別名(エイリアス)を付ける方法が2つあります。 alias(キーワード) alias_method(メソッド) どちらも同じ目的で使えますが、挙動が微妙に異なるため、初心者にとって混乱しやすいトピックです。 本記事では、両者の違いを詳しく掘り下げ、しっかりと理解できるように解説していきます。 aliasキーワードとは まずはaliasから見ていきましょう。aliasはRubyのキーワードの一つです(ifやdef、classなどと同じ扱いです)。 基本的な書き方は以下の通りです。 alias print_something puts print_someth

  2. Rubyのfreezeメソッド完全解説 – オブジェクトの可変性と不変性を理解しよう

    オブジェクトが「変更可能(ミュータブル)」であるとは、どういう意味なのでしょうか? 難しい言葉に構える必要はありません。「可変性(ミュータビリティ)」とは、単純に「オブジェクトの内部状態を後から変更できる」という意味です。これはすべてのオブジェクトのデフォルトの挙動であり、freeze(凍結)されたオブジェクトや、言語側で特別扱いされている一部のオブジェクトだけが例外となります。 つまり、Rubyのすべてのオブジェクトが変更可能というわけではないのです。 なぜ数値やシンボルは変更できないのか? たとえば、整数・シンボル、さらにはtrueやfalse(これらもすべてオブジェクトです)が変化するの