はじめに
Ruby で (2..0)
のように値の大きい方を先に、小さい方を後に定義した場合に、以下のようにエラーにはならない、かつ範囲内の要素数が 0
になる挙動が不思議に思いました。
(0..2).each { |v| p v } # ↓出力結果 # 0 # 1 # 2 (2..0).each { |v| p v } # 出力結果なし (2..1).size => 0
そのため、Range
の定義、および関連する処理で何を行っているのか、Ruby のソースコードを眺めながら調べてみました。
調査
1. Ruby のリファレンスを見る
まずは、Ruby のリファレンスの Range
クラスのページを全体的に眺めました。
リファレンスを確認していると、overlap?
メソッドの説明で以下のような記述を見つけました。
ここで、Range が空であるとは、
- 始端が終端より大きい
- Range#exclude_end? が true であり、始端と終端が等しい
のいずれかを満たすことをいいます。
https://docs.ruby-lang.org/ja/latest/method/Range/i/overlap=3f.html
つまり、 (2..0)
のような定義をした場合、Range
は空であるものとして取り扱われるようです。
また、(2..0)
は Range.new(2, 0)
と同じ意味になるので、実際のソースコードを読む際には後者の定義に紐づくコードを探すことにしました。
Ruby のコードの読み方を把握する
Ruby のソースコードの構成把握のために、以下に目を通しました(2004年に公開されたもののようですので、あくまで参考程度に)。
https://i.loveruby.net/ja/rhg/book/(とくに第4章クラスとモジュール、第5章ガ-ベージコレクション > オブジェクトの生成)
上によると、Ruby のコードでのクラス・メソッドの定義は以下のようなルールになっているようです。
- クラスの定義を格納している変数は
rb_c#{クラス名}
というルールで統一されている - クラスに含まれるメソッドは
Init_#{クラス名}
メソッド内でrb_define_method(rb_c#{クラス名}, "#{メソッド名}", #{メソッドの実体}, #{引数の数})
の形式で呼び出している new
メソッドはrb_class_new_instance
で定義され、その中でinitialize
が呼び出されている
そのため、以下のように調査方法の方針を立てました。
- (
rb_class_new_instance
を確認し、現在も上記資料の通りに読んで問題なさそうか確認しておく) Init_Range
メソッドを探すrb_define_singleton_method(rb_cRange, "new"
を探し、Range.new
メソッドを探すRange.new
のコードを読む(ここで十分に調査できれば打ち切る)- 調査が不十分な場合、
size
、each
メソッドも合わせて調査する
実際にソースコードを読む
前項で確認した読み方をもとに、GitHub のリポジトリにある Ruby のコードを順に追っていきました。
確認したソースコードは v3.3.5 のものでした。
1. rb_class_new_instance
を確認する
rb_class_new_instance
を探したところ、Object
クラスにそれらしい関数を見つけました。
/* * call-seq: * class.new(args, ...) -> obj * * Calls #allocate to create a new object of <i>class</i>'s class, * then invokes that object's #initialize method, passing it * <i>args</i>. This is the method that ends up getting called * whenever an object is constructed using <code>.new</code>. * */ VALUE rb_class_new_instance_pass_kw(int argc, const VALUE *argv, VALUE klass) { VALUE obj; obj = rb_class_alloc(klass); rb_obj_call_init_kw(obj, argc, argv, RB_PASS_CALLED_KEYWORDS); return obj; }
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/object.c#L2123-L2161
rb_class_new_instance_pass_kw
の記述が前述の資料と一致していることと、メソッド名に new
が指定されていること、コメントの内容から、new
メソッドでrb_class_new_instance_pass_kw
が呼び出されており、rb_class_new_instance_pass_kw
の処理中で対象クラス(今回の場合は Range
)の initialize
メソッドが呼び出されているようです。
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/object.c#L4487
2. Range
クラスの initialize
メソッドを確認する
まず、 initialize
メソッドが定義されていると思われる Init_Range
を探し、紐づいているメソッドの実体を探しました。
記事の通り、 Init_Range
内に rb_define_method
を使って initialize
メソッドとその実体 range_initialize
が紐づけられており、 range_initialize
を探していきました。
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L2641
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L85-L109
/* * call-seq: * Range.new(begin, end, exclude_end = false) -> new_range * * Returns a new range based on the given objects +begin+ and +end+. * Optional argument +exclude_end+ determines whether object +end+ * is included as the last object in the range: * * Range.new(2, 5).to_a # => [2, 3, 4, 5] * Range.new(2, 5, true).to_a # => [2, 3, 4] * Range.new('a', 'd').to_a # => ["a", "b", "c", "d"] * Range.new('a', 'd', true).to_a # => ["a", "b", "c"] * */ static VALUE range_initialize(int argc, VALUE *argv, VALUE range) { VALUE beg, end, flags; rb_scan_args(argc, argv, "21", &beg, &end, &flags); range_modify(range); range_init(range, beg, end, RBOOL(RTEST(flags))); return Qnil; }
関数名と引数を見ると、おそらく rb_scan_args
は引数として渡ってきた begun
、end
、 exclude_end
に該当する値を beg
、end
、flags
に格納していると推測しました。また、処理全体からの推測ですが、 Range
のインスタンス本体は引数 range
に格納されていそうです。
range_modify
は freeze
しているかの確認とエラー処理のみのようなので、読み飛ばしました。
range_init
を確認しました。最初の if
文で開始値・終了値の判定をしているが、中身の処理を確認する限りエラー処理のみに関係しているようなので、エラーが出ていない今回は読み飛ばしました。その後、構造体に各引数をセットして最後に freeze
しているよう(※)なので、 Range.new
で実際に範囲を指定して定義する処理は、range_init
の内容がほとんどと推測しました。
(※)破壊的な変更の項目に一度生成した Range
は freeze
することが記載されています。
https://docs.ruby-lang.org/ja/latest/class/Range.html
static void range_init(VALUE range, VALUE beg, VALUE end, VALUE exclude_end) { if ((!FIXNUM_P(beg) || !FIXNUM_P(end)) && !NIL_P(beg) && !NIL_P(end)) { VALUE v; v = rb_funcall(beg, id_cmp, 1, end); if (NIL_P(v)) rb_raise(rb_eArgError, "bad value for range"); } RANGE_SET_EXCL(range, exclude_end); RANGE_SET_BEG(range, beg); RANGE_SET_END(range, end); if (CLASS_OF(range) == rb_cRange) { rb_obj_freeze(range); } }
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L46-L64
range_initialize
に戻ると、range_init
のあとに return Qnil;
で値を返却しているようですが、 range
の構造を変更するものではないと推測して深追いはしませんでした。
ここまで中身を見る限り、この時点ではとくに Range
の定義で開始値と終了値の大小に関わる制限は見られなかったため、引き続き処理を確認することにしました。
3. Range
クラスの size
メソッドを確認する
まず、 Init_Range
から size
メソッドに紐づく定義を確認しました。size
メソッドは range_size
に紐づいているようです。
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L2659
/* * call-seq: * size -> non_negative_integer or Infinity or nil * * Returns the count of elements in +self+ * if both begin and end values are numeric; * otherwise, returns +nil+: * * (1..4).size # => 4 * (1...4).size # => 3 * (1..).size # => Infinity * ('a'..'z').size #=> nil * * Related: Range#count. */ static VALUE range_size(VALUE range) { VALUE b = RANGE_BEG(range), e = RANGE_END(range); if (rb_obj_is_kind_of(b, rb_cNumeric)) { if (rb_obj_is_kind_of(e, rb_cNumeric)) { return ruby_num_interval_step_size(b, e, INT2FIX(1), EXCL(range)); } if (NIL_P(e)) { return DBL2NUM(HUGE_VAL); } } else if (NIL_P(b)) { if (rb_obj_is_kind_of(e, rb_cNumeric)) { return DBL2NUM(HUGE_VAL); } } return Qnil; }
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L819-L854
コードの外観としては range_init
で格納した beg
と end
をそれぞれ b
、e
に格納して、その型によって処理・返り値を振り分けている、といった感じのようです。
今回は (2..0)
のような整数値で定義された範囲を想定するため、b
、e
の両方が数値の判定になり、ruby_num_interval_step_size(b, e, INT2FIX(1), EXCL(range))
が実行されると考えられます。この関数は numeric.c
(include された numeric.h
経由で呼び出されたものと推測)に定義されているようなので、該当の関数の処理を確認します。
VALUE ruby_num_interval_step_size(VALUE from, VALUE to, VALUE step, int excl) { if (FIXNUM_P(from) && FIXNUM_P(to) && FIXNUM_P(step)) { long delta, diff; diff = FIX2LONG(step); if (diff == 0) { return DBL2NUM(HUGE_VAL); } delta = FIX2LONG(to) - FIX2LONG(from); if (diff < 0) { diff = -diff; delta = -delta; } if (excl) { delta--; } if (delta < 0) { return INT2FIX(0); } return ULONG2NUM(delta / diff + 1UL); } // 以下略 }
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/numeric.c#L2797-L2841
最初の if
文では開始値、終了値、ステップがすべて Fixnum
であるかを確認しています。
Fixnum
は v3.2.0 で廃止されているようですが、 Integer
クラスに統合されているようなので、Ruby 側から見ると Integer
であるかを確認しているものと推測します。
https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/
<https://bugs.ruby-lang.org/issues/12005>
そのため、おそらくここでの処理は true
になると推測して中の処理を引き続き確認しました。
次に step
を格納している diff
の値での条件分岐がされていますが、今回は 1
が指定されているため処理はスキップしました。
excl
の判定も今回は末尾の値を含むため、 0
が渡されていると推測し、スキップしました。
最後に開始値と終了値の差分を見ている delta
の判定ですが、(2..0)
の場合、 from
は 2
、 to
は 0
です。そのため、 delta = FIX2LONG(to) - FIX2LONG(from) < 0
となり、最後の判定で 0
が返却されることになります。
以上より、(2..0).size
が 0
を返却する処理は、この関数の処理によるもののようです。
4. each
メソッドも確認してみる
size
と同様に each
メソッドも確認してみました。
Init_Range
内を確認すると、 range_each
関数で処理を定義しているようです。
static VALUE range_each(VALUE range) { VALUE beg, end; long i; RETURN_SIZED_ENUMERATOR(range, 0, 0, range_enum_size); beg = RANGE_BEG(range); end = RANGE_END(range); if (FIXNUM_P(beg) && NIL_P(end)) { range_each_fixnum_endless(beg); } else if (FIXNUM_P(beg) && FIXNUM_P(end)) { /* fixnums are special */ return range_each_fixnum_loop(beg, end, range); } // 以下略 }
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L916-L1025
コードの外観としてはsize
と同じように range_init
で格納した beg
と end
をそれぞれ取得して、その型によって処理・返り値を振り分けている、といった感じのようです。
今回は 2つ目の分岐 FIXNUM_P(beg) && FIXNUM_P(end)
ではじめて true
になると想定されるため、 range_each_fixnum_loop
の処理を確認します。
static VALUE range_each_fixnum_loop(VALUE beg, VALUE end, VALUE range) { long lim = FIX2LONG(end) + !EXCL(range); for (long i = FIX2LONG(beg); i < lim; i++) { rb_yield(LONG2FIX(i)); } return range; }
https://github.com/ruby/ruby/blob/ef084cc8f4958c1b6e4ead99136631bef6d8ddba/range.c#L906-L914
中身はシンプルでループの終了値を Range
の終了値と末尾を含むかから計算して、開始値からループの終了値までの値を、ループごとに加算しながら順に返却していく、といった処理のようです。そのため開始値がすでに終了値より大きいため、ループ処理に入らず処理が終了している、というのが、 (2..0).each{ |v| p v }
で値が出てこない直接的な原因のようです。
まとめ
(2..0)
のように、大きい値を先に、小さい値を後に入れて Range
を定義した場合に、 size
が 0
になったり、 each
で値が返ってこない事象に対し、Ruby のコードを見ながら何が行われているか確認をしました。
一部のメソッドの挙動しか見ませんでしたが、なんとなく上記のケースを意図的に想定しないように作っていそう(実際reverse_each
メソッドなども用意されているあたりも合わせて)推測したりできた気がしました。
2024/10/9 追記
TechRacho さんで以下のような翻訳記事が公開されていました。
記事の中で、今回取り上げた (2..0)
のような逆順の Range の定義が許容されていることについて、array[2..-1]
のような Range
を用いたスライスをできるようにするためではないかとの話が出ていました。
techracho.bpsinc.jp docs.ruby-lang.org
Array
のスライスでの Range
の利用における挙動も調べてみるとおもしろいかもしれません。