背景
必要に迫られて、2つのRangeオブジェクト間の共通部分(交差)を求めるメソッドを作成しました。
なお、現状の Ruby 3.4 には、この交差を求めるメソッドはありません。てっきり、既にあると思っていたのですが(後述)
因みに、困ったときの ActiveSupport にも Range 拡張に交差を求めるメソッドは、ありませんでした。
要求仕様
- 動作環境は Ruby 3.4
-
Range#overlap?が使える前提です
-
- 無限範囲に対応必要
- 始端や終端に
nilを指定した、片側が無限の範囲です
- 始端や終端に
- 終端を含む/含まない、に対応必要
- exclude_end 指定があります
- Rangeの始端や終端は、比較可能
- 今回、具体的には Date が入ります
- 空のRangeは、与えられない
- 今回の具体的な用途による限定です
実装
module RangeExtensions
class Intersection
def initialize(range1, range2)
raise ArgumentError, 'values must be a Ranges' unless range1.is_a?(Range) && range2.is_a?(Range)
@range1 = range1
@range2 = range2
end
def call = overlap? ? intersection : nil
private
attr_reader :range1, :range2
def intersection = Range.new(new_begin, new_end, new_exclude)
def overlap? = range1.overlap?(range2)
def new_begin = [range1.begin, range2.begin].compact.max
def new_end = [range1.end, range2.end].compact.min
def new_exclude = same_end? ? same_end_exclude : different_end_exclude
def same_end? = end_from_range1? && end_from_range2?
# 両者の終端が同じ場合、どちらかが exclude ならば、交差も exclude になる
def same_end_exclude = range1.exclude_end? || range2.exclude_end?
# 両者の終端が異なる場合、採用した終端の exclude が、交差の exclude になる
def different_end_exclude = end_from_range1? ? range1.exclude_end? : range2.exclude_end?
def end_from_range1? = (new_end == range1.end)
def end_from_range2? = (new_end == range2.end)
end
refine Range do
def intersection(other) = Intersection.new(self, other).call
end
end
生成AIの利用
- まず自力でコード実装します1
- メジャーな複数の生成AI2に対して「このコードの解説と評価をしなさい」とだけ指示して、結果を出力
- 得られた結果を見比べて、意図の誤解や誤読がなされていないことを確認しておき
- 提示された改良点を理解し、妥当かどうか判断した上で
- 参考になる部分をコードに反映し、数回の修正を実施しました
例えば以下の実装は、生成AIの提案を参考にして実装しました。
片側無限の場合、端点が nil になる可能性を踏まえて、最大/最小を求めています。
def new_begin = [range1.begin, range2.begin].compact.max
def new_end = [range1.end, range2.end].compact.min
使用方法
- クラス内で
using RangeExtensionsすると、Range#intersectionが利用できます - 共通部分(交差)が無い場合、
nilを返します - 共通部分(交差)が一点のみの場合、その一点を含むRange (
n..n) を返します - 終端を含まない場合(exclude)、終端は「範囲が重なっていない」と見做します
実行例
using RangeExtensions
# 基本的な交差
(1..8).intersection(3..10) #=> 3..8
# 終端を含まない場合
(1...8).intersection(3..10) #=> 3...8
(1...8).intersection(3..8) #=> 3...8
# 交差がない場合
(1..8).intersection(10..18) #=> nil
# 終端を含むか含まないかの違い
(1..3).intersection(3..5) #→ 3..3
(1...3).intersection(3..5) #→ nil
# 片側無限の範囲
(1..).intersection(..10) #=> 1..10
(..5).intersection(..8) #=> ..5
(1..).intersection(1...5) #=> 1...5
# Date での実用例
require 'date'
period1 = Date.new(2025, 12, 25)..Date.new(2026, 1, 15)
period2 = Date.new(2025, 12, 31)..Date.new(2026, 4, 1)
period1.intersection(period2)
=> #<Date: 2025-12-31>..#<Date: 2026-01-15>
# Date内部表現は省略しています
なぜ最初から入っていないのか?
Rangeの共通部分(交差)を求める、という用途は一般的なものだと思います。また、前記のコードを実装してみて、それほど複雑ではない、という感触を持ちました。
Ruby の場合、こうしたケースだと「きっと既にあるに違いない」という予測が当たっていることが多かった3ため、Ruby 標準ライブラリ(Core)にも ActiveSupport 拡張にも、どちらにも無かった点が気になりました。
特に、Range#overlap? が以前から実装済みだった4ことから、きっと Range#intersection もあるに違いないと思っていたので意外でした。
この点を少し調べたところ、2020年ころに既に Issue #16757 "Add intersection to Range" として提案が出ていたものの、現時点ではOpenなままでした。
これはどうやら、汎用の仕様を決めることが難しかったらしく、
- 空集合を表現する標準的な Range オブジェクトが存在しない
-
nilだとメソッドチェーンがやりにくくなる
-
- 連続値(区間)と離散値(列)の違い
-
Range#stepの扱い -
Array#intersectionとの一貫性
などなど、ちょっと合意が簡単には得られない課題があるようです。
確かに、今回の実装に際して生成AIのコメントでも、仕様面や境界条件の扱いに関する指摘が多かったです。
まとめ
今回の様に、特定の用途に絞るなら、仕様の割り切りが出来るので、比較的簡単な実装が可能です。
一方で、標準に含めるためには、仕様面での課題から、難しい点が多いと思われます。