Ruby Advent Calendar 2020 の 23日目 です。
あまり内部の難しい話はできないのでドキュメントベースで何か書ければいいなと思っていたのですが、ちょうどRubyのバージョンアップでRangeを使った処理の挙動が変わってしまうという事態に遭遇し、それをきっかけに終端なしや始端なしRangeを調べたので、そのことについて書こうと思います。
概要
Ruby2.6から終端なしRangeが、Ruby2.7から始端なしRangeが使用できるようになっています。
それぞれのバージョンで生成される始端や終端なしのRangeが面白いので紹介します。
終端なしRangeと始端なしRange
1.. # 終端なし
..10 # 始端なし
始端終端共に省略して書くことはできません。
.. # syntax error
ただし、始端終端共になしのRangeは書けます。
nil..nil
終端もしくは始端がnilの場合のバージョンごとの差異
これらのバージョンごとの実行結果は以下のようになります。
1..nil
# < 2.6: ArgumentError
# >= 2.6: 1..
nil..10
# < 2.7: ArgumentError
# >= 2.7: ..10
nil..nil
# < 2.6: nil..nil
# ~> 2.6: nil..
# >= 2.7: nil..nil
最初の2つは違いが明白ですが、最後のものはいまいち違いがわかりづらいと思います。
< 2.6: nil..nil
これはその通りnil
からnil
のRangeです。
sizeはnil
でnil
しか含みません。
range = nil..nil
range.size #=> nil
range.cover?(nil) #=> true
~> 2.6: nil..
こちらもそのままnil
開始の終端なしRangeです。
こちらもsizeはnil
でnil
しか含みません。
range = nil..nil
range.size #=> nil
range.cover?(nil) #=> true
>= 2.7: nil..nil
最後にこちら、最初に述べた通り始端終端共になしのRangeです。
sizeはInfinity
で全てを含みます。
range = nil..nil
range.size #=> Infinity
range.cover?(nil) #=> true
range.cover?(1) #=> true
range.cover?('s') #=> true
range.cover?(true) #=> true
ちなみに最後のnil..nil
がsizeInfinity
となるので、nil..
や..nil
の場合のsizeも紹介していますが、Range#size
はArray#size
とは違い単純に要素の個数を表すものではありません。
('a'..'z').size #=> nil
('a'..'z').to_a.size #=> 26
要素数を表すメソッドではありますが、終端始端共にNumeric
のサブクラスオブジェクト、もしくはnil
の場合以外はnil
を返します。
Rangeは破壊的変更が不可能
Ruby の Range クラスは immutable です。つまり、オブジェクト自体を破壊的に変更することはできません。ですので、一度生成された Range のオブジェクトの指し示す範囲は決して変更することはできません。
そう言われるとなんとかして破壊的変更を行いたくなりますね。
...しかし、色々調べてみましたが破壊的な変更を行う手段はありませんでした。
るりまの話
Range#begin
とRange#first
、Range#end
とRange#last
が同じ説明として書かれていますが、始端なしや終端なしの場合結果が異なります。
(..10).begin #=> nil
(..10).first #=> RangeError
(1..).end #=> nil
(1..).last #=> RangeError
ソースコード読んでみると若干実装が違うみたいですね。
# Range#begin
static VALUE
range_begin(VALUE range)
{
return RANGE_BEG(range);
}
# Range#first
static VALUE
range_first(int argc, VALUE *argv, VALUE range)
{
VALUE n, ary[2];
if (NIL_P(RANGE_BEG(range))) {
rb_raise(rb_eRangeError, "cannot get the first element of beginless range");
}
if (argc == 0) return RANGE_BEG(range);
rb_scan_args(argc, argv, "1", &n);
ary[0] = n;
ary[1] = rb_ary_new2(NUM2LONG(n));
rb_block_call(range, idEach, 0, 0, first_i, (VALUE)ary);
return ary[1];
}
C言語は全く読めないのですが、パッとみた感じRange#first
やRange#last
はbegin
やend
で取得した値がnil
だった場合に例外を発生させるようです。
それ以降はRange#first
やRange#last
は引数を渡せば最初や最後を起点に値を複数取得できるのでその部分の処理みたいです。
この辺りの動作の違いを上手に説明できる文章を考えついたら修正PRを作成します。
ちなみにるりまの修正はここの「edit」リンクからPRの作成をすることで行えます。
Range#size
も終端もしくは始端がNumeric
のサブクラスオブジェクト以外の場合はnil
を返すとありますが、1..nil
の終端nil
はNumeric
のサブクラスオブジェクトではありませんがnil
ではなくInfinity
が返りますね。ここは解釈がどうなのかわからない1ので単純に誤りとは言えなさそうですが、表現をわかりやすく変更する余地はありそうです。
まとめ
普段よく使うようなクラスのオブジェクトもドキュメントを改めて読みながら手元で動かしていると新しい発見があったりして楽しいです。
最初に述べた「RubyのバージョンアップでRangeを使った処理の挙動が変わってしまうという事態」についてですが、以下のようなコードでした。
(time_or_nil..).cover?(time)
Timeオブジェクトの値を上記のような終端なしRangeと===
で比較し、含まれていればtrue
を返すような処理でした。Rangeはnil..
のような形を取る場合があり、この場合必ずfalse
を返していたのですが、ここが2.7に上げたタイミングで必ずtrue
を返すようになっていました。もしかすると同じような事態に遭遇する方もいらっしゃるかもしれませんのでご注意ください。
プライベートならまだ大丈夫ですが、仕事とかだと他人が書いたコードは全てを把握してませんので、こういうバージョンアップで挙動が変わる箇所は特に見落としやすいです。きちんと挙動が変わった時に拾えるよう、日頃からテストはきっちりと書いておきたいですね。
参考資料
-
(1..nil).end
の値nil
を終端と捉えても良いのかどうか。ここでのnil
は「終端なし」を表現するので終端はnil
ではなく「なし」だとも言える。 ↩