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

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

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

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

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

## 問題

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

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

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

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

## 解決策

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

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

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

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

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

#### サンプルコード

```ruby
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`を返す

#### サンプルコード

```ruby: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]
```

### `method`と`source_location`の組み合わせ

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

```ruby
# 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行目に定義されていることを示しています。

https://github.com/flori/json/blob/v2.7.2/lib/json/common.rb#L219

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

## `method`と`source_location`を使ってRailsのコードリーディングを進める

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

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

```ruby
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`メソッドの定義場所を確認します。

```ruby
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`メソッドが定義されていました。

https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation/calculations.rb#L94

```ruby: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`の定義を追う必要があります。

```ruby
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行目に定義されていました。

```ruby: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)`も同様に、位置を特定できます。

```ruby
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]
```

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

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

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

```ruby
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`メソッドが定義されていました。

https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation.rb#L346

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

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

```ruby
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`メソッドを活用できます。

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

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

https://qiita.com/Kazuyaa/items/d9f956031c79336692d2

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

最後に`length`メソッドです。

```ruby
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行目に書かれているようです。

実際に見にいきます。

```ruby: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`メソッドを探索します。

```ruby
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行目を見にいきます。

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

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

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

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

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

## おわりに

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

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

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

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

https://qiita.com/Kazuyaa/items/2dd40eef05b752fac66d

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

## 参考資料
https://docs.ruby-lang.org/ja/latest/method/Object/i/method.html

https://docs.ruby-lang.org/ja/latest/method/Method/i/source_location.html

https://www.amazon.co.jp/dp/4297124378

https://www.amazon.co.jp/dp/4873117437

https://techracho.bpsinc.jp/hachi8833/2024_01_15/137575