LoginSignup
3

More than 1 year has passed since last update.

Rangeの話

Last updated at Posted at 2020-12-22

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はnilnilしか含みません。

range = nil..nil
range.size        #=> nil
range.cover?(nil) #=> true

~> 2.6: nil..

こちらもそのままnil開始の終端なしRangeです。
こちらもsizeはnilnilしか含みません。

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#sizeArray#sizeとは違い単純に要素の個数を表すものではありません

('a'..'z').size      #=> nil
('a'..'z').to_a.size #=> 26

要素数を表すメソッドではありますが、終端始端共にNumericのサブクラスオブジェクト、もしくはnilの場合以外はnilを返します。

Rangeは破壊的変更が不可能

Ruby の Range クラスは immutable です。つまり、オブジェクト自体を破壊的に変更することはできません。ですので、一度生成された Range のオブジェクトの指し示す範囲は決して変更することはできません。

そう言われるとなんとかして破壊的変更を行いたくなりますね。
...しかし、色々調べてみましたが破壊的な変更を行う手段はありませんでした。

るりまの話

Range#beginRange#firstRange#endRange#lastが同じ説明として書かれていますが、始端なしや終端なしの場合結果が異なります。

Range#begin

(..10).begin #=> nil
(..10).first #=> RangeError

Range#end

(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#firstRange#lastbeginendで取得した値がnilだった場合に例外を発生させるようです。
それ以降はRange#firstRange#lastは引数を渡せば最初や最後を起点に値を複数取得できるのでその部分の処理みたいです。

この辺りの動作の違いを上手に説明できる文章を考えついたら修正PRを作成します。

ちなみにるりまの修正はここの「edit」リンクからPRの作成をすることで行えます。
スクリーンショット 2020-12-21 23.07.22.png

Range#sizeも終端もしくは始端がNumericのサブクラスオブジェクト以外の場合はnilを返すとありますが、1..nilの終端nilNumericのサブクラスオブジェクトではありませんがnilではなくInfinityが返りますね。ここは解釈がどうなのかわからない1ので単純に誤りとは言えなさそうですが、表現をわかりやすく変更する余地はありそうです。

Range#size

まとめ

普段よく使うようなクラスのオブジェクトもドキュメントを改めて読みながら手元で動かしていると新しい発見があったりして楽しいです。

最初に述べた「RubyのバージョンアップでRangeを使った処理の挙動が変わってしまうという事態」についてですが、以下のようなコードでした。

(time_or_nil..).cover?(time)

Timeオブジェクトの値を上記のような終端なしRangeと===で比較し、含まれていればtrueを返すような処理でした。Rangeはnil..のような形を取る場合があり、この場合必ずfalseを返していたのですが、ここが2.7に上げたタイミングで必ずtrueを返すようになっていました。もしかすると同じような事態に遭遇する方もいらっしゃるかもしれませんのでご注意ください。

プライベートならまだ大丈夫ですが、仕事とかだと他人が書いたコードは全てを把握してませんので、こういうバージョンアップで挙動が変わる箇所は特に見落としやすいです。きちんと挙動が変わった時に拾えるよう、日頃からテストはきっちりと書いておきたいですね。

参考資料

るりま


  1. (1..nil).endの値nilを終端と捉えても良いのかどうか。ここでのnilは「終端なし」を表現するので終端はnilではなく「なし」だとも言える。 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3