Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby研究シリーズ: Rangeクラスはどのようにして今の姿になったのか(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。
rangeは原則として英ママとしました。

Ruby研究シリーズ: Rangeクラスはどのようにして今の姿になったのか(翻訳)

コアクラスの設計と利用法を、それが辿った進化の道筋から理解する

数年前に私がRubyの進化に関する研究を始めたときには、次のような確信がありました。すなわち、Ruby言語がどのような道筋を通って進化したのか、Ruby言語にあるさまざまな要素の背後にはどんな意図が込められているのかを理解すれば、Rubyプログラミング言語を習得して自分の考えをコードで明確かつ効果的に表現するスキルは大きく進歩するはずだという確信です。

Ruby言語の要素のChangelogをたどることで、API設計につながる思考や合意のプロセスが明らかになります。そうすれば、チートシートをひたすら丸暗記するような形ではなく、その機能を自分の血と肉にできるようになります。

本記事では、Rubyのコアクラスの1種であるRangeを見ていくことで、そのことを説明したいと思います。

🔗 rangeとは何か

range = (1..5)
range.cover?(3) #=> true
range.each { puts _1 } # Prints 1, 2, 3, 4, 5

rangeは、開始値(beginning)と終了値(end)という2つの境界値(boundaries)によって定義される型であり、データ構造です。

プログラミング言語におけるrangeは、配列(またはリスト)や辞書(またはマップ)ほどではないものの、それでもかなり広く使われています。rangeの主な意味は以下の2つです。

  • 2つの境界値の間に存在する値の離散的なシーケンス(discrete sequence)
  • 2つの境界値の間に存在する値の連続的な空間(continuous space)

rangeの主な用途は以下のとおりです。

  • イテレーション(指定のシーケンスに対するforループ的な反復操作)
  • 値が何らかの区間(interval)内に存在するかどうかをテストする
  • コレクションのスライス

rangeやrange風のオブジェクトを提供するプログラミング言語が、離散的なrangeと連続的なrangeを両方とも提供しているとは限りません。また、上述の3つの用途すべてでrangeを使うとも限りません。

たとえば、Pythonにはイテレーション用のrange型とobj in rangeによるテストがあり、それとは別にコレクションのスライス用にslice型があります(これには開始・終了・ステップを認識する以上の意味はありません)。

C#にあるRangeクラスにあるのは、この「コレクションのスライス」機能だけです

同時に、Rust、Kotlin、Scalaは、上述のすべてのケースでrangeを用います。

Zig言語は、私の知る限りではスライスイテレーションマッチングで用いるrange風の構文がありますが、この構文では値が生成されず、それらの構成の外部では利用できません。

そこで...

Rubyの歴史においてrangeにどんなことが起きたのでしょうか?そして、もうひとつ間接的な疑問が持ち上がります: Rubyのrangeには大きな設計や変更の余地が残されているでしょうか?実はまだあるのです!

Rubyの歴史を振り返ると、いくつかのマイルストーンがあります。

Ruby 3.0 (2020年)
現在のメジャーバージョン。年1回リリースされる3.xは、どれも前のバージョンより大幅に進化している。現在のバージョンは3.3(3.4は2024年12月にリリース予定)。
Ruby 2.0(2013年)
「毎年新しいリリースを行う」運用が導入され、2.7まで続いた。
Ruby 1.9(2007年以降)
2.0に備えた大きな準備ブランチ。1.9.1、1.9.2、1.9.3のいずれも大きな変更が導入された。
Ruby 1.8(2003年以降)
「バッチ」リリースごとに多くの変更が導入された(中でも1.8.7の変更は大きかった)。このバージョンは、Rails(最初のバージョンは2004年にリリースされた)や広く読まれた"Pickaxe本"(Programming Ruby from Pragmatic Programmers, 2nd edition)で初めて大きな注目を集めた。
Ruby 1.6(2000年以降)
おそらく英語圏のプログラマーに知られた最初のバージョン。Pickaxe本の初版はこのバージョンのみを対象にしていたが、最終的にオンラインリファレンスという形でRubyコミュニティに寄贈された。
それ以前のバージョン
これについては詳しいことは言えないものの、1996年のRuby 1.0(最初のpublicリリース)から1999年のRuby 1.4まで非常に活発な開発が行われた。

それはともかく、上述の疑問に立ち返りましょう。

🔗 「この値はrangeに存在するか?」「それは何を意味するのか?」

b(beginning)からe(end)までのrangeがあり、大小を比較可能な値vがある場合、その値がrangeに「属しているかどうか」をどうやってチェックすればよいでしょうか?さらに、この「属している」とは何を意味するのでしょうか?

Rubyの場合、Rangeにはこの問いかけに答えるためのメソッドが2つあります。

  • #include?#member?というエイリアスもある):
    vbeの(離散的な)シーケンスの一部に該当するかどうかをチェックする

  • #cover?:
    vbeの連続的な値空間内に存在するかどうかをチェックする

2つのメソッドの違いを文字列の例で示します。

('a'..'e').include?('b') #=> true (シーケンスの一部に該当する)
('a'..'e').cover?('b') #=> これもtrue

('a'..'e').include?('bat')
#=> false('bat'は'a'〜'e'というシーケンスには含まれない)
('a'..'e').cover?('bat') #=> true

#===(トリプルイコール)という第3の方法もありますが、これは明示的に使われることはほとんどなく、パターンマッチング的な文脈で暗黙で呼び出されます1

case year
when 2000..2005 # `(2000..2005) === year`が暗黙で呼び出される
  # ...
end

# コレクションの項目ごとに`0..18 === item`が暗黙で呼び出され、
# マッチするものが返される
# (なお、マッチしないものを返す`grep_v`というメソッドもある)
collection.grep(0..18)

# コレクションの項目ごとに`0.8..1.0 === item`が暗黙で呼び出され、
# マッチするものが返される
# (なお`all?`や`none?`というメソッドもある)
collection.any?(0.8..1.0)

Ruby 2.6で、私はRubyコアチームを説得して、汎用のrangeで用いる#===の実装では、#include?ではなく#cover?を使うように変更してもらいました(#14575)。これによって、たとえば以下のコードが動くようになったのです。

require 'date'
case DateTime.now
when Date.new(2024, 6, 1)...Date.new(2004, 9, 1)
  puts "まだ夏だ!"
end

日付のrangeは、開始日時から終了日時までの(離散的な)シーケンスのどれかにincludeされるかどうかで考えるのではなく、2つの日時の(連続的な)期間をcoverするかどうかで考えるべきなのは明らかです。私がこの変更を行った理由の1つは、数値で常にincludeが使われると以下のような驚くべき不整合が発生してしまうからです。

(1..5) === 2.3             #=> true
('1.8'..'1.9') === '1.8.7' #=> false(比較すればたしかに該当しているにもかかわらず)

おそらく、これまでこの不整合はほとんど注目されていなかったのでしょう。rangeが最も広く使われている用途は今でも数値なので(実際、他の多くの言語でもrangeは数値のみを対象としています)、たまたま誰かが他の値で試してみて「どこかおかしな」結果になったときに、「まあそういうものかな」と判断されて終わったのかもしれません2

しかしそれより前にも、case文でTimeが使えたら便利かもしれないと思ってやってみたものの、うまくいかないことに気づいた人たちがいました(そもそも時間というものは離散的な型ではないので、2つの時刻の間に時刻の"シーケンス"が存在するのではありません: つまり、#===#include?を呼び出そうとすれば時刻のシーケンスを生成しようとしてエラーになります)。

そこでRuby 2.3では、「線形」オブジェクトという概念を導入することでこの問題を解決しました(あくまで内部的な概念であり、ユーザーのコードからはわかりません)。この線形オブジェクトは、実数および(Ruby標準ライブラリであるDateDateTimeではなく)RubyコアのTimeクラスとなる形でrangeにハードコードされました3#include?は、このような「線形」オブジェクトでまるで#cover?のように振る舞いました(rangeの開始と終了で比較した場合)。

しかし、この不整合がRubyに常に存在しているわけではありません。

それよりずっと前、Ruby 1.9.1で導入された#cover?メソッドの意図は、おそらく「rangeにある2つの境界値の間に存在しうる要素」という概念をメソッド名で明確に示すことだったのでしょう。同じRuby 1.9.1では、(数値でない値が「シーケンスに含まれる(include)かどうか」を示すために)#include?の実装も変更されたのに、#===の実装では相変わらず(#coverではなく)#include?が使われていたのです!

その理由は、それより前のRuby 1.8#include?が導入されたことで、以下の2つのメソッドが存在していたからです。

  • #member?: シーケンスに含まれるかどうかをチェックする
  • #include?自身: ある値がrangeの2つの境界の間に存在するかどうかをチェックする(#===はこのメソッドを経由して動作します)

ここで興味深い点は、Rubyのgit履歴にも#member?#include?の振る舞いに関する疑念が示されていることです。

  • Ruby 1.8.0で#member?#include?が導入されたとき、「#member?は、対象が数値であってもシーケンスに含まれるかどうかをチェック」し、「#include?は値が(2つの境界値の間を)カバーしているかどうかをチェック」するという点では一貫していました。

  • 直後のRuby 1.8.2では、どちらのメソッドも同じ実装(カバーのみをチェック)に変更されました。

  • さらにRuby 1.9.1では、#member?#include?はどちらも「数値のカバー」や「ASCIIのみの文字列のカバー」をチェックするようになり、それ以外の場合は汎用的な#include?コレクションに委譲する(つまりシーケンスに含まれるかどうかをチェックする)という形で洗練されました。

  • このようにして、Ruby 2.6がリリースされるまで私たちが経験した状況にゆっくりと移行していったのです。

しかし話を#===メソッドと「カバー」の問題に戻しましょう。
以下のコードの振る舞いは、Ruby 1.9.1以前と、Ruby 2.6/2.7以後で同じでした。

("1.8".."1.9") === '1.8.7' #=> true
['1.6.1', '1.8.1', '1.8.7', '1.9.1'].grep("1.8".."1.9")
#=> ['1.8.1', '1.8.7']

しかし、その間のバージョンでは奇妙な不整合が発生していました。Ruby 1.8.0〜1.8.2という短い期間にのみ発生し、以後二度と発生しなかった問題なのですが、#member?が以下のように、あたかも「その数値がシーケンスに存在するかどうかをチェックしている」かのように振る舞っていたのです。

(1..5).member?(2)   #=> true
(1..5).member?(1.3) #=> Ruby 1.8.0〜1.8.2でのみfalse、以後はtrue

しかし「この数値は指定の整数値シーケンスの一部であるかどうか」をチェックするのはあまりにも難解なので、これを何らかのメソッドでサポートするのは無理でしょう。

最終的に、Ruby 1.8より前(および極めて初期のバージョンのRuby)に存在していたのはRange#===メソッドだけだったのです(このRange#===は、case的な文脈で暗黙で使われることもあれば、明示的に値のチェックで使われることもありました)。そして、そのときの振る舞いは現代の#cover?メソッド「そのもの」だったのです。

他の言語ではどうやっているか

RustとKotlinには、Rubyのcover?のように振る舞うメソッドが1つだけあります(RustのcontainsとKotlinのcontains)。

PythonとScalaでは整数値のrangeしか利用できないので、必然的にrangeには整数値だけが含まれることになります。

Rust、Kotlin、Zigでは、(range風だが値を生成しない構文を用いて)rangeをcase風の構造で利用できますが、PythonやScalaではできません。

というわけで、rangeのインクルージョンにまつわる激動の物語は以上です。しかしお話はこれだけではありません!

🔗 ウクライナ通信🇺🇦

ほんの少しお時間をください。私が生活しているウクライナが現在も侵略を受けていることを思い出していただくため、記事の途中にはさむことにしています。どうかお読みください。

とあるニュース記事: 7/24にロシアの弾道ミサイルが私の地元であるハルキウ市を攻撃し、人道的地雷除去基金の事務所と車両が破壊されました(ロシア側はただちに「外人傭兵の巣窟」だったと主張しました)。

とある背景情報: ロシアによるミサイル・ドローン・爆弾の97%が民間のインフラストラクチャを攻撃しており、民間や商用の建築物・ホテル・学校・教会・病院および多数のインフラ施設を破壊しています。一方、軍事目標への攻撃はわずか3%です。

とある募金活動: ウクライナでよく知られている優秀なドローン対策ボランティアによるPayPalの募金活動が行われています。ロシア(およびロシアとイラン)のドローンは私たちの都市や戦線における大きな脅威ですが、それを変えられるかもしれないという新しい展望がこの業界で生まれています。

引き続き記事をどうぞ。

🔗 rangeの境界値にはどんな値が使えるのか?

...話を広げると、「rangeは一般的にどんな値、どんな型をサポート可能なものなのか?」となります。

Rubyの場合、「比較可能な型であれば」任意の型をRangeの境界値として利用できます。ここで言う比較可能とは、begin <=> end01-1のどれかを返すという意味です4

# 有効なrange
(1..5)
('a'..'b')
(Time.parse('13:30')..Time.parse('14:30'))

# 無効なrange(数値と文字列)
(1..'3')
# rangeで使えない値(ArgumentError)

((2 + 3i)..(2 + 4i))
# rangeで使えない値(ArgumentError)
# (複素数同士には線形な大小関係がない)

ただし、rangeで指定する2つの境界値の「順序」は強制されないので、(0..-5)のような逆順のrangeも有効です。その理由の1つは、おそらく以下のような配列のスライスが使われているためと思われます。

ary = [1, 2, 3, 4, 5]
ary[-1] #=> 5(末尾の項目)
ary[2..-1] #=> [3, 4, 5](項目3から末尾の項目)

Ruby 2.6では「endレス」による無限を含むrange構文が導入されました。

r = (1..)(1〜無限大)
r.end #=> nil

# (明示的にnilを渡してもよい)
r == (1..nil) #=> true

この構文は当初、「末尾の項目まで」の配列スライスを表す、小さなシンタックスシュガーを意図していたに過ぎませんでした。
つまりary[2..]と書ける方が、数学的にぎこちないary[2..-1]や長ったらしいary[2...ary.length]よりもよいと思われたからです。

その時点では、このendレスrange構文と対をなす「beginレス」range構文というアイデアに対しては、「これは基本的に配列のスライスで用いるものだから」という反論が当初からありました。しかし私は、#14799で「beginレス」range構文を導入するために十分説得力のある説明を頑張って見出しました。そのときの議論では「パターンとしての利用」と「DSL内の定数としての利用」を強調しました。

case release_date
when ..1.year.ago
  puts "ancient"
when 1.year.ago..3.months.ago
  puts "old"
when 3.months.ago..Date.today
  puts "recent"
when Date.today..
  puts "upcoming"
end

# 摂氏温度
WORK_RANGES = {
  ..-10 => :off,
  -10..0 => :energy_saving,
  0..20 => :main,
  20..35 => :cooling,
  35.. => :off
}

これは、私が#14784で提案した、値に制約をかけるComparable#clampメソッドを用いる方法で強制されます。

# Ruby 2.7より前の#clampの振る舞い:
# 境界値を2つ指定する必要があった
-2.clamp(0, 100) #=> 0
20.clamp(0, 100) #=> 20
101.clamp(0, 100) #=> 100

# Ruby 2.7以後の#clampの振る舞い:
# rangも渡せるようになった
-2.clamp(0..100) #=> 0

# これによって、必要に応じてendレスrangeや
# beginレスrangeを渡せるようになった
-2.clamp(0..) #=> 0
10000.clamp(..100) #=> 100

endレスrange構文とbeginレスrange構文が両方とも存在するとなると、「endもbeginもないrangeはありなのか?」という疑問が持ち上がりました。そういう書き方も一応可能ですが、専用の特殊なリテラルはありません。

r = (nil..)
# または
r = (..nil)
# または
r = (nil..nil)

しかし(..)という構文は理論的にはよいのですが、めったに必要が生じないので、そのためにパーサーを複雑にするほどではありません。

「無限がらみのrange」は単なる興味本位としか思えないかもしれませんが、以下のような用途ならコードの一貫性を高めるのに役立つ可能性がありそうです。

  • 何らかの値に条件付きで上限値や下限値を決定するコードを動的に生成する
  • 何らかのDSLで「すべてをキャッチする」デフォルトパターンを利用する

このように新たなrangeが存在するようになると、「そのrangeはカバー(連続)を意味するのか、インクルージョン(離散)を意味するのか」という問題が持ち上がってきます。これについての回答はほぼ自然に得られますが、一部のエッジケースで修正が必要でした。

# これは自然な振る舞い:
('a'..).cover?('b') #=> true(aより後にbがあるので)

 #include?で上述の「線形オブジェクト」を用いれば、数値(およびTime)でも#cover?と同様に振る舞うようになります。

(1..).include?(1.5) #=> true

ただしRuby 3.2 のみ、線形でない(=大小関係を定義できない)オブジェクトのendレスrangeで#include?を行った場合は以下のエラーをraiseするよう修正されました。

('a'..).include?('bat')
# cannot determine inclusion in beginless/endless ranges (TypeError)

それより前のRubyバージョンでは、無期限にハングしてしまいました(シーケンス「全体」のイテレーションを試み、要素がそのシーケンスに存在しなければ停止しません)。

Ruby 3.3ではさらに明確になった点があります。線形オブジェクトを対象とする完全に無限のrangeがtrueを返すようになったのです。

(nil..).include?(1)
# 3.3: => true
# 3.2: cannot determine inclusion in beginless/endless ranges (TypeError)

つまり、開始値や終了値からrangeの型を推測して、数値でもTimeでもない場合と同様に「イテレーションを試みる」デフォルトの振る舞いに切り替えるようになりました。このステートメントで「定義済み」の値が数値だけの場合は、数値の(線形)空間にいることになります(この空間内では開始と終了の2つのnilが無限であることを表す形になります)。

ちなみに、beginレスrangeリテラルやendレスrangeリテラルが導入される前は、endレスrange的なことをFloat::INFINITY-Float::INFINITYで書くのが普通でしたが、この記法は当然ながら数値にしか通用しませんでした(なお、Date歴史的な理由で「たまたま」比較可能でしたが、Timeはそうではありません5)。

rangeのendの「現代史」については、これでおしまいです。


しかし、それより遥か昔のRuby 1.4(Rails 1.0や書籍『Programming Ruby』すら登場していない頃です)に、「終了値(最大値)を含まないrange」(...記号)が導入されていたのです。

(1..5).cover?(5)  #=> true(このrangeには5が含まれる)
(1...5).cover?(5) #=> false(このrangeには5が含まれない)

他のプログラミング言語では「終了値を含まないrange」の方が基本的な形式として古くから存在しているのですが、Rubyはそれらと異なり、当初は1番目の..による「終了値を含むrange」しか存在していなかったのです。

原注

面白いことに、「開始値を含めないrange」というアイデアの提案を見かけた覚えがありません。私が見落としているでなければ、おそらく良い構文や説得力のあるユースケースを誰も思いつけなかっただけなのかもしれません。他の主流言語にもなさそうです。

Ruby 1.4では、range境界値を表す名前(エイリアス)であるbeginendも導入されていました。この変更は「先史時代の産物」とも言えますが、この発想がその後どのように飛躍したのかは興味をそそります。

境界値の名前は、当初はfirstlastでした。それぞれ beginおよびendと同じ意味は保持されていたものの、混乱することもありました。

r = (1...5)
r.last #=> 5(5はこのシーケンスの末尾要素ではない!)
# (しかもlastで複数の値を取り出したときの結果と整合しない)
r.last(2) #=> [3, 4]

r = (1...1) # 空のrange
r.to_a #=> []
# rangeは空なのに、firstとlastで1が返される
r.first #=> 1
r.last  #=> 1

この不整合は、少なくとも上述の最初のケース(...の終了値が整数)について#8739で修正が試みられましたが、コードの破壊や非互換性があまりに多すぎたため、そのままにされました。

さらに混乱するのは(あるいは混乱を減らしたのは)、新しいbeginレスrangeやendレスrangeでは、beginendと同義の振る舞いは維持されていないことです。

r = (1..)
r.end #=> nil
r.last # cannot get the last element of endless range (RangeError)

他の言語ではどうやっているか

Rustには、「境界値を1つだけ指定する」「endを含める/含めないの指定」(1..3(3を含めない)、1..=3(3を含む)、..31..、そして..すらあります!)のように、Rubyと同じことができるrangeがすべて揃っています。

Kotlin(1..31..<3)やScala(1 to 31 until 3)には、境界値のペアをrangeに含めるか含めないかの指定は可能ですが、境界値を指定しないrange構文や記法はありません。

Pythonのrange(1, 3)は常に境界値を含まないrangeになります。また、境界値を省略することもできません。

rangeについて言えることは以上でおしまいです!しかし、言語の設計空間の旅はまだ終わりません。

🔗 シーケンスのrangeとイテレーション

rangeのイテレーションは非常に多用される書き方の1つです(この種の抽象化を敬遠しがちなGo言語ですら、Go 1.22range 10という記法が導入されました: この変更前のrangeは、「このコレクション内のキーの範囲」を意味するキーワードでした)。

Rubyでは伝統的に#eachメソッドでrangeを実装します。

(1..5).each { |v| puts v } # 出力: 1, 2, 3, 4, 5

RubyのrangeはEnumerableモジュールをincludeしているので、Enumerableのあらゆるイディオムがすぐ利用できるようになっています。

(1...4).to_a #=> [1, 2, 3]
('a'..'d').map(&:upcase) #=> ["A", "B", "C", "D"]
(Date.today..).find(&:monday?) #=> #<Date: 2024-07-29>

実は、#include?のデフォルトの実装もEnumerableが提供しています(「線形の値」に特化していない場合の話ですが)。

rangeをイテレーション可能にするために必要なのは、rangeの開始値に#succメソッド(その値の「次の」値を返す)を実装することだけです。この種の型は、内部的には「離散的」と呼ばれます。
しかし、型の中には「線形ではあるが離散的ではない」ものもありえます(小数など)。

(1.5..2.5).to_a
#=> `each': can't iterate from Float (TypeError)

この場合は、#stepメソッドを使うことでイテレーションの方法をRubyに指定できます。

(1.5..2.5).step(1.5).to_a #=> [1.5, 2.0, 2.5]

私は次のRuby 3.4で、この#stepを数値以外の値でも使えるよう強化しようとしています(そうなればの話です: #18368はMatzの承認を得ていますが、まだマージされていません6)。これは以下のような感じになる予定です。

(Time.now..).step(1.hour).take(3)
#=> [2024-07-24 20:22:12, 2024-07-24 21:22:12, 2024-07-24 22:22:12]

#stepが導入されたのはRuby 1.8のときで、member?include?の話が始まったのと同じ頃でした(初期のRangeよりはずっと後です)。そして#stepには2つの異なる実装があったのです。

  • 数値の場合
    #+で利用できる(次の値は直前の値 + stepで指定した値で生成される)

  • それ以外のすべての型の場合
    整数値のみを受け取り、「#succを指定の回数だけ呼び出す」

('a'..'z').step(2).take(3) #=> ["a", "c", "e"]
(Time.now..).step(1.hour) # can't iterate from Time (TypeError)

この振る舞いはあまり使い道がなさそうに思えますし、数値の場合(私にとって直感的な「デフォルトの振る舞い」を表します)と振る舞いが異なっていますので、この振る舞いが変更されることを願っています。

おそらくお気づきの方もいるかと思いますが、この「数値は特殊である」というモチーフは繰り返し使われています。

別の例として、#reverse_eachの特殊な振る舞いを考えてみましょう(導入されたのはRuby 3.3とごく最近の話です)。逆順イテレーション用のメソッドをデフォルトで提供しているのはEnumerableですが、逆順イテレーションを可能にする一般的な方法は「シーケンス全体を最後までイテレーションし、結果をメモ化してから逆順イテレーションする」方法だけです。

数値の場合は、以下のように数値だけを使う形に特殊化してもよいので、beginレスrangeでも問題なく動きます!

(...5).reverse_each.take(3)
#=> [4, 3, 2]

しかしこの方法は、数値以外の型では不可能です。

「数値は特殊(だった)」の(ある意味愉快な)別の例は、「2番目の方法による#stepメソッド(#succを指定の回数繰り返すだけ)」ではステップ数に0を指定しようとすると必ずraiseしますが、数値の場合は0を指定することが許されていたという点です。

('a'..'c').step(0).take(3) # step can't be 0 (ArgumentError)

(1..5).step(0).take(3) #=> [1, 1, 1]

他のケースと異なり、一般的な振る舞いが最終的に数値の場合に近づいてきたので、Ruby 3.0では「ステップ数0は無意味である」と判断され、以下のように数値の場合でもステップ数0が禁止されました。

(1..5).step(0) # step can't be 0 (ArgumentError)

とは言うものの、変更前の振る舞いに意味のあるエッジケースでは疑問が残りますが。

しかし、この点に関する好奇心を別にすれば、「数値(および一般的な数値のrange)をステップオーバーする」という手法は、引き続き最も強力な構成要素であり続けます。

これを裏付けるかのように、Ruby 2.6で等差数列を実現するEnumerator::ArithmeticSequenceが導入され、等差数列を生成する%演算子が追加されました。

(0..10).step(2) #=> (1..10).step(2)(ArithmeticSequenceクラスのオブジェクト)

# 同じ振る舞いだが、文脈によっては表現力が高まる可能性がある
(0..10) % 2
# 例:
array[(1..10) % 2] #=> 配列内の第2要素

('a'..'z').step(2) #=> 従来通り単なるEnumeratorを返す

このArithmeticSequenceオブジェクトは、通常のenumeratorオブジェクト(変更前にはこれが返されました)と同様にイテレーションできます。ArithmeticSequenceオブジェクトは#stepを単なる属性として公開しており、値の(begin, end, step)セットを他の場所に受け渡すことも、カスタムコレクションのスライスなどで利用することも可能です(この変更はScientific Rubyコミュニティからこの機能をリクエストされたもので、Ruby 3.0で初めて#16812標準の配列にもpushするよう求めました)。

他の言語ではどうやっているか

上述で言及したrangeを持つ言語(Python、Rust、Kotlin、Scala)にはいずれもstepがあります。ただしどれも整数値のステップのみであり、これは整数値以外のrangeも持っている言語(Rust、Kotlin)でも同じです。たとえばKotlinの'a'..'z' step 2は、「そのシーケンス内の個別の第2項目」を意味します。
どの言語も「数値と数値の間の浮動小数点ステップ」用の例外を設けていないので、ステップを「通常とは別の何か」、つまり値空間のカスタムイテレーションであるというアイデアは、ここではあまり自然なものに思えません。

Python、Kotlin、Scalaのstepは、Rangeの「属性(attribute)」になっています(つまりRubyのArithmeticSequenceに似ています)が、RustのRange::step_byは、汎用のIterator::step_byの仕様に過ぎず、したがって配列のスライスには使えません。

🔗 rangeに他の用途はあるか?

上で述べた変更点や疑問点は、主にrangeの設計についてのものですが、完璧を期すため、本記事で触れておく価値のあるその他の改善方法や用途についても2つ述べておきます。

1つ目は「range同士の数学的演算」です。
Ruby 2.6では、Range#cover?に別のrangeも渡せるようにする(かつオペランドがそのrange内にあるかどうかをチェックする)変更が行われましたし、Ruby 3.3では、#overlap?メソッドが追加されました。
この他にもさまざまな「区間演算」が理論的には有用になることは想像がつくでしょう。Rubyコアチームは、いつものように、説得力のあるユースケースや明確なセマンティクス定義が寄せられることを期待しています(なお#16757でrange同士の交差(共通集合)に関する議論が進行中ですが、あまり盛り上がっていません)。

2つ目の興味深いトピックは、「意味的に健全なrangeを他のAPIでも採用する」ことについてです。
既に述べたComparable#clamp以外にも、以下のような注目すべき例があります。

  • Ruby 2.5から、numbers.any?(3..5)のようなチェックが使えるようになりました。
    (rangeには直接関係ありませんが、Enumerable#any?#all?#none?#one?#===パターンマッチングで使えるようになっただけです)

  • Ruby 1.9.3で導入されたrand(begin..end)は、指定のrangeで数値を生成するAPIです。

  • 標準ライブラリのIpAddrのrange形式表示はRuby 1.8で導入されました(rangeのビッグバージョンといった趣です)。

IPAddr.new("192.168.0.0/16").to_range
#=> #<IPAddr: IPv4:192.168.0.0/255.255.0.0>..#<IPAddr: IPv4:192.168.255.255/255.255.0.0>

rangeの進化にまつわる物語は、これでおしまいです。

「言語は生きていて息を吸ったり吐いたりする存在である」という感覚、「決断を下すこともあればそれで失敗することもあり」「自らの振る舞いを明らかにし」「遺産や慣習という重荷を背負いながらなおも前進し続ける」という感覚を皆さんにも味わっていただければと願っています。

そうしたものがもっと他にもあるとよいと思います。


お読みいただきありがとうございます。ウクライナへの軍事および人道支援のための寄付およびロビー活動による支援をお願いいたします。このリンクから、総合的な情報源および寄付を受け付けている国や民間基金への多数のリンクを参照いただけます。

すべてに参加するお時間が取れない場合は、Come Back Aliveへの寄付が常に良い選択となります。

本記事(あるいは過去の私の仕事)が有用だと思えたら、Buy Me A Coffeeサイトにある私のアカウントまでお心づけをお願いします。戦争が終わるまでの間、ここへのお支払いは(可能な場合)私や私の戦友たちが必要とする装備または上述のいずれかの基金に100%充てられます。

関連記事

Ruby研究シリーズ1: メソッド定義構文はどう決定され、どう進化したか(翻訳)

Ruby言語の進化を追いかけて意外な構文機能を発見した話(翻訳)


  1. Ruby 2.7以降のRubyには構造的パターンマッチングが搭載されていますが、本記事ではもっとシンプルでもっと広く使われている機能について述べます(多くのオブジェクトではほとんどの場合#===メソッドを再定義する形で実装されています)。 
  2. この変更が行われた時点では、文字列の振る舞いについては手つかずでした(ので、Ruby 2.6では引き続きfalseが返されました)が、Ruby 2.7では修正されました。このとき古いコードが壊れる可能性について注意が必要でした: つまり、rangeの値が(正しく)連続的にチェックされるようになると、文字列での不整合があたかもバグであるかのように浮かび上がってきたのです。 
  3. Rubyでは、何らかのオブジェクトが何らかの「インターフェイス」に対応しているかどうかをダックタイピング(duck typing)でチェックするのが通例ですが、ダックタイピングはこの「線形」オブジェクトにはまったく通用しません。「その型が持つ2つの値には、いかなる場合であっても順序付けが定義される」のが基本的な定義であり、これは<=>メソッド(-101のいずれかを返す)で表現されますが、実際にはこの<=>メソッドが「線形」オブジェクトにあっても何の役にも立ちません(ほぼあらゆるオブジェクトに<=>メソッドが存在しますが、比較不可能な値に対して使ってもnilを返すだけです)。 
  4. Object#<=>は、rangeやソート、およびそれらと同様の状況で利用される汎用の比較メソッドであり、値の順序が定義されている型ではこの<=>メソッドを再定義します。<=>メソッドは、2つの値の線形順序が未定義の場合(文字列と数値の比較や、複素数同士の比較)は、単にnilを返します。Comparableモジュールは、クラスにincludeすることでこの<=>メソッドを定義できる便利なミックスインであり、それを元に他のすべての比較用メソッド)(==<><=>=)を提供します。提供されるメソッドは適切に振る舞います(たとえば、Comparableで実装された==メソッドは、比較不可能なオブジェクト同士ではfalseを返し、>や <ではArgumentErrorが発生します)。 
  5. もしRubyに無限を表す何らかのリテラルか定数名が存在していたら(しかもFloat::INFINITYより短くて入力しやすく読みやすいものだったら)、歴史はどうなっていただろうかという気持ちになります。Ruby 2.6より前のとあるコードベースでは、この種の半無限rangeが大量に存在していたため、INF = Float::INFINITYを独自に定義していたことが思い出されます。 
  6. 訳注: その後文字列について#11454がマージされました。さらにその後#11573でシンボルについても修正がマージされました。Timeなどそれ以外の型についてはまだのようです。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。