0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-26

今回の目標

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

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

準備

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

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クラスを見ていると、timeというメソッドを経由してメソッドを実行している箇所があります。

def tomorrow?
  time.tomorrow?
end

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

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

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

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メソッドを見てみました。

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

@timelocal_timeを代入しています。
instance_variable_getを使って、time_with_zoneが@timeを持っているか確認してみましょう。

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

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

# 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が省略されているため、以下のように変化させると読みやすくなります。

# 元のコード
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オブジェクトとなります。

詳しくは分からないけどそういうもんという理解
  1. time.__send__(:middle_of_day)time.middle_of_dayと同じ結果を返すため、wrap_with_time_zoneの引数にはTimeオブジェクトが渡される
  2. if time.acts_like?(:time)はtrueを返すため、最終的にself.class.new(nil, time_zone, time, periods.include?(period) ? period : nil)が実行される
  3. @time.middle_of_dayの実行結果を新しいActiveSupport::TimeWithZoneオブジェクトとして作成し、その結果を返す
# 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::TimeWithZoneObject#method_missingをオーバーライドしていることも発見することができました。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?