こんにちは、hachi8833です。ActiveSupport::Duration探訪の前回と前々回でDurationの主なメソッドをご紹介しましたが、今回はRailsの#27610で行われたDurationの変更点を追ってみました。
期間の概念に迫るのは手強そうなのでコードの改修を中心に据えましたが、Rubyのクラスメソッドのよい使い方を見つけることができました。
今回の内容
今回はRails 5.0-stableより新しいバージョンのコードを中心に見ます。前回と前々回ではRails 5.0-stableとRuby 2.4.0をベースにしていましたのでご注意ください。
条件
上のmasterは、#27610以降の変更も含みます。
比較対象
- Railsバージョン: 5-0-stable
- Rubyバージョン: 2.4.0
active_support/duration.rb
duration.rbのソースコードはそこそこ長いので、完全なソースについてはmasterのactive_support/duration.rbをご覧ください。
モジュールの冒頭部分
#27610以降のmasterでは、Durationが秒をベースとして書き直され、以下のように多くの定数が定義されました。大きく変わった部分です。
require "active_support/core_ext/array/conversions"
require "active_support/core_ext/object/acts_like"
module ActiveSupport
class Duration
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
SECONDS_PER_DAY = 86400
SECONDS_PER_WEEK = 604800
SECONDS_PER_MONTH = 2629746 # グレゴリオ年の1/12
SECONDS_PER_YEAR = 31556952 # グレゴリオ年 (365.2425日)
PARTS_IN_SECONDS = {
seconds: 1, # parseに備えてハッシュの各値を秒に変換する
minutes: SECONDS_PER_MINUTE,
hours: SECONDS_PER_HOUR,
days: SECONDS_PER_DAY,
weeks: SECONDS_PER_WEEK,
months: SECONDS_PER_MONTH, # 「1年」とは常に「12か月」であり、日数で表すものではない
years: SECONDS_PER_YEAR # 400年に1度の「非うるう年」を扱えるグレゴリオ暦ベースの年の長さ
}.freeze
...
Rails 5.0.1ではEPOCH
という定数が定義されていたのが置き換えられています。
続きを見てみましょう。
module ActiveSupport
...
class Duration
attr_accessor :value, :parts
autoload :ISO8601Parser, "active_support/duration/iso8601_parser"
autoload :ISO8601Serializer, "active_support/duration/iso8601_serializer"
...
この部分には変更はないようです。
なお、ISO 8601周りの機能はRails 5で追加されました。#autoload
することで、たまにしか使われないライブラリを常駐させる無駄を避け、必要な場合にのみ読み込むようになっています。DurationのメソッドはISO8601と無関係なものがほとんどですし、ISO8601の利用頻度は低いはずです。
valueとparts
Duration#new
するときのパラメータはvalue
とparts
です。コードにはこれらの説明はありません。value
はともかくparts
が何かを知りたかったので、#27610と直接関連しませんが、この機会に動かして確認しました。幸い#parts
というアクセサがひっそりと使えます。
du = 2.month + 5.days + 7.hours + 4.minutes + 9.seconds
du.class #=> ActiveSupport::Duration
du.value #=> 5716941
du.parts #=> [[:months, 2], [:days, 5], [:hours, 7], [:minutes, 4], [:seconds, 9]]
(参考:Rails 5.0.1とRuby 2.4.0ではvalueが異なっています)
parts
はDurationの要素を配列として保存していることがわかりました。
説明がないことから、Durationを直接new
したりvalue
やparts
に直接アクセスすることは意図されていないと考えられます。1.month
といった方法でインスタンスを生成し、メソッド経由で正規にアクセスすべきでしょう。
Durationのインスタンス生成
ActiveSupportのDurationは2.months
や10.seconds
といった形で利用するので、ActiveSupportのIntegerクラスかその子孫クラスで#new
していると推測できます。探してみると、core_ext/integer/time.rbとcore_ext/numeric/time.rbにそれぞれありました。
IntegerクラスとNumericクラスでDuration向けの拡張が行われています。Rails 5のmasterから以下に抜粋します。
class Integer
def months
ActiveSupport::Duration.months(self)
end
alias :month :months
def years
ActiveSupport::Duration.years(self)
end
alias :year :years
end
class Numeric
def seconds
ActiveSupport::Duration.seconds(self)
end
alias :second :seconds
def minutes
ActiveSupport::Duration.minutes(self)
end
alias :minute :minutes
def hours
ActiveSupport::Duration.hours(self)
end
alias :hour :hours
def days
ActiveSupport::Duration.days(self)
end
alias :day :days
def weeks
ActiveSupport::Duration.weeks(self)
end
alias :week :weeks
def fortnights
ActiveSupport::Duration.weeks(self * 2)
end
alias :fortnight :fortnights
def in_milliseconds
self * 1000
end
end
Numeric#in_milliseconds
のみその場で処理され、その他はActiveSupport::Durationのクラスメソッド(後述)を呼び出しています。
#seconds
や#days
など多くのメソッドがNumericで定義されているので、1.2.seconds
や5.4.days
といった小数点でも期間を表現できます。
対照的に、#months
と#years
はIntegerクラスで定義されているので、小数点表記はできないようになっています。月や年の長さで小数点含みの値が誤ってDurationとして使われないよう意図的にIntegerクラスで定義していると考えられます。#months
と#years
は#27610以前からこのように配置されていました。
なお、期間系のメソッドの中にNumeric#fortnights
というものを見つけました。fortnightはイギリス英語で「2週間」を表します。コードからも明らかですが、実行すると以下のようにweekで表現されます。
#27610以前のインスタンス生成との違い
core_ext/integer/time.rbとcore_ext/numeric/time.rbでのインスタンス生成方法は、#27610以降変更されています。
変更前
元々のインスタンス生成は、core_ext/integer/time.rbのIntegerクラスやcore_ext/numeric/time.rbのNumericクラスでActiveSupport::Duration#new
を行い、最終的にDurationのinitialize
メソッドで生成していました。例のparts
もnew
の時点で仕込まれています。
コードの中に30.days
という値が直接書かれており、ここでnew
するようになっています。30日という定数が生で書かれているのが何だか残念です。
## 変更前(5.0.1)
# core_ext/integer/time.rb側
def months
ActiveSupport::Duration.new(self * 30.days, [[:months, self]])
end
alias :month :months
# duration.rb側
def initialize(value, parts) #:nodoc:
@value, @parts = value, parts.to_h
@parts.default = 0
end
#27610での変更
このActiveSupport::Duration.new(self * 30.days, [[:months, self]])
は、#27610のcore_ext/integer/time.rbで以下のようにリテラルをPARTS_IN_SECONDS
といった上述の新しい定数に置き換えています。
## 変更後(#27610)
# core_ext/integer/time.rb側
def months
ActiveSupport::Duration.new(self * ActiveSupport::Duration::PARTS_IN_SECONDS[:months].to_i, [[:months, self]])
end
alias :month :months
# duration.rb側には関連する変更はなし
現在のmaster
現在のmasterでは、さらに以下のように変更されています。
## 変更後(master)
# core_ext/integer/time.rb側
def months
ActiveSupport::Duration.months(self)
end
alias :month :months
# duration.rb側
def months(value) #:nodoc:
new(value * SECONDS_PER_MONTH, [[:months, value]])
end
- IntegerやNumeric側で
new
するのをやめた - 代わりにDurationの中に
months
などをクラスメソッドとして実装し↓、IntegerやNumeric側はエイリアス対応とクラスメソッド呼び出しだけ行うようになった
class << self
def parse(iso8601duration)
parts = ISO8601Parser.new(iso8601duration).parse!
new(calculate_total_seconds(parts), parts)
end
def ===(other) #:nodoc:
other.is_a?(Duration)
rescue ::NoMethodError
false
end
def seconds(value) #:nodoc:
new(value, [[:seconds, value]])
end
def minutes(value) #:nodoc:
new(value * SECONDS_PER_MINUTE, [[:minutes, value]])
end
def hours(value) #:nodoc:
new(value * SECONDS_PER_HOUR, [[:hours, value]])
end
def days(value) #:nodoc:
new(value * SECONDS_PER_DAY, [[:days, value]])
end
def weeks(value) #:nodoc:
new(value * SECONDS_PER_WEEK, [[:weeks, value]])
end
def months(value) #:nodoc:
new(value * SECONDS_PER_MONTH, [[:months, value]])
end
def years(value) #:nodoc:
new(value * SECONDS_PER_YEAR, [[:years, value]])
end
private
def calculate_total_seconds(parts)
parts.inject(0) do |total, (part, value)|
total + value * PARTS_IN_SECONDS[part]
end
end
end
クラスメソッドを使ってインスタンス生成を簡潔にする
今回上のコードで知ったのは、class << self
を使ってメソッドをクラスメソッドにしたことにより、メソッド内で名前空間なしのnew
を書くだけで自分のクラスのDuration#initialize
を呼べるというテクニックです。
Rubyのクラスメソッドは、クラスオブジェクトに特異メソッドとして追加できます(他にも方法があります)。なお、class << self
という書き方を「特異クラス定義」と呼ぶそうです(『たのしいRuby』第5版、p133)。
クラスメソッドでないインスタンスメソッド内で単にnew
を実行すると、以下のようにエラーになります。morimorihogeさんが、new
自体がクラスの特異メソッドであると教えてくれました。ありがとうございます!
class MyTest
def initialize
@var1 = 0
end
def init
new # このままでは動かない: self.class.newかMyTest.newなら動く
end
end
MyTest.new.init #=> NameError: undefined local variable or method `new' for #<MyTest:0x007fe51eb3c6c0 @var1=0>
init
はインスタンスメソッドなのでnew
でインスタンスを作らないと呼べません。new
するためにnew
していては本末転倒です。
init
をクラスメソッド化することでnew
だけで自分のインスタンスを生成できるようになり、init
呼び出しもnew
なしで行えます。
class MyTest
def initialize
@var1 = 0
end
class << self # クラスメソッドを定義する
def init
new # 動く(self.new と同等)
end
end
end
aa = MyTest.init #=> #<MyTest:0x007fc3b29e65b0 @var1=0>
この手法は、クラスをさまざまな初期値と半固定設定でnew
したい場合に便利そうです。
まとめ
これらの改修でも最終的にDurationがnew
される点は変わりませんが、IntegerやNumericsの長ったらしいインスタンス生成が最終的に以下のように簡潔なクラスメソッド呼び出しに変更されました。
- 変更前:
ActiveSupport::Duration.new(self * 30.days, [[:months, self]])
- 変更後:
ActiveSupport::Duration.new(self * ActiveSupport::Duration::PARTS_IN_SECONDS[:months].to_i, [[:months, self]])
- 再変更:
ActiveSupport::Duration.months(self)
この改修にはクラスメソッド(クラスの特異メソッド)の便利な性質が応用されています。
また、これまでduration.rbの外にあったインスタンス生成の実装もクラスメソッドとしてduration.rbにまとめられたことで、クラスメソッド呼び出しはIntegerやNumericに残したまま、duration.rbの中身を少し追いやすくなったと思います。