~fvknk/bk

技術関連の備忘録

(2..0).size・(2..0).each を読み解く

はじめに

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 が呼び出されている

そのため、以下のように調査方法の方針を立てました。

  1. rb_class_new_instance を確認し、現在も上記資料の通りに読んで問題なさそうか確認しておく)
  2. Init_Range メソッドを探す
  3. rb_define_singleton_method(rb_cRange, "new" を探し、Range.new メソッドを探す
  4. Range.new のコードを読む(ここで十分に調査できれば打ち切る)
  5. 調査が不十分な場合、sizeeach メソッドも合わせて調査する

実際にソースコードを読む

前項で確認した読み方をもとに、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 は引数として渡ってきた begunendexclude_end に該当する値を begendflags に格納していると推測しました。また、処理全体からの推測ですが、 Range のインスタンス本体は引数 range に格納されていそうです。

range_modifyfreeze しているかの確認とエラー処理のみのようなので、読み飛ばしました。

range_init を確認しました。最初の if 文で開始値・終了値の判定をしているが、中身の処理を確認する限りエラー処理のみに関係しているようなので、エラーが出ていない今回は読み飛ばしました。その後、構造体に各引数をセットして最後に freeze しているよう(※)なので、 Range.new で実際に範囲を指定して定義する処理は、range_init の内容がほとんどと推測しました。

(※)破壊的な変更の項目に一度生成した Rangefreeze することが記載されています。

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 で格納した begend をそれぞれ be に格納して、その型によって処理・返り値を振り分けている、といった感じのようです。

今回は (2..0) のような整数値で定義された範囲を想定するため、be の両方が数値の判定になり、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) の場合、 from2to0 です。そのため、 delta = FIX2LONG(to) - FIX2LONG(from) < 0 となり、最後の判定で 0 が返却されることになります。

以上より、(2..0).size0 を返却する処理は、この関数の処理によるもののようです。

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 で格納した begend をそれぞれ取得して、その型によって処理・返り値を振り分けている、といった感じのようです。

今回は 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 を定義した場合に、 size0 になったり、 each で値が返ってこない事象に対し、Ruby のコードを見ながら何が行われているか確認をしました。

一部のメソッドの挙動しか見ませんでしたが、なんとなく上記のケースを意図的に想定しないように作っていそう(実際reverse_each メソッドなども用意されているあたりも合わせて)推測したりできた気がしました。

2024/10/9 追記

TechRacho さんで以下のような翻訳記事が公開されていました。

techracho.bpsinc.jp

記事の中で、今回取り上げた (2..0) のような逆順の Range の定義が許容されていることについて、array[2..-1] のような Range を用いたスライスをできるようにするためではないかとの話が出ていました。

techracho.bpsinc.jp docs.ruby-lang.org

Array のスライスでの Range の利用における挙動も調べてみるとおもしろいかもしれません。