0
0

methodとsource_locaitonでメソッド定義の場所を探し、Railsのコードリーディングを効率よく進める

Last updated at Posted at 2024-08-14

はじめに

Railsのコードリーディングを少しだけうまく進められるようになってきたため、やり方を備忘録としてまとめます。

対象読者は、Railsのコードリーディングをしていきたいけど、そもそもメソッドがどこにあるかすら見つけられない、といった状況のエンジニアです。

この記事を最後まで読めば、気になるメソッドの定義場所を1人で探せるようになるかなと思います。

※バージョンはRuby 3.3, Rails 7.2です。

問題

普段使っているフレームワークをより深く学びたいと思って、Railsのコードリーディングを始めています。

あまりにも規模が大きくどこから読んでいいか分からないため、まずはよく使うメソッドを1つずつ見ていこうと考えました。

しかし、そもそもメソッドがどこに定義されているかを発見するのにも一苦労です。

仮に特定できたとしても、その中でさらに他のメソッドが呼ばれており、追いかけていくことができなくなってしまいました。

解決策

methodメソッドとsource_locationメソッドを活用することで、Railsのコードリーディングが徐々に進められるようになりました。

methodメソッドとは?

methodメソッドとは、RubyのObjectクラスに定義されているメソッドです。

指定したメソッド名に対応するメソッドオブジェクトを返してくれます。

具体的には以下の機能を持っています。

  • 引数で指定したメソッドをMethodクラスのインスタンスとしてオブジェクト化する
  • 返されたMethodオブジェクトは元のメソッドと同じ挙動をとる
  • メソッドの詳細な情報(定義場所、引数など)へアクセスできるようになる

サンプルコード

class Sample
  def hello(name)
    "Hello, #{name}"
  end
end

obj = Sample.new

# hello メソッドを Method オブジェクトとして取得
method_obj = obj.method(:hello)

# Method オブジェクトと call メソッドを使って hello メソッドを呼び出す
p method_obj.call("Rails")  #=> "Hello, Rails"

# Method オブジェクトの情報を取得
p method_obj.name             #=> :hello
p method_obj.owner            #=> Sample
p method_obj.receiver         #=> #<Sample:0x00000001048c7a08>
p method_obj.parameters       #=> [[:req, :name]]

source_locationメソッドとは?

source_locationメソッドとは、RubyのMethodクラスに定義されているメソッドです。

対象のメソッドが定義されているファイルのパスと行番号を配列で返してくれます。

具体的な機能は以下のとおりです。

  • メソッドが定義されているソースファイルの絶対パスを返す
  • そのファイル内でメソッドが定義されている行数も返す
  • Rubyの組み込みメソッドの場合はnilを返す

サンプルコード

sample.rb
# 自作メソッドの場合
class OriginalClass
  def original_method
    "This is original method."
  end
end

obj = OriginalClass.new
method_obj = obj.method(:original_method)

p method_obj.source_location
#=> ["sample.rb", 3]

# 組み込みメソッドの場合
p "hello".method(:upcase).source_location
#=> nil

# gemのメソッドの場合
require 'json'
p JSON.method(:parse).source_location
#=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/json-2.7.2/lib/json/common.rb", 219]

methodsource_locationの組み合わせ

上記のサンプルコードで注目してもらいたいのが次の箇所です。

# gemのメソッドの場合
require 'json'
p JSON.method(:parse).source_location
#=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/json-2.7.2/lib/json/common.rb", 219]

これはJSONクラスのparseメソッドが/lib/json/common.rb"の219行目に定義されていることを示しています。

このように、methodメソッドとsource_locationメソッドを組み合わせることで、探しているメソッドがgemのソースファイルのどこに定義されているか特定できるのです。

methodsource_locationを使ってRailsのコードリーディングを進める

今回はActive Recordのcount, size, lengthの3メソッドを題材にしていきます。

最初にposts = Post.allでActive Recordのオブジェクトを取得しておきます。

rails72-app(dev)> posts = Post.all
  Post Load (9.4ms)  SELECT "posts".* FROM "posts" /* loading for pp */ LIMIT ?  [["LIMIT", 11]]
=> 
[#<Post:0x0000000125d3e570 id: 1, content: "Updated Content", created_at: "2024-07-21 08:27:20.341870000 +0000", updated_at: "2024-07-21 08:27:20.381694000 +0000">,
...

countメソッドの定義場所を確認

まずはcountメソッドの定義場所を確認します。

rails72-app(dev)> posts.method(:count).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation/calculations.rb", 94]

/activerecord-7.2.0/lib/active_record/relation/calculations.rbの94行目に書かれているようです。

実際に見に行ってみると、たしかにcountメソッドが定義されていました。

activerecord/lib/active_record/relation/calculations.rb
# Count the records.
#
#   Person.count
#   # => the total count of all people
#
#   Person.count(:age)
#   # => returns the total count of all people whose age is present in database
#
#   Person.count(:all)
#   # => performs a COUNT(*) (:all is an alias for '*')
#
#   Person.distinct.count(:age)
#   # => counts the number of different age values
#
# If #count is used with {Relation#group}[rdoc-ref:QueryMethods#group],
# it returns a Hash whose keys represent the aggregated column,
# and the values are the respective amounts:
#
#   Person.group(:city).count
#   # => { 'Rome' => 5, 'Paris' => 3 }
#
# If #count is used with {Relation#group}[rdoc-ref:QueryMethods#group] for multiple columns, it returns a Hash whose
# keys are an array containing the individual values of each column and the value
# of each key would be the #count.
#
#   Article.group(:status, :category).count
#   # =>  {["draft", "business"]=>10, ["draft", "technology"]=>4, ["published", "technology"]=>2}
#
# If #count is used with {Relation#select}[rdoc-ref:QueryMethods#select], it will count the selected columns:
#
#   Person.select(:age).count
#   # => counts the number of different age values
#
# Note: not all valid {Relation#select}[rdoc-ref:QueryMethods#select] expressions are valid #count expressions. The specifics differ
# between databases. In invalid cases, an error from the database is thrown.
#
# When given a block, loads all records in the relation, if the relation
# hasn't been loaded yet. Calls the block with each record in the relation.
# Returns the number of records for which the block returns a truthy value.
#
#   Person.count { |person| person.age > 21 }
#   # => counts the number of people older that 21
#
# Note: If there are a lot of records in the relation, loading all records
# could result in performance issues.
def count(column_name = nil)
  if block_given?
    unless column_name.nil?
      raise ArgumentError, "Column name argument is not supported when a block is passed."
    end

    super()
  else
    calculate(:count, column_name)
  end
end

このコードを見ると、最後にcalculate(:count, column_name)が実行されているため、calculateの定義を追う必要があります。

rails72-app(dev)> posts.method(:calculate).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation/calculations.rb", 217]

同じファイルの217行目に定義されていました。

activerecord/lib/active_record/relation/calculations.rb
def calculate(operation, column_name)
  operation = operation.to_s.downcase

  if @none
    case operation
    when "count", "sum"
      result = group_values.any? ? Hash.new : 0
      return @async ? Promise::Complete.new(result) : result
    when "average", "minimum", "maximum"
      result = group_values.any? ? Hash.new : nil
      return @async ? Promise::Complete.new(result) : result
    end
  end

  if has_include?(column_name)
    relation = apply_join_dependency

    if operation == "count"
      unless distinct_value || distinct_select?(column_name || select_for_count)
        relation.distinct!
        relation.select_values = Array(model.primary_key || table[Arel.star])
      end
      # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT
      relation.order_values = [] if group_values.empty?
    end

    relation.calculate(operation, column_name)
  else
    perform_calculation(operation, column_name)
  end
end

一番最後の行のperform_calculation(operation, column_name)も同様に、位置を特定できます。

rails72-app(dev)> posts.method(:perform_calculation).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation/calculations.rb", 418]

このようにmethodsource_locationを使うことで、メソッドの定義場所を追いかけていくことが可能です。

sizeメソッドの定義場所を確認

次にsizeメソッドを確認します。

rails72-app(dev)> posts.method(:size).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation.rb", 346]

/activerecord-7.2.0/lib/active_record/relation.rbの346行目に書かれているようです。

実際に見にいってみると、たしかにsizeメソッドが定義されていました。

activerecord/lib/active_record/relation.rb
# Returns size of the records.
def size
  if loaded?
    records.length
  else
    count(:all)
  end
end

loaded?の定義を追いかけることももちろん可能です。

rails72-app(dev)> posts.method(:loaded?).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation.rb", 71]

また、if loaded?の条件分岐の結果を知りたい場合は、次のようにsendメソッドを活用できます。

rails72-app(dev)> posts.send(:loaded?)
=> true

sendメソッドについては下記の記事にまとめています。

lengthメソッドの定義場所を確認

最後にlengthメソッドです。

rails72-app(dev)> posts.method(:length).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation/delegation.rb", 98]

/activerecord-7.2.0/lib/activerecord-7.2.0/lib/active_record/relation/delegation.rbの98行目に書かれているようです。

実際に見にいきます。

activerecord/lib/active_record/relation/delegation.rb
# This module creates compiled delegation methods dynamically at runtime, which makes
# subsequent calls to that method faster by avoiding method_missing. The delegations
# may vary depending on the model of a relation, so we create a subclass of Relation
# for each different model, and the delegations are compiled into that subclass only.

delegate :to_xml, :encode_with, :length, :each, :join, :intersect?,
         :[], :&, :|, :+, :-, :sample, :reverse, :rotate, :compact, :in_groups, :in_groups_of,
         :to_sentence, :to_fs, :to_formatted_s, :as_json,
         :shuffle, :split, :slice, :index, :rindex, to: :records

これまで見てきた2つとは異なり、delegateを用いて委譲されています。

to: :recordsとなっているため、実質的にはrecords.lengthと定義されている状態です。

そこでrecordsメソッドを探索します。

rails72-app(dev)> posts.method(:records).source_location
=> ["/opt/homebrew/lib/ruby/gems/3.3.0/gems/activerecord-7.2.0/lib/active_record/relation.rb", 335]

/activerecord-7.2.0/lib/activerecord-7.2.0/lib/active_record/relation.rbの335行目を見にいきます。

activerecord/lib/active_record/relation.rb
def records # :nodoc:
  load
  @records
end

この箇所で@recordsオブジェクトを返しています。

この@recordsオブジェクトはArrayクラスの配列のようです。

rails72-app(dev)> posts.instance_variable_get(:@records).class
=> Array

つまり@records.lengthlengthは、最終的にはRubyのArrayクラスのlengthメソッドを実行しているのですね。

おわりに

この記事ではmethodメソッドとsource_locationメソッドを用いてRailsのメソッド定義の場所を探す方法をまとめました。

これを知ってから少しずつRailsのコードリーディングが進められるようになりました。

やったことがない方はぜひ取り組んでみてください。

また、この記事で題材としたcount, size, lengthの違いについては以下の記事にまとめているため、こちらも読んでみてください。

最後に、記事の内容に誤りがありましたらご指摘いただけますと幸いです。

参考資料

0
0
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
0
0