gemを読む[2]: ActiveSupport::TimeWithZoneとTimeの委譲関係について



## 今回の目標

前回の記事では、`TimeWithZone`オブジェクトが`Time`オブジェクトへメソッド呼び出しを委譲している部分をうやむやにしました。

https://qiita.com/ysk91_engineer/items/a4f8b5cb35d16c791cfb

今回の記事ではここを明確にしたいと思います。

### 準備

本記事ではTimeWithZoneオブジェクトの代表として、`time_with_zone`という変数を用意しました。
`time_with_zone`は`TimeWithZone`クラスのオブジェクトです。

```ruby
pry(main)> time_with_zone = Date.current.in_time_zone
=> Tue, 25 Feb 2025 00:00:00.000000000 JST +09:00
pry(main)> time_with_zone.class
=> ActiveSupport::TimeWithZone
```

※ 執筆タイミングの関係で記事の途中で日付が変わることもありますが、気にしないでください。

## インスタンス変数による委譲

### `TimeWithZone#time`

[TimeWithZone](https://github.com/rails/rails/blob/7-2-stable/activesupport/lib/active_support/time_with_zone.rb)クラスを見ていると、`time`というメソッドを経由してメソッドを実行している箇所があります。

```ruby
def tomorrow?
  time.tomorrow?
end
```

timeの前には`self.`が省略されているので、TimeWithZoneオブジェクトに対してtimeメソッドを実行していることになります。
timeメソッドの実装箇所を見てみると、下記のように記載されていました。

```ruby
def time
  @time ||= incorporate_utc_offset(@utc, utc_offset)
end
```

インスタンス変数の`@time`か、`incorporate_utc_offset(@utc, utc_offset)`を返すメソッドのようです。
`time_with_zone`からtimeを呼び出すと、Timeクラスのオブジェクトが得られました。

```ruby
pry(main)> time_with_zone.time
=> 2025-02-25 00:00:00 UTC
pry(main)> time_with_zone.time.class
=> Time
```

### `TimeWithZone#initialize`

timeメソッドはインスタンスメソッドである`@time`を返すので、オブジェクトが作成されるタイミングで実行される`initialize`メソッドを見てみました。

```ruby
def initialize(utc_time, time_zone, local_time = nil, period = nil)
  @utc = utc_time ? transfer_time_values_to_utc_constructor(utc_time) : nil
  @time_zone, @time = time_zone, local_time
  @period = @utc ? period : get_period_and_ensure_valid_local_time(period)
end
```

`@time`に`local_time`を代入しています。
[instance_variable_get](https://docs.ruby-lang.org/ja/latest/method/Object/i/instance_variable_get.html)を使って、time_with_zoneが`@time`を持っているか確認してみましょう。

```ruby
pry(main)> time_with_zone.instance_variable_get(:@time)
=> 2025-02-25 00:00:00 UTC
pry(main)> time_with_zone.instance_variable_get(:@time).class
=> Time
```

TimeWithZoneオブジェクトはインスタンス変数`@time`を持ち、それはTimeオブジェクトであることが分かりました。

一旦の結論として、TimeWithZoneオブジェクトがTimeクラスで実装されているメソッドを使用できる理由はいかのようにまとめられます。

- TimeWithZoneオブジェクトが作成されるときに、`@time`というインスタンス変数が作られる
- `@time`はTimeオブジェクトであり、`TimeWithZone#time`で`@time`が呼び出される
- TimeWithZoneのインスタンスメソッドを定義するときに`time`を経由することでTimeクラスに定義されているインスタンスメソッドを使用する（Timeオブジェクトに委譲する）ことが可能


## メソッド探索による委譲

前回の記事でうやむやにした`middle_of_day`はTimeWithZoneクラスには実装されていません。
つまり上の説明では`middle_of_day`がTimeWithZoneオブジェクトに対して呼び出し可能である理由になっていません。

TimeWithZoneクラスに`middle_of_day`メソッドは実装されていないのに、TimeWithZoneオブジェクトに対して`middle_of_day`が実行可能なのはなぜなのでしょうか？

### 結論: `method_missing`がTimeWithZoneオブジェクト内部の`@time`にアクセスしている

色々調べたところ、TimeWithZoneオブジェクトに対して`middle_of_day`メソッドを実行すると以下の処理をするそうです。

1. `time_with_zone.middle_of_day`を呼び出すと、Rubyのメソッド探索プロセスにより、最初に `ActiveSupport::TimeWithZone`クラス自身で`middle_of_day`が定義されているかを探す
2. クラス内に`middle_of_day`メソッドが定義されていない場合、Railsはレシーバ内部の`@time`オブジェクトにメソッド呼び出しを委譲する（`@time`はTimeオブジェクト）
3. ActiveSupportにより`Time#middle_of_day`メソッドが拡張されているため、`@time.middle_of_day` の形で処理が実行される
4. 最終的に`Time#middle_of_day` が呼び出されて処理が実行され、適切な日時（正午）を計算する

なんと、メソッドが見つからない場合は内部でインスタンス変数である`@time`を呼び出し、それに対してメソッドを実行するようです。

これは先程の**インスタンス変数による委譲**を内部でおこなっていることになります。

### 実装箇所

では、なぜインスタンス変数`@time`を呼び出すことができるのでしょうか？

ここで、Rubyがオブジェクトに対してメソッド呼び出しをしたときの挙動を復習します。

1. オブジェクトのクラス内でメソッドがあったらその実行結果を返す
1. クラスにメソッドが無い場合、クラスが継承しているスーパークラスを探索し、メソッドがある場合は実行結果を返す
1. メソッドが無い場合、さらにスーパークラスを探索し、最終的にObjectクラスまで遡る
1. Objectクラスにもメソッドが無い場合、`method_missing`の結果を返す

通常TimeWithZoneオブジェクトに`middle_of_day`メソッドを実行すると`method_missing`となります。
そして、TimeWithZoneは以下の実装により`method_missing`をオーバーライドしています。

```ruby
# Send the missing method to +time+ instance, and wrap result in a new
# TimeWithZone with the existing +time_zone+.
def method_missing(...)
  wrap_with_time_zone time.__send__(...)
rescue NoMethodError => e
  raise e, e.message.sub(time.inspect, inspect).sub("Time", "ActiveSupport::TimeWithZone"), e.backtrace
end

private

  def wrap_with_time_zone(time)
    if time.acts_like?(:time)
      periods = time_zone.periods_for_local(time)
      self.class.new(nil, time_zone, time, periods.include?(period) ? period : nil)
    elsif time.is_a?(Range)
      wrap_with_time_zone(time.begin)..wrap_with_time_zone(time.end)
    else
      time
    end
  end
```

`method_missing`の引数にはメソッドのシンボルが渡されます。
そして、ここでもインスタンス自身であるselfが省略されているため、以下のように変化させると読みやすくなります。

```ruby
# 元のコード
time_with_zone(time.__send__(:middle_of_day))
# ↓ インスタンス自身であるselfを明記
time_with_zone(self.time.__send__(:middle_of_day))
# ↓ self.timeはインスタンス変数である@timeを返す
time_with_zone(@time.__send__(:middle_of_day))
```

そして、`self.class.new(nil, time_zone, time, periods.include?(period) ? period : nil)`の結果はレシーバのクラスである`TimeWithZone`オブジェクトとなります。

<details><summary>詳しくは分からないけどそういうもんという理解</summary>

1. `time.__send__(:middle_of_day)`は`time.middle_of_day`と同じ結果を返すため、`wrap_with_time_zone`の引数にはTimeオブジェクトが渡される
1. `if time.acts_like?(:time)`はtrueを返すため、最終的に`self.class.new(nil, time_zone, time, periods.include?(period) ? period : nil)`が実行される
1. `@time.middle_of_day`の実行結果を新しい`ActiveSupport::TimeWithZone`オブジェクトとして作成し、その結果を返す

</details>

```ruby
# TimeWithZoneオブジェクトがmethod_missingを通じてmiddle_of_dayを呼び出せる様子
pry(main)> time_with_zone.method_missing(:middle_of_day)
=> Wed, 26 Feb 2025 12:00:00.000000000 UTC +00:00
pry(main)> time_with_zone.method_missing(:middle_of_day) == time_with_zone.middle_of_day
=> true

# method_missing経由で得られたオブジェクトのクラスはActiveSupport::TimeWithZoneになる
pry(main)> time_with_zone.middle_of_day.class
=> ActiveSupport::TimeWithZone

# Timeオブジェクトからmiddle_of_dayを呼び出した結果はTimeオブジェクトである
pry(main)> Time.new.middle_of_day.class
=> Time
```

## まとめ

今回は、TimeWithZoneオブジェクトがTimeオブジェクトと同様の挙動をする秘密を探りました。
その結果、委譲という手法について学ぶことができました。
また、`ActiveSupport::TimeWithZone`が`Object#method_missing`をオーバーライドしていることも発見することができました。