日本の祝日を出力可能なJPDateクラスを作る

以前からの続き。


jcalコマンドで祝日もちゃんと表示されるようになったのだけど、中のコードはまだ混沌としている...。そもそも、祝日を計算するJPCalendarモジュールが、lookupメソッドで参照する方式なんて、全然いけてない。脳内が表計算アプリの影響を受けているから、こんな発想になってしまうのだ。

module JPCalendar
  require 'date'

  class JPDate < Date
    def monday(w)
      Date.new(year, month, 7 * w.to_i - ((self - 1).wday + 6) % 7)
    end

    def spring_day
      case year
      when 1900..2099
        dy = year - 1900
        Date.new(year, month, (21.4471 + 0.242377*dy - dy/4).to_i)
      end
    end

    def autumn_day
      case year
      when 1900..2099
        dy = year - 1900
        Date.new(year, month, (23.8896 + 0.242032*dy - dy/4).to_i)
      end
    end
  end

  class JPHoliday
    HOLIDAYS = [
      {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
      {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
      {month:11, day:12,          term:1990..1990, name:'即位の礼'},
      {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
      {month:1,  day:1,           term:   0..9999, name:'元旦'},
      {month:1,  day:15,          term:   0..1999, name:'成人の日'},
      {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
      {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
      {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
      {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
      {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
      {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
      {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
      {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
      {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
      {month:7,  day:20,          term:1996..2002, name:'海の日'},
      {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
      {month:8,  day:11,          term:2016..9999, name:'山の日'},
      {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
      {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
      {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
      {month:10, day:10,          term:1966..1999, name:'体育の日'},
      {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
      {month:11, day:3,           term:   0..9999, name:'文化の日'},
      {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
      {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
    ]

    def initialize(y)
      # 有効な祝日を取り出し、日付を追加する
      enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
        case h[:day]
        when Fixnum
          {date: Date.new(y, h[:month], h[:day])}.merge(h)
        when String
          {date: JPDate.new(y, h[:month]).send(*h[:day].split)}.merge(h)
        end
      end

      enable_dates = enable_holidays.map {|h| h[:date]}

      # 振替休日を判定
      enable_dates.each do |date|
        if date.wday == 0
          while enable_dates.include?(date)
            date += 1
          end
          enable_holidays << {date:date, name:'振替休日'}
        end
      end

      # 国民の休日を判定
      enable_dates.each_cons(2) do |a, b|
        if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
          enable_holidays << {date:a + 1, name:'国民の休日'}
        end
      end

      @holidays_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort
    end
  
    def lookup(*args)
      case args.first
      when Fixnum
        @holidays_database.assoc(Date.new(*args))
      when Date
        @holidays_database.assoc(*args)
      when String
        @holidays_database.assoc(Date.parse(*args))
      end
    end
  end # class JPHoliday
end # module JPCalendar


本来ならDateオブジェクトから曜日を取得する操作感で、祝日も取得できた方がいい。きっと欲しいのはholidayメソッド。ここからはコードをじっくり眺めて、より簡潔に分かりやすいコード表現を目指して、繰り返し書き直すのだ。

とりあえずholidayメソッドを実装

  • 実装の仕方はともかく、Dateを継承したJPDateクラスを作って、holidayメソッドを作ってみた。
  • ハッピーマンデー・春分秋分を計算するクラスは、JPCalcへと名称変更した。
module JPCalendar
  require 'date'

...中略...

  class JPHoliday
    HOLIDAYS = [
...中略...
    ]

    def initialize(y)
      # 有効な祝日を取り出し、日付を追加する
      enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
        case h[:day]
        when Fixnum
          {date: Date.new(y, h[:month], h[:day])}.merge(h)
        when String
          {date: JPCalc.new(y, h[:month]).send(*h[:day].split)}.merge(h)
        end
      end

      enable_dates = enable_holidays.map {|h| h[:date]}

      # 振替休日を判定
      enable_dates.each do |date|
        if date.wday == 0
          while enable_dates.include?(date)
            date += 1
          end
          enable_holidays << {date:date, name:'振替休日'}
        end
      end

      # 国民の休日を判定
      enable_dates.each_cons(2) do |a, b|
        if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
          enable_holidays << {date:a + 1, name:'国民の休日'}
        end
      end

      @holidays_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort
    end
  
    def lookup(*args)
      case args.first
      when Fixnum
        @holidays_database.assoc(Date.new(*args))
      when Date
        @holidays_database.assoc(*args)
      when String
        @holidays_database.assoc(Date.parse(*args))
      end
    end
  end # class JPHoliday

  class JPDate < Date
    def holiday
      JPHoliday.new(year).lookup(self).to_a.last
    end
  end # class JPDate
end # module JPCalendar
  • このJPDateを実際に使ってみると、思ったとおり使いやすい。
irb(main):001:0> date = JPDate.new(2014, 9, 23)
irb(main):002:0> puts date.holiday
秋分の日
  • 自分が欲しかったのは、このようなholidayメソッドなのだ。

Dateを継承するとinitializeされない謎

  • それにしてもholidayメソッドを追加するだけなのに、JPCalc・JPHoliday・JPDateと3つもクラスを使っている。
  • きっと、JPDate1つにすべての機能を詰め込んでも良さそう。
  • そこで、インスタンス変数@holidayを用意して、newした時に祝日名称を保存しようと考えた。ところが...
require 'date'

class JPDate < Date
  attr_reader :holiday
  
  def initialize(y, m, d)
    @holiday = '秋分の日'
  end
end # class JPDate

date = JPDate.new(2014, 9, 23)
p date.holiday
$ ruby jpdate_test.rb 
nil
  • 間違いなく@holiday = '秋分の日'を設定しているのに、実行するとnilが返る...。謎。
  • 試しに、Dateの継承を止めてみると、ちゃんと設定されている。
class JPDate #< Date
  attr_reader :holiday
  
  def initialize(y, m, d)
    @holiday = '秋分の日'
  end
end # class JPDate

date = JPDate.new(2014, 9, 23)
p date.holiday
$ ruby jpdate_test.rb 
"秋分の日"

Dateクラスは、newしてもinitializeされないのか?何故?

  • 強引にnewメソッドも再定義してみた。
require 'date'

class JPDate < Date
  attr_reader :holiday

  def self.new(y, m, d)
    res = super
    res.initialize(y, m, d)
    res
  end

  def initialize(y, m, d)
    @holiday = '秋分の日'
  end
end # class JPDate

date = JPDate.new(2014, 9, 23)
p date.holiday
  • initializeメソッドはprivate扱いになっているらしく、エラー発生!
$ ruby jpdate_test.rb 
jpdate_test.rb:8:in `new': private method `initialize' called for # (NoMethodError)
  • ならば、sendで呼び出してみると...
require 'date'

class JPDate < Date
  attr_reader :holiday

  def self.new(y, m, d)
    res = super
    res.send(:initialize, y, m, d)
    res
  end

  def initialize(y, m, d)
    @holiday = '秋分の日'
  end
end # class JPDate

date = JPDate.new(2014, 9, 23)
p date.holiday
  • これでようやくinitializeが実行され、@holidayに祝日名称を設定できた。
$ ruby jpdate_test.rb 
"秋分の日"
  • しかし、newやinitializeをこのように再定義してしまって良いものかどうか?


一抹の不安が残る...。

Dateクラスのソースコード

  • Dateクラスは、一体どんな仕組みになっているのか?
  • 中身を見るために、require 'date'した時のロードパスを調べてみた。
$ irb
irb(main):001:0> require 'date'
=> true
irb(main):002:0> puts $LOADED_FEATURES.grep /date/
/System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/lib/ruby/2.0.0/universal-darwin13/date_core.bundle
/System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/lib/ruby/2.0.0/date/format.rb
/System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/lib/ruby/2.0.0/date.rb
  • date.rbを見てみると...
require 'date_core'
require 'date/format'

# 以下、Infinityクラスの定義のみ
9417	#ifndef NDEBUG
9418	#define de_define_singleton_method rb_define_singleton_method
9419	#define de_define_alias rb_define_alias
9420	    de_define_singleton_method(cDate, "new!", date_s_new_bang, -1);
9421	    de_define_alias(rb_singleton_class(cDate), "new_l!", "new");
9422	#endif
9423	
9424	    rb_define_singleton_method(cDate, "jd", date_s_jd, -1);
9425	    rb_define_singleton_method(cDate, "ordinal", date_s_ordinal, -1);
9426	    rb_define_singleton_method(cDate, "civil", date_s_civil, -1);
9427	    rb_define_singleton_method(cDate, "new", date_s_civil, -1);
9428	    rb_define_singleton_method(cDate, "commercial", date_s_commercial, -1);
  • この辺りから辿れそうな気がするが、今の自分には追跡しきれなかった...。
  • 結局、自分にとっては完全なブラックボックスである。
  • 中身が分からないのに、強引にnewとinitializeを再定義して、書き換えるのはちょっと気が引ける。

必要になってから計算する方式に変更

  • そもそも現状では、休日情報は必要になってから日付を元に計算して、求める仕組みになっている。
  • newした時点で、インスタンスに休日情報を保持させる必要はないのだ。
  • holidayメソッドを実行した時に計算する方が無駄がない気がしてきた。
  • すべての機能は、JPDate1つにまとめてしまった。
  • 祝日情報を参照するテーブルは、クラス変数@@holidays_databaseにして共用するようにした。
    • 今まではインスタンス変数@holidays_databaseに保持していた。
    • この方式ではJPDateをnewする度に祝日テーブルを作り直すことになってしまい、無駄が多い。
  • holidayメソッドが呼ばれると、以下の手順で祝日名称を取得するのだ。
    • 祝日テーブルとJPDateインスタンスの西暦が一致しているか確認して、
    • 違っていたらJPDateインスタンスの西暦で祝日テーブルを作り直して、
    • その後、祝日名称を取得するのだ。
require 'date'

class JPDate < Date
  HOLIDAYS = [
    {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
    {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
    {month:11, day:12,          term:1990..1990, name:'即位の礼'},
    {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
    {month:1,  day:1,           term:   0..9999, name:'元旦'},
    {month:1,  day:15,          term:   0..1999, name:'成人の日'},
    {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
    {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
    {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
    {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
    {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
    {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
    {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
    {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
    {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
    {month:7,  day:20,          term:1996..2002, name:'海の日'},
    {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
    {month:8,  day:11,          term:2016..9999, name:'山の日'},
    {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
    {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
    {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
    {month:10, day:10,          term:1966..1999, name:'体育の日'},
    {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
    {month:11, day:3,           term:   0..9999, name:'文化の日'},
    {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
    {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
  ]
  @@holiday_database = nil

  def holiday
    build_holiday(year) if year != holiday_year
    @@holiday_database.assoc(self).to_a.last
  end

  private

  def holiday_year
    @@holiday_database ? @@holiday_database[0][0].year : nil
  end

  def build_holiday(y)
    # 有効な祝日を取り出し、日付を追加する
    enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}.map do |h|
      case h[:day]
      when Fixnum
        {date: Date.new(y, h[:month], h[:day])}.merge(h)
      when String
        {date: send(*h[:day].split, y, h[:month])}.merge(h)
      end
    end

    enable_dates = enable_holidays.map {|h| h[:date]}

    # 振替休日を判定
    enable_dates.each do |date|
      if date.wday == 0
        while enable_dates.include?(date)
          date += 1
        end
        enable_holidays << {date:date, name:'振替休日'}
      end
    end

    # 国民の休日を判定
    enable_dates.each_cons(2) do |a, b|
      if b.day - a.day == 2 && (a + 1).wday != 0 && !enable_holidays.map {|h| h[:date]}.include?(a + 1)
        enable_holidays << {date:a + 1, name:'国民の休日'}
      end
    end

    @@holiday_database = enable_holidays.map {|h| [h[:date], h[:name]]}.sort
  end

  def monday(w, y, m)
    Date.new(y, m, 7 * w.to_i - ((Date.new(y, m) - 1).wday + 6) % 7)
  end

  def spring_day(y, m)
    case y
    when 1900..2099
      dy = y - 1900
      Date.new(y, m, (21.4471 + 0.242377*dy - dy/4).to_i)
    end
  end

  def autumn_day(y, m)
    case y
    when 1900..2099
      dy = y - 1900
      Date.new(y, m, (23.8896 + 0.242032*dy - dy/4).to_i)
    end
  end
end # class JPDate


これでJPDateは、holidayメソッドによって祝日名称を出力できるようになった!

春分秋分計算の重複を排除

  • じっとコードを眺めていると、春分秋分計算のコードがほとんど同じことに気付く。
  • 違っているのは数値のみ。無駄な重複を排除してみる。
...中略...
  def equinox_day(y, m)
    case y
    when 1900..2099
      dy = y - 1900
      return Date.new(y, m, (21.4471 + 0.242377*dy - dy/4).to_i) if m == 3
      return Date.new(y, m, (23.8896 + 0.242032*dy - dy/4).to_i) if m == 9
    end
  end
  alias_method :spring_day, :equinox_day
  alias_method :autumn_day, :equinox_day
end # class JPDate

@@holiday_databaseをハッシュに変更

  • さらに、現状@@holiday_databaseは、日付と祝日名称のペアの配列である。
  • でもこれって素直にハッシュを使った方が効率的なはず。
  • クラス変数名もシンプルに@@holidaysにしてみた。

メソッドを分割する

  • さらに、さらに、build_holidayメソッドが長過ぎる。
  • わざわざ3つに区切ってコメントを書いているということは、機能が分かれている証拠。
  • メソッドを分割してみた。
require 'date'

class JPDate < Date
  HOLIDAYS = [
...中略...
  ]
  @@holidays = {}

  def holiday
    build_holiday(year) if year != holiday_year
    @@holidays[self]
  end

  private

  def holiday_year
    @@holidays.keys[0].year rescue nil
  end

  def build_holiday(y)
    @@holidays = {}
    enable_holidays = HOLIDAYS.select {|h| h[:term].include?(y)}
    enable_holidays.each do |h|
      case h[:day]
      when Fixnum
        @@holidays[Date.new(y, h[:month], h[:day])]    = h[:name]
      when String
        @@holidays[send(*h[:day].split, y, h[:month])] = h[:name]
      end
    end
    holidays_dates = @@holidays.keys
    add_substitute_holiday(holidays_dates)
    add_national_holiday(holidays_dates)
  end

  # 振替休日を追加
  def add_substitute_holiday(dates)
    dates.each do |date|
      if date.wday == 0
        while @@holidays.keys.include?(date)
          date += 1
        end
        @@holidays[date] = '振替休日'
      end
    end
  end

  # 国民の休日を追加
  def add_national_holiday(dates)
    dates.each_cons(2) do |a, b|
      if b.day - a.day == 2 && (a + 1).wday != 0 && !@@holidays.keys.include?(a + 1)
        @@holidays[a + 1] = '国民の休日'
      end
    end
  end
...中略...

コード表現をさらに簡潔にする

  • コード全体の構成は決まった。
  • あとはコードの表現をより簡潔に、より短くを目指して工夫してみる。
  • 最終的なJPDateクラスは、以下のコードになった。
require 'date'

class JPDate < Date
  HOLIDAYS = [
    {month:4,  day:10,          term:1959..1959, name:'結婚の儀'},
    {month:2,  day:24,          term:1989..1989, name:'大喪の礼'},
    {month:11, day:12,          term:1990..1990, name:'即位の礼'},
    {month:6,  day:9,           term:1993..1993, name:'結婚の儀'},
    {month:1,  day:1,           term:   0..9999, name:'元旦'},
    {month:1,  day:15,          term:   0..1999, name:'成人の日'},
    {month:1,  day:'monday 2',  term:2000..9999, name:'成人の日'},
    {month:2,  day:11,          term:1967..9999, name:'建国記念日'},
    {month:3,  day:'spring_day',term:1900..2099, name:'春分の日'},
    {month:4,  day:29,          term:   0..1988, name:'天皇誕生日'},
    {month:4,  day:29,          term:1989..2006, name:'みどりの日'},
    {month:4,  day:29,          term:2007..9999, name:'昭和の日'},
    {month:5,  day:3 ,          term:   0..9999, name:'憲法記念日'},
    {month:5,  day:4 ,          term:2007..9999, name:'みどりの日'},
    {month:5,  day:5 ,          term:   0..9999, name:'こどもの日'},
    {month:7,  day:20,          term:1996..2002, name:'海の日'},
    {month:7,  day:'monday 3',  term:2003..9999, name:'海の日'},
    {month:8,  day:11,          term:2016..9999, name:'山の日'},
    {month:9,  day:15,          term:1966..2002, name:'敬老の日'},
    {month:9,  day:'monday 3',  term:2003..9999, name:'敬老の日'},
    {month:9,  day:'autumn_day',term:1900..2099, name:'秋分の日'},
    {month:10, day:10,          term:1966..1999, name:'体育の日'},
    {month:10, day:'monday 2',  term:2000..9999, name:'体育の日'},
    {month:11, day:3,           term:   0..9999, name:'文化の日'},
    {month:11, day:23,          term:   0..9999, name:'勤労感謝の日'},
    {month:12, day:23,          term:1989..9999, name:'天皇誕生日'},
  ]
  @@holidays = {}

  def holiday
    build_holiday if year != holiday_year
    @@holidays[self]
  end

  private

  def holiday_year
    @@holidays.keys.first.year rescue nil
  end

  def build_holiday
    @@holidays = {}
    HOLIDAYS.select {|h| h[:term].include?(year)}.each do |h|
      date = case h[:day]
             when Fixnum then Date.new(year, h[:month], h[:day])
             when String then send(*h[:day].split, year, h[:month])
             end
      @@holidays[date] = h[:name]
    end
    dates = @@holidays.keys.sort
    add_substitute_holiday(dates)
    add_national_holiday(dates)
  end

  # 振替休日を追加
  def add_substitute_holiday(dates)
    dates.each do |date|
      if date.wday == 0
        while @@holidays.keys.include?(date)
          date += 1
        end
        @@holidays[date] = '振替休日'
      end
    end
  end

  # 国民の休日を追加
  def add_national_holiday(dates)
    dates.each_cons(2) do |a, b|
      if b.day - a.day == 2 && (a + 1).wday != 0 && !@@holidays.keys.include?(a + 1)
        @@holidays[a + 1] = '国民の休日'
      end
    end
  end

  def monday(w, y, m)
    Date.new(y, m, 7 * w.to_i - ((Date.new(y, m) - 1).wday + 6) % 7)
  end

  def equinox_day(y, m)
    case y
    when 1900..2099
      dy = y - 1900
      return Date.new(y, m, (21.4471 + 0.242377*dy - dy/4).to_i) if m == 3
      return Date.new(y, m, (23.8896 + 0.242032*dy - dy/4).to_i) if m == 9
    end
  end
  alias_method :spring_day, :equinox_day
  alias_method :autumn_day, :equinox_day
end # class JPDate

仕様

  • JPDateクラスは、Dateクラスに祝日名称を出力するholidayメソッドを追加したクラスである。
    • Dateクラスのすべての機能 + holidayインスタンスメソッドのみ。
    • JPDateクラス内部では、以下の定数とクラス変数を利用している。
      • HOLIDAYS
      • @@holidays
  • コマンド作成時点(2014年9月)において確認できる日本の法令に基づいて、1900年から2099年までの日本の祝日名称を出力する。
  • 国民の祝日に関する法律に準拠する。(昭和23年・1948年7月20日 公布・施行)
  • 1948年上記法律施行以降から2099年までの祝日名称を出力する。
  • 将来の日付の祝日は現行の法律をそのまま適用して算出したもの。
    • コマンド内の祝日情報を使って計算する。
    • 祝日情報を求めて、外部とは通信しない。

ダウンロード