【Ruby】Range#include?とRange#cover?の違い
Range#include? と Range#cover?の違い
前提
本記事の内容は'MRI'いわゆる'CRuby'を対象としています。
また、Rubyのバージョンは3.2です。
概要
Range#include? と Range#cover? は以下のように、どちらも引数が指定した範囲に含まれるかどうかを判定する時に使われています。
(1.1..2.3).include?(1.1) # => true
(1.1..2.3).cover?(1.1) # => true
しかし、その違いがよく分からなかったため、本記事ではその違いについて説明します。
本記事では、公式ドキュメントへの理解を深め、最終的にはCRubyのコードについても考察していきたいと思います。
結論
公式ドキュメントでは書かれていなかったことで、自分で調べたことを補足していきます。
Range#include?
公式ドキュメント
Range#include? (Ruby 3.2 リファレンスマニュアル)
obj が範囲内に含まれている時に true を返します。そうでない場合は、false を返します。
<=> メソッドによる演算により範囲内かどうかを判定するには Range#cover? を使用してください。
始端・終端・引数が数値であれば、 Range#cover? と同様の動きをします。
上記に対しての補足は、以下のとおりです。
2文字以上の文字範囲を比較する時、Range#include?はRange#eachを使用して、引数とRangeの各要素を==
で比較することで、範囲内に要素が存在するかどうか判定します。
範囲の始点と終点が1文字の時で、引数が1文字なら始点および終点と引数を比較します。引数が1文字でないなら、問答無用でfalseになります。
また、始端・終端・引数が数値であれば、C言語では、cover?
の処理の関数r_cover_p
を呼び出しています。
ruby/range.c at v3_2_1 · ruby/ruby
Range#cover?
公式ドキュメント
Range#cover? (Ruby 3.2 リファレンスマニュアル)
obj が範囲内に含まれている時に true を返します。
Range#include? と異なり <=> メソッドによる演算により範囲内かどうかを判定します。 Range#include? は原則として離散値を扱い、 Range#cover? は連続値を扱います。(数値については、例外として Range#include? も連続的に扱います。)
上記に対しての補足は、以下のとおりです。
引数とRangeの始点および終点を<=>
で比較して、範囲内にあるかを判定します。そのため、Range#include?よりも処理が早いです。
詳細
以降は、先の結論に至った理由を公式ドキュメント元に説明していきます。
Range#include? と Range#cover?の文字列比較について
Range#cover? (Ruby 3.2 リファレンスマニュアル)では以下のような文字列に対する、両者の比較があります。
('b'..'d').include?('ba') # => false
('b'..'d').cover?('ba') # => true
以下、この違いについて考察します。
文字列比較について
最初に文字列の比較方法について考察します。複数文字の文字列の比較に関しては以下の記事を参考にしました。
文字列の大小比較をもう少し詳しく調べてみる(チェリー本の補足として) - Qiita
こちらでは、文字比較を各バイトの文字コード(半角英数字ではASCIIコード)に対して、一要素ずつ比較していることが分かります。そのため、以下において'b'と'd'のバイトが比較され大小が決められています。
'ba' <=> 'd' # => -1
'ba'.bytes # => [98, 97]
'd'.bytes #=> [100]
'b'.bytes[0] <=> 'd'.bytes[0] # => -1
実際、ruby/string.c at v3_2_1 · ruby/rubyでは、同じ文字列の長さまではmemcmp
で文字列を比較しています。また、同じ文字列が続いて長さが違う場合、長さの少ない方が小さいと判定されます。
rb_str_cmp(VALUE str1, VALUE str2)
{
long len1, len2;
const char *ptr1, *ptr2;
int retval;
if (str1 == str2) return 0;
RSTRING_GETMEM(str1, ptr1, len1);
RSTRING_GETMEM(str2, ptr2, len2);
if (ptr1 == ptr2 || (retval = memcmp(ptr1, ptr2, lesser(len1, len2))) == 0) {
if (len1 == len2) {
/*略*/
return 0;
}
if (len1 > len2) return 1;
return -1;
}
if (retval > 0) return 1;
return -1;
}
文字列比較におけるRange#include?とRange#cover?の動き
次に、二つのメソッドの内部の動きを見てみます。
内部の演算がわかりやすいように、以下のようにメソッドをオーバーライドします。
module Foo
def <=>(other)
puts "#{self} <=> #{other}"
super
end
def ==(other)
puts "#{self} == #{other}"
super
end
def ===(other)
puts "#{self} === #{other}"
super
end
def equal?(other)
puts "#{self} equal? #{other}"
super
end
def eql?(other)
puts "#{self} eql? #{other}"
super
end
def hash
puts "hash #{self}"
super
end
def each(*)
puts "#{self} each"
super
end
end
String.prepend(Foo)
# Range.prepend(Foo) => このケースでは、この行が無くても結果が変わらない
puts "include?('ba')?"
puts ('b'..'d').include?('ba')
puts "\ncover?('ba')"
puts ('b'..'d').cover?('ba')
この結果が、以下のようになります。
include?('ba')?
false
cover?('ba')
b <=> ba
ba <=> d
true
これで、少なくともcover?
が境界としか比較していないことが分かります。
一方、include?
は、これだとどういう動作をしているか分からないため、Cコードの方を見てみます。
ruby/string.c at v3_2_1 · ruby/ruby
if (RSTRING_LEN(beg) == 1 && RSTRING_LEN(end) == 1) {
if (RSTRING_LEN(val) == 0 || RSTRING_LEN(val) > 1)
return Qfalse;
else {
char b = *bp;
char e = *ep;
char v = *vp;
if (ISASCII(b) && ISASCII(e) && ISASCII(v)) {
if (b <= v && v < e) return Qtrue;
return RBOOL(!RTEST(exclusive) && v == e);
}
}
}
上記の部分で分かる通り、対象範囲が1文字で引数が1文字でない時は問答無用でfalseになるみたいです。また、引数が1文字の時は、境界との比較をしています。
範囲が2文字以上の場合は、次章で説明します。
Range#include? と Range#cover?の日付の比較について
Range#cover? (Ruby 3.2 リファレンスマニュアル)では以下のような日付に対する、両者の比較があります。
require 'date'
(Date.today - 365 .. Date.today + 365).include?(DateTime.now) #=> false
(Date.today - 365 .. Date.today + 365).cover?(DateTime.now) #=> true
以下、この違いについて考察します。
日付の比較
そもそも、DateとDateTimeの比較は以下のようになります。
require 'date'
d = Date.new(2023, 4, 11)
puts d #=> #<Date: 2023-04-11 ((2460046j,0s,0n),+0s,2299161j)>
dt_00_00 = DateTime.new(2023, 4, 11, 0, 0)
puts dt_00_00 #=> #<DateTime: 2023-04-11T00:00:00+00:00 ((2460046j,0s,0n),+0s,2299161j)>
dt_00_01 = DateTime.new(2023, 4, 11, 0, 1)
puts dt_00_01 #=> #<DateTime: 2023-04-11T00:01:00+00:00 ((2460046j,60s,0n),+0s,2299161j)>
# 両者は等しい
puts d <=> dt_00_00 #=> 0
# dはdt_00_01より小さい
puts d <=> dt_00_01 #=> -1
このことから、以下のcover?
は日付の範囲に対して、0時00分で比較していると予想できます。
(Date.today - 365 .. Date.today + 365).cover?(DateTime.now) #=> true
日付の比較におけるRange#include?とRange#cover?の動き
次に、二つのメソッドの内部の動きを見てみます。
簡単のために、日付の範囲は(Date.today - 1 .. Date.today + 1)
とします。
こちらも内部の演算がわかりやすいように、以下のようにメソッドをオーバーライドします。
require 'date'
module Foo
def <=>(other)
puts "#{self} <=> #{other}"
super
end
def ==(other)
puts "#{self} == #{other}"
super
end
def ===(other)
puts "#{self} === #{other}"
super
end
def equal?(other)
puts "#{self} equal? #{other}"
super
end
def eql?(other)
puts "#{self} eql? #{other}"
super
end
def hash
puts "hash #{self}"
super
end
def each(*)
puts "#{self} each"
super
end
end
Date.prepend(Foo)
Range.prepend(Foo)
puts "(Date.today - 1 .. Date.today + 1).include?(DateTime.now)"
puts (Date.today - 1 .. Date.today + 1).include?(DateTime.now)
puts "---"
puts "(Date.today - 1 .. Date.today + 1).cover?(DateTime.now)"
puts (Date.today - 1 .. Date.today + 1).cover?(DateTime.now)
この実行結果は以下となります。
(Date.today - 1 .. Date.today + 1).include?(DateTime.now)
2023-04-23 <=> 2023-04-25
2023-04-23..2023-04-25 each
2023-04-23 <=> 2023-04-25
2023-04-23 == 2023-04-24T19:10:22+09:00
2023-04-23 <=> 2023-04-24T19:10:22+09:00
2023-04-24 <=> 2023-04-25
2023-04-24 == 2023-04-24T19:10:22+09:00
2023-04-24 <=> 2023-04-24T19:10:22+09:00
2023-04-25 <=> 2023-04-25
2023-04-25 == 2023-04-24T19:10:22+09:00
2023-04-25 <=> 2023-04-24T19:10:22+09:00
false
---
(Date.today - 1 .. Date.today + 1).cover?(DateTime.now)
2023-04-23 <=> 2023-04-25
2023-04-23 <=> 2023-04-24T19:10:22+09:00
2023-04-24T19:10:22+09:00 <=> 2023-04-25
true
まず、cover?
の方から見ていくと、こちらは文字列の時と同じく境界と比較していることが分かります。
一方で、include?
は、eachにより範囲内の日付それぞれと'2023-04-24T19:10:22+09:00'を比較していることが分かります。
ちなみに、
2023-04-23 <=> 2023-04-25
2023-04-23..2023-04-25 each
2023-04-23 <=> 2023-04-25
でeachの前後で同じものを比較しているのは、範囲オブジェクトを初期化するタイミングで ruby/range.c at v3_2_1 · ruby/ruby の部分で呼ばれています。
この処理は範囲オブジェクトとして初期化可能かを判定しています。
たとえばDate.today + 1 .. '日付でない文字'
のような範囲オブジェクトを作ると、Date.today + 1 <=> '日付でない文字'
(Cではrb_funcall(beg, id_cmp, 1, end))で比較不能のnilが返ります。
上記のオーバーライドのRange.prepend(Foo)
をコメントアウトしても、実行結果に出てくるため、Dateクラスの方に原因がありそうです。一応、Cコードを見てもよくわかりませんでした。
詳しい方がいらっしゃいましたら、教えて頂けると幸いです(そもそもDateクラスのCコードがどこにあるか分かっていません...)。
ruby/string.c at v3_2_1 · ruby/ruby
Range#include? と Range#cover? のベンチマーク結果
最後に、両メソッドの実行速度を比較してみます。
以下のようにベンチマークのコードを用意します。
require 'benchmark'
time = Benchmark.realtime do
('ab'..'ad').include?('ac')
end
puts "('ab'..'ad').include?('ac'): #{time}s"
time = Benchmark.realtime do
('ab'..'ad').cover?('ac')
end
puts "('ab'..'ad').cover?('ac'): #{time}s"
実行結果は以下のとおりです。
('ab'..'ad').include?('ac'): 8.00006091594696e-06s
('ab'..'ad').cover?('ac'): 1.00000761449337e-06s
上記より、確かにinclude?
の方が遅いと思われます。この原因は前節でも述べたように、each
で各要素を比較していることにあると思われます。
ちなみに数値に対して、比較すると以下のようになります。
time = Benchmark.realtime do
(1..3).include?(2)
end
puts "(1..3).include?(2): #{time}s"
time = Benchmark.realtime do
(1..3).cover?(2)
end
puts "(1..3).cover?(2): #{time}s"
以下が実行結果です。
(1..3).include?(2): 1.00000761449337e-06s
(1..3).cover?(2): 1.00000761449337e-06s
数値の場合は、include?
もC言語上ではcover?
と同じ関数を呼びに行っているので、実行時間も同じになります(たまにズレるので参考値です)。
まとめ
結論は最初に挙げた通りです。
改めてざっくりまとめると、
-
include?
: 範囲と引数が数値ならば、cover?
と同じ動作をする。数値以外なら、メソッドの内部としてはStringとして扱われ、範囲が1文字の文字からなるなら、始点と終点との比較を行う。そうでないなら、各要素に対し、each
(Cコード中ではwhile
内での実行)を行い、範囲内の各要素と==
で一致するか判定する。 -
cover?
: 始点と終点に対して、引数を<=>
で比較する。include?
より処理が早い。
となります。
感想
まだ、Rubyを習い始めてから1ヶ月ですが、裏のC言語を読むと少し理解が深まる気がしました。
やはり、公式ドキュメントとRuby自体のコードをしっかり読むのは大事だと感じたテーマだったと思います。
分からなかったこと
文字列の比較に関して
本題とはずれますが、(b..d).include?(d)
の動作に関して、疑問点があります。
「文字列比較におけるRange#include?とRange#cover?の動き」の節において、
ruby/string.c at v3_2_1 · ruby/ruby
if (RSTRING_LEN(beg) == 1 && RSTRING_LEN(end) == 1) {
if (RSTRING_LEN(val) == 0 || RSTRING_LEN(val) > 1)
return Qfalse;
else {
char b = *bp;
char e = *ep;
char v = *vp;
if (ISASCII(b) && ISASCII(e) && ISASCII(v)) {
if (b <= v && v < e) return Qtrue;
return RBOOL(!RTEST(exclusive) && v == e);
}
}
}
を引用しました。ここから、(b..d).include?(d)
のような1文字同士の比較の場合、引数を境界との文字のバイトで比較しているように見えます。実際、メソッドをオーバーライドすると境界としか比較をしていませんでした。
しかし、ここで述べた処理の下にあるrb_str_upto_each
関数内でも以下の処理をしています。
ruby/string.c at v3_2_1 · ruby/ruby
/* single character */
if (RSTRING_LEN(beg) == 1 && RSTRING_LEN(end) == 1 && ascii) {
char c = RSTRING_PTR(beg)[0];
char e = RSTRING_PTR(end)[0];
if (c > e || (excl && c == e)) return beg;
for (;;) {
if ((*each)(rb_enc_str_new(&c, 1, enc), arg)) break;
if (!excl && c == e) break;
c++;
if (excl && c == e) break;
}
return beg;
}
コメントだけ読むと、ここでも1文字の比較をしています。
こちらの処理ではfor文を回しており、各要素と引数を比較してそうですが、eachのコールバック関数がよく分からないため、具体的に何をやっているのか分かっていません。
もし、上記の処理(特にeachのコールバック関数の動作)に関して詳しい方がおりましたら、コメント頂きたいです。
また、なぜinclude?
において1文字の比較に対して二つ処理があるのかも、合わせて教えていただきたいです。
日付の比較に関して
前述の通り、以下で<=>
が二度出てくる理由が分かりませんでした。もし、分かる方がいらっしゃいましたら、こちらも合わせて教えて頂けると幸いです。
2023-04-23 <=> 2023-04-25
2023-04-23..2023-04-25 each
2023-04-23 <=> 2023-04-25
よろしくお願い致します。
謝辞
ここまでの内容は、自分が所属しているプログラミングスクールであるFjord Boot Campにおいて質問した回答が元となっています。
そのため、以下のお二方の回答を参考にしております。
本当にありがとうございました!!
Discussion
@kochi さん、こんにちは。
疑問点に関してわかる範囲でお答えします。
ただしC言語は強くないので、「たぶんこういうことなのでは」という推測で話します。
間違ってたらごめんなさい🙏
rb_funcall(beg, id_cmp, 1, end);
のコードがDate.today - 1 <=> Date.today + 1
に相当する処理なので、Dateクラスの<=>
メソッドが呼ばれて"2023-04-23 <=> 2023-04-25"が画面に出力されたんじゃないかなーと推測します。外部gemになっているのでここですね。
include?やcover?を1回呼び出すだけでは一瞬で終わってしまうので、1000回とか1万回ぐらい繰り返してある程度の実行時間がかかる状況でパフォーマンスを比較するのが定石です。
詳しくは以下の記事を参考にしてみてください。
この処理は
rb_str_upto_each
という関数の処理なので、内部的に'b'.upto('d')
に相当する処理を実行してるんだと思います。変数
c
は手前の流れからアスキー文字のポインタなので、c++
で"b→c→d"と一文字ずつ文字をずらしながらループ処理してるものと思われます。で、uptoメソッドを使う場合、
'b'.upto('d') { ??? }
のようにブロックを組み合わせますが、この???
部分に当たる処理がeach
です。そしてこれはこの関数の引数として渡されます。rb_str_upto_each
を呼び出している行はここです。つまり上で出てきたeachの実態は
include_range_i
という関数です。なので、
(*each)(rb_enc_str_new(&c, 1, enc), arg)
は、イメージとしてinclude_range_i('b', 'd')
、include_range_i('c', 'd')
、include_range_i('d', 'd')
のような呼び出しを繰り返すことになります。include_range_i
はstr
とarg
が異なれば偽を返し、同値であれば真を返します。include_range_i
から真が返ってきた場合、if ((*each)(rb_enc_str_new(&c, 1, enc), arg)) break;
でbreakして、uptoのループ処理はそこで中断します(そしてreturn beg;
で戻り値を返してrb_str_upto_each
関数の処理が終わる)。つまりざっくりまとめると、疑問に思われている部分のコードは、たとえば
('b'..'d').include?('d')
であれば「"b"と"d"が同値か?」「"c"と"d"が同値か?」「"d"と"d"が同値か?」を繰り返して、真になれば終わる処理、という感じだと思います。上で詳しく説明した
rb_str_upto_each
関数の処理ですが、('b'..'d').include?('d')
のように、範囲オブジェクトやinclude?メソッドの引数が全部アスキー文字1文字だった場合は、おそらくこの処理は呼ばれません。なぜなら
rb_str_upto_each
を呼び出す前にこの行でreturnするからです。rb_str_upto_each
関数はinclude?
専用の関数ではなく、upto
メソッドでも呼ばれています(というか、関数名からしてupto
メソッドで呼ばれるのが主目的だと思われる)。なので、
'b'.upto('d') { |s| puts s.upcase }
みたいに「uptoメソッドのレシーバと引数がどちらもアスキー文字1文字」だった場合に、rb_str_upto_each
関数の/* single character */
とコメントされているコードが実行されるんだと思います。よって「なぜinclude?において1文字の比較に対して二つ処理があるのか」という問いに対しては、「アスキー文字1文字の場合、一方はinclude?メソッドで呼ばれ、もう一方はuptoメソッドで呼ばれるのであって、同時に2つが呼び出されるわけではない」というのが回答になります。
コメントありがとうございます!!
自分の疑問に対して、ここまで詳細に答えて頂けるなんて感無量です!
頂いた回答によって、よりRubyのメソッドとC言語に関して詳しくなれました!!
頂いたコメントは、後ほど記事に反映したいと思います。
本当にありがとうございました!!
すみません、以下の点だけよく分からなかったので、再質問させてください。
上記に関して、Cコードを追うだけでは
include?
の中にrb_funcall(beg, id_cmp, 1, end);
が出てこないのですが、どのように繋がっているのでしょうか?Rangeクラスを見に行っているとは思うのですが、Cコードだけ追うと
range_init
でRangeオブジェクトを初期化する場面が出てこないように見えます。一応、eachのコールバック関数の
include_range_i
を見ると更に、
rb_equal
を見るととなり、
result = rb_funcall(obj1, id_eq, 1, obj2);
にて<=>
を行っていることが分かります。自分はこのresult = rb_funcall(obj1, id_eq, 1, obj2);
によって<=>
が表示されていると思ってましたが、違うのでしょうか?(もし、こちらが呼ばれている場合、Range#each
の前に<=>
が出てくるのは説明できません...)もう一度質問をまとめると、
include?
のCコード中で、range_init
内のrb_funcall(beg, id_cmp, 1, end);
をどのように呼びに行っているか教えて頂きたいです。かなり細かい質問となってしまいますが、もしよろしければご回答頂けると幸いです。
よろしくお願い致します!!
以下のコードを実行します。
出力結果はこうなります。
最初の2行の出力で
<=>
が呼ばれるのはrange.cの52行目だと思われます。つまり
range_init
のrb_funcall(beg, id_cmp, 1, end);
は、include?
メソッド経由で呼ばれるのではなく、Rangeクラスのインスタンスを作成したタイミングで呼ばれます。・・・という説明で疑問は解消するでしょうか?
なるほど、そもそも
( .. )
の範囲オブジェクトを作成時にRangeクラスのインスタンスが作成され、同時にrb_range_new
が呼び出され、更にその関数内のrange_init
内のrb_funcall(beg, id_cmp, 1, end);
が実行されるということですね。結果として
( .. )
作成時に<=>
が実行される形ですね。勘違いしていて、ずっと
include?
のメソッド内を追ってました..笑分かりやすいご説明本当にありがとうございます!
先の回答を含め、本当に勉強になりました!!
記事には随時反映していきます😆
ありがとうございました!!!