今回の目標
前回の記事では、TimeWithZone
オブジェクトがTime
オブジェクトへメソッド呼び出しを委譲している部分をうやむやにしました。
今回の記事ではここを明確にしたいと思います。
準備
本記事ではTimeWithZoneオブジェクトの代表として、time_with_zone
という変数を用意しました。
time_with_zone
はTimeWithZone
クラスのオブジェクトです。
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
@time
にlocal_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
メソッドを実行すると以下の処理をするそうです。
-
time_with_zone.middle_of_day
を呼び出すと、Rubyのメソッド探索プロセスにより、最初にActiveSupport::TimeWithZone
クラス自身でmiddle_of_day
が定義されているかを探す - クラス内に
middle_of_day
メソッドが定義されていない場合、Railsはレシーバ内部の@time
オブジェクトにメソッド呼び出しを委譲する(@time
はTimeオブジェクト) - ActiveSupportにより
Time#middle_of_day
メソッドが拡張されているため、@time.middle_of_day
の形で処理が実行される - 最終的に
Time#middle_of_day
が呼び出されて処理が実行され、適切な日時(正午)を計算する
なんと、メソッドが見つからない場合は内部でインスタンス変数である@time
を呼び出し、それに対してメソッドを実行するようです。
これは先程のインスタンス変数による委譲を内部でおこなっていることになります。
実装箇所
では、なぜインスタンス変数@time
を呼び出すことができるのでしょうか?
ここで、Rubyがオブジェクトに対してメソッド呼び出しをしたときの挙動を復習します。
- オブジェクトのクラス内でメソッドがあったらその実行結果を返す
- クラスにメソッドが無い場合、クラスが継承しているスーパークラスを探索し、メソッドがある場合は実行結果を返す
- メソッドが無い場合、さらにスーパークラスを探索し、最終的にObjectクラスまで遡る
- 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
オブジェクトとなります。
詳しくは分からないけどそういうもんという理解
-
time.__send__(:middle_of_day)
はtime.middle_of_day
と同じ結果を返すため、wrap_with_time_zone
の引数にはTimeオブジェクトが渡される -
if time.acts_like?(:time)
はtrueを返すため、最終的にself.class.new(nil, time_zone, time, periods.include?(period) ? period : nil)
が実行される -
@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::TimeWithZone
がObject#method_missing
をオーバーライドしていることも発見することができました。