Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

[Rails5] ActiveSupport::Durationの期間処理メソッド(4)5.1のその他のメソッド

こんにちは、hachi8833です。ActiveSupport::Duration探訪の第1回第2回ではDurationの主なメソッド紹介、第3回では5.1での変更点チェックでした。

せっかくなので引き続きRails 5.1 + Ruby 2.5devでDurationの残りのコードを追ってみることにしました。ISO8601Parser関連やmethod_missingまで追うと長くなりそうなのでここでは扱いませんでした。

今回の内容

条件

  • Railsバージョン: 現時点のmaster(Rails 5.1になる予定)
  • Rubyバージョン: 1月20日の2.5.0dev(2017-01-20 trunk 57378相当)

active_support/duration.rb

masterのactive_support/duration.rbは今もコミットが繰り返されていますので、今後記事の内容と同じでなくなることがあります。

sinceago

上述のとおり、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です。よくよく見るとafterbeforeというエイリアスがつい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の別のコードにお邪魔しようと思います。ご期待ください。

関連記事

[Rails5] Railsの主要なライブラリ構成

Rails: ビューのHTMLエスケープは#link_toなどのヘルパーメソッドで解除されることがある


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。