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

[Rails5] ActiveSupport::Durationの期間処理メソッド(3)5.1の改修とクラスメソッドの利用法

こんにちは、hachi8833です。ActiveSupport::Duration探訪の前回前々回でDurationの主なメソッドをご紹介しましたが、今回はRailsの#27610で行われたDurationの変更点を追ってみました。

期間の概念に迫るのは手強そうなのでコードの改修を中心に据えましたが、Rubyのクラスメソッドのよい使い方を見つけることができました。

今回の内容

今回はRails 5.0-stableより新しいバージョンのコードを中心に見ます。前回前々回ではRails 5.0-stableとRuby 2.4.0をベースにしていましたのでご注意ください。

条件

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

上のmasterは、#27610以降の変更も含みます。

比較対象

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するときのパラメータはvaluepartsです。コードにはこれらの説明はありません。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したりvaluepartsに直接アクセスすることは意図されていないと考えられます。1.monthといった方法でインスタンスを生成し、メソッド経由で正規にアクセスすべきでしょう。

Durationのインスタンス生成

ActiveSupportのDurationは2.months10.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.seconds5.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メソッドで生成していました。例のpartsnewの時点で仕込まれています。

コードの中に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の中身を少し追いやすくなったと思います。

関連記事

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

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


CONTACT

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