Railsのautoload_pathsでの定数探索

環境

autoload_pathsの概要

例:

class Hoge < SuperHoge; end

のようにモデルを定義したとします。Hogeは未定義の場合定数作成され、定義済みであればオープンクラスされるためautoload_pathsの出番なしですが、SuperHogeのほうが未定義だった場合autoload_pathsを元に定数を探索しに行きます。 autoload_pathsがあるので

require 'super_hoge'

といちいち書かなくてもよくなっています。

puts ActiveSupport::Dependencies.autoload_paths
RAILS_ROOT/app/assets
RAILS_ROOT/app/channels
RAILS_ROOT/app/controllers
RAILS_ROOT/app/controllers/concerns
RAILS_ROOT/app/helpers
RAILS_ROOT/app/jobs
RAILS_ROOT/app/mailers
RAILS_ROOT/app/models
RAILS_ROOT/app/models/concerns
...etc

みたいにActiveSupport::Dependencies.autoload_pathsの結果が探索の対象になります。 詳しくはwebで(https://railsguides.jp/autoloading_and_reloading_constants.html)

実際のソースコードを見てみる

rubyにはさまざまなフックメソッドが提供されていてその一つに定数が見つからないときにModule#const_missingというものがあります。これをActiveSupport::Dependencies::ModuleConstMissingではオーバーライドしています。このmoduleはrubyModuleincludeされているのでRailsを使う際に定数が見つからないとこのメソッドのconst_missingが呼ばれることになります。なのでこれを見ていくことにします。

https://github.com/rails/rails/blob/5-2-0/activesupport/lib/active_support/dependencies.rb#L191

def const_missing(const_name)
  from_mod = anonymous? ? guess_for_anonymous(const_name) : self
  Dependencies.load_missing_constant(from_mod, const_name)
end

from_modはどの名前空間に属するかを特定しています。そして定数探索の旅が始まる!

ActiveSupport::Dependencies#load_missing_constant

https://github.com/rails/rails/blob/master/activesupport/lib/active_support/dependencies.rb#L489

2018/05/23時点のソースをのせる。

    def load_missing_constant(from_mod, const_name)
      unless qualified_const_defined?(from_mod.name) && Inflector.constantize(from_mod.name).equal?(from_mod)
        raise ArgumentError, "A copy of #{from_mod} has been removed from the module tree but is still active!"
      end

      qualified_name = qualified_name_for from_mod, const_name
      path_suffix = qualified_name.underscore

      file_path = search_for_file(path_suffix)

      if file_path
        expanded = File.expand_path(file_path)
        expanded.sub!(/\.rb\z/, "".freeze)

        if loading.include?(expanded)
          raise "Circular dependency detected while autoloading constant #{qualified_name}"
        else
          require_or_load(expanded, qualified_name)
          raise LoadError, "Unable to autoload constant #{qualified_name}, expected #{file_path} to define it" unless from_mod.const_defined?(const_name, false)
          return from_mod.const_get(const_name)
        end
      elsif mod = autoload_module!(from_mod, const_name, qualified_name, path_suffix)
        return mod
      elsif (parent = from_mod.parent) && parent != from_mod &&
            ! from_mod.parents.any? { |p| p.const_defined?(const_name, false) }
        begin
          return parent.const_missing(const_name)
        rescue NameError => e
          raise unless e.missing_name? qualified_name_for(parent, const_name)
        end
      end

上から適当に見ていくとqualified_name_forHoge::Fuga::Barのような文字列取得して、underscorehoge/fuga/barのような文字列を生成しています。それをsearch_for_file(あとでみる)でファイルを探しています。 その絶対パスを取得して、require_or_loadを呼びだして定数をrequireかロードし、その後そのmoduleがreturnされます。

もしsearch_for_fileの返り値がなければautoload_moduleを呼び出す。 autoload_moduleは --> 予想されるパスサフィックスに一致するディレクトリを検索して、提供されたモジュール名を自動ロードしようとします。 見つかった場合、モジュールは作成され、+ const_name +という名前の定数に+ from_mod +の定数に代入されます。 ディレクトリが再ロード可能なベースパスからロードされていれば、アンロードされる定数セットに追加されます。

らしいです。何かしらが代入されて場合、そのmoduleを返します。あとは例外処理なので省略。

search_for_fileを読む

https://github.com/rails/rails/blob/5-2-0/activesupport/lib/active_support/dependencies.rb#L414

def search_for_file(path_suffix)
  path_suffix = path_suffix.sub(/(\.rb)?$/, ".rb".freeze)
  autoload_paths.each do |root|
    path = File.join(root, path_suffix)
    return path if File.file? path
  end
  nil
end

やっと出現しましたautoload_paths。 実装は単純で、autoload_pathsをぶん回してpath_suffix(探索対象のモジュールの名前空間に対応するファイルパス)と各要素を結合します。それがファイルならばその文字列を返します。

まとめ

支離滅裂に書いてきたが、ActiveSupport::Dependencies::ModuleConstMissing#const_missingでは未定義の定数をautoload_pathsをもとに読み込んでいます。