無限大を含む Date の Range で include? (include_with_range?) に失敗する

Ruby + ActiveSupport で、ある期間と別の期間との関係を調べるメソッドを書いてたところ、期間に無限大を含む場合に失敗した。

# ruby 1.8.7
require "date"
require "rubygems"
require "active_support"

(Date.today..(1.0 / 0)).include?(Date.today..Date.tomorrow) # true これは問題ないが、逆にすると...
(Date.today..Date.tomorrow).include?(Date.today..(1.0 / 0)) # NoMethodError: undefined method `<=' for nil:NilClass

ActiveSupport のコードを追ってみると、原因は Float <=> Date で nil が返ってしまう点にあるらしい。

Date.today <=> 1.0 # 1
1.0 <=> Date.today # nil

Date が演算子の左側にある場合、Date#<=> が呼ばれる。実装は下記。

# File lib/date.rb, line 1267def <=> (other)
case other
  when Numeric
    return @ajd <=> other
  when Date
    return @ajd <=> other.ajd
  end
  nil
end

演算子の右側が Numeric なら、@ajd と比較している。@ajd は、紀元前4713年1月1日からの日数を示す数値。数値なので Float と比較できる。

いっぽう、Float が演算子の左側にある場合は、Float#<=> が呼ばれるため、Date をどう扱っていいかわからず、比較できない。

そこで Date#coerce を実装した。

require "date"
class Date
  def coerce(other)
    return [other, @ajd] if other.is_a? Numeric
    nil
  end
end

Numeric は、比較対象と型が異なる場合、比較対象の coerce メソッドを (あれば) 呼び出して、型を合わせてから比較する。これにより、@ajd を使って比較できるようにする。これで演算子の左側が Float でも比較できるようになり、include? や overlaps? も動く。

1.0 <=> Date.today # -1
(Date.today..Date.tomorrow).include?(Date.today..(1.0 / 0)) # false

また、始点に Float を、終点に Date を持つ Range も作成できる。

(-(1.0 / 0)..Date.today) # 無限遠の過去から今日までの Range

include? や overlaps? 含め、おおむね期待どおりに動くが、この場合、Date の範囲でなく Float の範囲になってしまうので、Enumerable にならないなどの制限がある。

しかし coerce なんて、ごく最近、プログラミング言語 Ruby 読むまで知らなかった。記述細かいなあと思ってたけど、役に立つもんですね。