Seiichi Yonezawa — How creativity is helped by failure

祝日の取得

有給休暇を取得しようか悩ましい時期が訪れますね。それはそうと、現在案件で「年末年始を取得する」コードを書いていたのですが、その判定のなかに「祝日を取得する」というライブラリが用いられていました。その特徴によると:

このロジックは、営業日数計算など連続して祝日判定を行なう処理の為に、レスポンスを第一義として、可能な限り少ない【条件判定の実行】で結果を出せるように設計してあります(このコードはVBAでの利用を前提にして作っています。同じコードでも、VBでネイティブコンパイルした場合と比べれば、VBAは、かなり低速です。そのVBAを使っていても、出来るだけ効率良く高速に処理されるように作ってあります)。
1年間の判定を考えた場合、1年365日の内、祝日は20日ほど、残りの345日ほどは祝日以外です。したがって、レスポンスを向上させるポイントは『如何に祝日と判定するか』ではなく、『どれだけ早く祝日でないという判断を下せるか』です。

元のコードがVisualBasicということを考えると手続き型のアプローチは理にかなっていると思うのですが、Rubyのようなオブジェクト指向にこの処理をそのまま当てはめるのは果たして効果的なのかどうか興味がわきました。というわけでまずは単純なテストを用意してみます。

module HolidaysIn2017
  HOLIDAYS = [
    Time.new(2017, 1, 1),     # 元日
    Time.new(2017, 1, 2),     # 振替休日
    Time.new(2017, 1, 9),     # 成人の日
    Time.new(2017, 2, 11),    # 建国記念の日
    Time.new(2017, 3, 20),    # 春分の日
    Time.new(2017, 4, 29),    # 昭和の日
    Time.new(2017, 5, 3),     # 憲法記念日
    Time.new(2017, 5, 4),     # みどりの日
    Time.new(2017, 5, 5),     # こどもの日
    Time.new(2017, 7, 17),    # 海の日
    Time.new(2017, 8, 11),    # 山の日
    Time.new(2017, 9, 18),    # 敬老の日
    Time.new(2017, 9, 23),    # 秋分の日
    Time.new(2017, 10, 9),    # 体育の日
    Time.new(2017, 11, 3),    # 文化の日
    Time.new(2017, 11, 23),   # 勤労感謝の日
    Time.new(2017, 12, 23),   # 天皇誕生日
  ].freeze

  def self.shukujitu?(time)
    HOLIDAYS.include?(time)
  end
end

これがいわゆる「表引き」で、あらかじめ祝日を登録しておくことで対象となるTimeが正しいか調べます。^1また、今回は単純な比較対象が目的なので今年(2017年)のみを対象としました。

require "benchmark/ips"
require "./holiday_logic"
require "./holidays_in_2017"

def generate_day
  Time.new(2017, rand(1..12), rand(1..31))
end

DATES = Array.new(10_000) { generate_day }.freeze

Benchmark.ips do |x|
  x.report("holiday_logic")        { DATES.map{ |t| Shukujitu.shukujitu?(t) } }
  x.report("holidays_in_2017")     { DATES.map{ |t| HolidaysIn2017.shukujitu?(t) } }

  x.compare!
end

1万件のランダムな2017年の日付に対して判定するようにしました。単にShukujitsu.shukujitsu?(generate_day)としなかったのは同一の条件でテストをするためです。ちなみに実行環境は仮想マシンのruby 2.4.1p111です。

Warming up --------------------------------------
       holiday_logic     6.000  i/100ms
    holidays_in_2017     1.000  i/100ms
Calculating -------------------------------------
       holiday_logic     55.504  (± 5.4%) i/s -    282.000  in   5.092435s
    holidays_in_2017     11.942  (± 8.4%) i/s -     60.000  in   5.034287s

Comparison:
       holiday_logic:       55.5 i/s
    holidays_in_2017:       11.9 i/s - 4.65x  slower

そして、これをいざ実行してみると確かに表引きが遅いようです。また、これがもし2018年等のデータが増えるにつれ処理速度が遅くなっていくことは想像に難しくありません。しかし、ステップを減らすという着眼点で考えてみると「ハッシュテーブル」はどうでしょうか。

module HolidaysIn2017
  module V2
    HOLIDAYS = [
      Time.new(2017, 1, 1),     # 元日
      Time.new(2017, 1, 2),     # 振替休日
      Time.new(2017, 1, 9),     # 成人の日
      Time.new(2017, 2, 11),    # 建国記念の日
      Time.new(2017, 3, 20),    # 春分の日
      Time.new(2017, 4, 29),    # 昭和の日
      Time.new(2017, 5, 3),     # 憲法記念日
      Time.new(2017, 5, 4),     # みどりの日
      Time.new(2017, 5, 5),     # こどもの日
      Time.new(2017, 7, 17),    # 海の日
      Time.new(2017, 8, 11),    # 山の日
      Time.new(2017, 9, 18),    # 敬老の日
      Time.new(2017, 9, 23),    # 秋分の日
      Time.new(2017, 10, 9),    # 体育の日
      Time.new(2017, 11, 3),    # 文化の日
      Time.new(2017, 11, 23),   # 勤労感謝の日
      Time.new(2017, 12, 23),   # 天皇誕生日
    ].group_by do |time|
      time.year * 100 + time.month
    end

    def self.shukujitu?(time)
      HOLIDAYS.fetch(time.year*100+time.month, []).include?(time)
    end
  end
end

上記例では年と月の組み合わせをキーとして、そこに該当する日付を検索します。この例では毎回1/17を調べるのではなく、一番多い例でも1/3から調べるのでステップは減るはずです。この例ではどうでしょうか。

Warming up --------------------------------------
       holiday_logic     5.000  i/100ms
    holidays_in_2017     1.000  i/100ms
 holidays_in_2017_v2     8.000  i/100ms
Calculating -------------------------------------
       holiday_logic     58.042  (± 5.2%) i/s -    290.000  in   5.008739s
    holidays_in_2017     11.879  (± 0.0%) i/s -     60.000  in   5.058658s
 holidays_in_2017_v2     86.375  (± 2.3%) i/s -    432.000  in   5.004637s

Comparison:
 holidays_in_2017_v2:       86.4 i/s
       holiday_logic:       58.0 i/s - 1.49x  slower
    holidays_in_2017:       11.9 i/s - 7.27x  slower

すると、今度は元のコードよりもおよそ1.5倍高速化に成功しました。これは単にハッシュのインデックスが少ないから早いだけなのかもしれません。というわけで、以下のようなコードに書き換えてみました。

module HolidaysIn2017
  module V2a
    HOLIDAYS = Array.new(203) do |i|
      year = 1948 + i
      [
        Time.new(year, 1, 1),     # 元日
        Time.new(year, 1, 2),     # 振替休日
        Time.new(year, 1, 9),     # 成人の日
        Time.new(year, 2, 11),    # 建国記念の日
        Time.new(year, 3, 20),    # 春分の日
        Time.new(year, 4, 29),    # 昭和の日
        Time.new(year, 5, 3),     # 憲法記念日
        Time.new(year, 5, 4),     # みどりの日
        Time.new(year, 5, 5),     # こどもの日
        Time.new(year, 7, 17),    # 海の日
        Time.new(year, 8, 11),    # 山の日
        Time.new(year, 9, 18),    # 敬老の日
        Time.new(year, 9, 23),    # 秋分の日
        Time.new(year, 10, 9),    # 体育の日
        Time.new(year, 11, 3),    # 文化の日
        Time.new(year, 11, 23),   # 勤労感謝の日
        Time.new(year, 12, 23),   # 天皇誕生日
      ]
    end.flatten.group_by do |time|
      time.year * 100 + time.month
    end

    def self.shukujitu?(time)
      HOLIDAYS.fetch(time.year*100+time.month, []).include?(time)
    end
  end
end

このバージョンでは祝日法によって制定されている1948年から2150年までの範囲を想定しています。さらに突き詰めるなら遅延評価を使うかもしれませんが、現状はこれだけでも十分でしょう。

Warming up --------------------------------------
       holiday_logic     6.000  i/100ms
    holidays_in_2017     1.000  i/100ms
 holidays_in_2017_v2     8.000  i/100ms
holidays_in_2017_v2a     8.000  i/100ms
Calculating -------------------------------------
       holiday_logic     61.769  (± 3.2%) i/s -    312.000  in   5.054810s
    holidays_in_2017     12.264  (± 0.0%) i/s -     62.000  in   5.057041s
 holidays_in_2017_v2     83.569  (± 8.4%) i/s -    416.000  in   5.010722s
holidays_in_2017_v2a     85.399  (± 4.7%) i/s -    432.000  in   5.070677s

Comparison:
holidays_in_2017_v2a:       85.4 i/s
 holidays_in_2017_v2:       83.6 i/s - same-ish: difference falls within error
       holiday_logic:       61.8 i/s - 1.38x  slower
    holidays_in_2017:       12.3 i/s - 6.96x  slower

結果を見ると、おそらく言語側でハッシュテーブルが最適化されているおかげで特にパフォーマンスに影響することはありませんでした。もちろん、この後に細かい条件を加えていかなければ完全に同じ条件とは言えないのですが、現時点でも最小限の処理でパフォーマンスを向上させる余地があって嬉しいです。