こんにちは、hachi8833です。ActiveSupport::Duration探訪の第1回と第2回ではDurationの主なメソッド紹介、第3回では5.1での変更点チェックでした。
せっかくなので引き続きRails 5.1 + Ruby 2.5devでDurationの残りのコードを追ってみることにしました。ISO8601Parser関連やmethod_missing
まで追うと長くなりそうなのでここでは扱いませんでした。
今回の内容
条件
active_support/duration.rb
masterのactive_support/duration.rbは今もコミットが繰り返されていますので、今後記事の内容と同じでなくなることがあります。
since
とago
上述のとおり、Rails 5.1用の現時点のmasterのコードです。
...
def since(time = ::Time.current)
sum(1, time)
end
alias :from_now :since
alias :after :since
def ago(time = ::Time.current)
sum(-1, time)
end
alias :until :ago
alias :before :ago
...
デフォルト値は::Time.current
です。よくよく見るとafter
とbefore
というエイリアスがつい6日前に追加されています。
コミットメッセージによると以下のようにテストコードを書きたいということです。
With this change, we can instead write
let(:today) { 2.weeks.after(customer_start_date) }
let(:earlier_date) { 5.days.before(today) }
「agoもbeforeもおんなじようなものだろう」と思う方もいるかもしれませんが、英語的にはagoとbeforeは同じではありません。「#ago
や#until
だと英語的にキモチワルイときがあるんだYO!」という需要に応えたものだと思います。
#sum
はduration.rbにprivateとして実装されています。
...
private
def sum(sign, time = ::Time.current)
parts.inject(time) do |t, (type, number)|
if t.acts_like?(:time) || t.acts_like?(:date)
if type == :seconds
t.since(sign * number)
elsif type == :minutes
t.since(sign * number * 60)
elsif type == :hours
t.since(sign * number * 3600)
else
t.advance(type => sign * number)
end
else
raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
end
end
end
...
parts.inject(time) do |t, (type, number)|
では、#inject
のブロック引数で(type, number)
と書いています。当初この( )
は読みやすさのためのものかなと思ったところ、Webチームのkazzさんがさっと検証コードを書いて( )
が必要であることを示してくれました。
# ( )がない場合
[[:a, 1], [:b, 2], [:c, 3]].inject(0) do |r, first, last|
r += last
end
# ==> TypeError: nil can't be coerced into Fixnum
# ( )がある場合
[[:a, 1], [:b, 2], [:c, 3]].inject(0) do |r, (first, last)|
r += last
end
# ==> 6
ブロック引数のt
は時間、(type, number)
はparts
の要素です。
#acts_like?
でダックタイピングしてからの条件分岐では#since
を呼んでいます。呼び出し元も#since
であれば#since
と#sum
の間を往復して再帰的に動作することになります。最終的には#advance
が呼ばれますが、#sum
と#since
が結合しているのがちょっと気になりました。
これについてkazzさんからサジェスチョンをいただきました。
なるほど、#since
が実際に加算するメソッドなのだから#since
を呼ぶのが筋ということなんですね。
なお、#sum
がprivateなのでt.sum
のようには呼べないことも教えていただきました。
#+
と#-
同じくRails 5.1用のmasterです。
#+
では引数がDurationであるかどうかで処理を分け、最終的にDuration#new
で生成したオブジェクトを返しています。#-(others)
は符号だけ変えて#+
を呼ぶというRubyらしいDRYな書き方です。
...
def +(other)
if Duration === other
parts = @parts.dup
other.parts.each do |(key, value)|
parts[key] += value
end
Duration.new(value + other.value, parts)
else
seconds = @parts[:seconds] + other
Duration.new(value + other, @parts.merge(seconds: seconds))
end
end
def -(other)
self + (-other)
end
...
参考までに、現在の安定版である5.0.1では以下のようになっていました。
# https://github.com/rails/rails/blob/5-0-stable/activesupport/lib/active_support/duration.rb#L21 より
def +(other)
if Duration === other
Duration.new(value + other.value, @parts + other.parts)
else
Duration.new(value + other, @parts + [[:seconds, other]])
end
end
当初この改修の意図がよくわかりませんでしたが、これまたkazzさんから「parts
をArrayからHashに変えたんじゃね?」とポイントを示してもらいました。
確かに5.1 masterではother.parts.each do |(key, value)|
やparts[key] += value
のようにHashを使っています。parts
の要素は重複してほしくないものなのでHashの方が適切そうです。
単項の#-@
符号を反転する単項の#-@
を定義しています。レシーバの符号を反転してDuration#new
するだけのメソッドで、Rubyの符号反転系メソッドと同じメソッド名になっています。
def -@ #:nodoc:
Duration.new(-value, parts.map { |type, number| [type, -number] })
end
定義に使われているアットマーク@
は特殊な用途の記号で、Rubyで(引数を取らない)単項の#+
や#-
を再定義するときに必要です。再定義した単項の#+
や#-
の呼び出しにはアットマークをつけません。
# https://docs.ruby-lang.org/ja/2.4.0/doc/symref.html#at より
class Symbol
def +@
self.upcase
end
end
puts(+:joke) #=> JOKE ('@'をつけない)
今回知りましたが、再定義でアットマーク@
が必要なのは単項の#+
や#-
の場合のみです。その他の場合は、引数を取る二項の#+
や#-
の再定義も含めアットマークは不要です。
# 「Ruby 2.4.0リファレンスマニュアル: 演算子式の定義」より
# 二項演算子
def +(other) # obj + other
def -(other) # obj - other
# 単項プラス/マイナス
def +@ # +obj
def -@ # -obj
#==
と#eql
と#===
同じくmasterのコードです。引数がDurationであるかどうかに応じてtrue
/false
を返すシンプルなメソッドです。
def ==(other)
if Duration === other
other.value == value
else
other == value
end
end
def eql?(other)
Duration === other && other.value.eql?(value)
end
以下の#===
はDurationのクラスメソッドとして定義されています。これは安定版5.0.1にはありませんでした。NoMethodErrorの場合でもfalse
を返すよう変更されています。
class Duration
class << self
def ===(other) #:nodoc:
other.is_a?(Duration)
rescue ::NoMethodError
false
end
end
end
#inspect
Durationの内容を文字列で取り出すシンプルなメソッドです。@value
ではなく@parts
を使っています。むしろこの#inspect
のために@parts
も内部値としてメンテしているという方が近いのかもしれません。
メソッドはと言うと、#map
と#sort_by
と#reduce
を一気にメソッドチェーンし、とどめにArray#to_sentence
でStringに変換して終了です。
def inspect #:nodoc:
parts.
reduce(::Hash.new(0)) { |h, (l, r)| h[l] += r; h }.
sort_by { |unit, _ | [:years, :months, :weeks, :days, :hours, :minutes, :seconds].index(unit) }.
map { |unit, val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}" }.
to_sentence(locale: ::I18n.default_locale)
end
locale:
は::I18n.default_locale
に固定されているんですね。Array#to_sentence
もActiveSupport Core Extensionの仲間で、active_support/core_ext/array/conversions.rbにあります。
Durationのその他の主なメソッド
残った短いメソッドをまとめてみました。これはもう見てのとおりですね。
def is_a?(klass) #:nodoc:
Duration == klass || value.is_a?(klass)
end
alias :kind_of? :is_a?
def instance_of?(klass) # :nodoc:
Duration == klass || value.instance_of?(klass)
end
def to_s
@value.to_s
end
def to_i
@value.to_i
end
def hash
@value.hash
end
def as_json(options = nil) #:nodoc:
to_i
end
Durationに長々とお邪魔しましたが、次回はCore Extensionsの別のコードにお邪魔しようと思います。ご期待ください。