range関数はなぜエンドポイントを含まないのか?生徒からの質問

プログラミング

range関数はなぜエンドポイントを含まないのか?

「データサイエンスとAIの初歩」コースも台風の影響で一週間遅れでスタートしました。
Pythonの基礎的な文法から始め、組み込み関数であるrange関数の説明をしたところで早速、質問が出ました。

例えば
x = [i for i in range(10)]
print(x)
としてリストを作成し、表示してみましょう。
結果は
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
となります。
スタートポイントは0
エンドポイントは9で指定した10のひとつ前までです。
「なぜ、ひとつ前なのか?」

「仕様です」と言ってしまえば、おしまいですが、良い質問です。

結論から言ってしまうと上の図のように数直線を2つに分割したときに、両方に数字の5が含まれてしまうと5がダブってしまって数学的に好ましくないからと考えられます。
以下に詳しく見ていきましょう。

スタートポイントに関してはどうか?

先ほどの例では、スタートポイントは指定しなくても0でした。なぜでしょうか。

まず、プログラムではスタートポイントが0というのはよくある話です。
コンピューターの中では0と1の2進数で処理が行われており、0から数えるというのは自然な流れかと思います。
(2進数の0は10進数でも0です。)
人間でも生まれたばかりの赤ちゃんは0歳児です。

なお、スタートポイントは必要に応じて変えることが出来ます。例えば
x = [i for i in range(1, 10)]
print(x)
とすると
[1, 2, 3, 4, 5, 6, 7, 8, 9]
となります。
(さらにステップを指定することもできます。)

それではなぜ指定したエンドポイントのひとつ前までなのか?

参考となる文献にダイクストラが書いたものがあります。
http://www.cs.utexas.edu/users/EWD/transcriptions/EWD08xx/EWD831.html

ただし、これも必ずしも納得のいく説明とは限らないように思います。
x = [i for i in range(0, 10)]
を例にして考えてみましょう。

エンドポイントを含まないことへのダイクストラの説明
1.エンドポイントからスタートポイントを引くとリストの要素数になるので、要素数が簡単に求まる。
すなわち
10 – 0 = 10
これが要素数と一致すると言っています。
ただし、Pythonのlen関数を使えば要素数は簡単に得られますので、それほど強い理由ではないように思われます。

スタートポイントを省略した
range(10)
の場合は、確かに要素数が10個のリストを作成したということが一目でわかりやすいということは言えるかもしれませんが。

2.空のリストを作るとき、醜い表現になる。
エンドポイントを含まなければ
range(0, 0)
とすると空のリストになります。
もし、エンドポイントを含むとすると、
range(0, -1)
というふうに書かなければなりませんが、これは醜い表現であるという訳です。
ただし、Pythonの場合は
x = []
で空のリストが作成できるので、これもあまり積極的な理由にはならないようにも思います。

3.隣接する2つの範囲(range)があるとき、一方のエンドポイントの数字が他方のスタートポイントの数字に等しいということも言っています。
これは自然数を扱う場合は積極的な理由になりませんが、実数を扱う場合には納得のいく理由になると思います。
以下に説明します。

例えば
自然数で範囲range(0, 10)を定義して、これを2つに分けるとき
range(0, 5)
range(5, 10)
となります。
ただし、自然数で考えているのでエンドポイントを含む場合でも
range(0, 5)
range(6, 10)
のようにすれば良いだけです。
実際、下で述べたように整数の乱数を発生するとき、標準ライブラリではエンドポイントを含みます。

ところが、実数の場合は、このことは重要です。
実数で範囲range(0, 10)を定義したとすると、
範囲range(0, 10)を2つに分けるにはエンドポイントを含まず
range(0, 5)
range(5, 10)
とするのが妥当です。


rangeがエンドポイントを含むとすると
range(0, 5)

range(5, 10)
の両方に5が含まれてしまい数学的におかしなことになってしまいます。

実数の場合は、このような理由からエンドポイントは含まないとするのが合理的です。
また、実数の場合に合わせて、自然数や整数を扱うrange関数においてもエンドポイントは含まない方が素直な考えのように思われます。

なお、rangeだけでなく、実数の乱数を発生するときにも、エンドポイントは含みません。
例えば、Pythonの標準ライブラリ
random.random()
はランダムな浮動小数点数を返しますが、その範囲は 0.0 <= X < 1.0であり、1.0を含みません。

ダイクストラによるスタートポイントを含む理由

なお、ダイクストラはスタートポイントを含む理由についても触れています。
range(0, 10)は
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
というリストを表現できますが、もしスタートポイントを含まないとすると
range(-1, 10)
という記述になります。
これはカッコ悪いという訳です。

乱数発生のライブラリによる違い

関連した話題として、整数の乱数が欲しいときに、標準ライブラリのrandomを使用するか、外部ライブラリのnumpyを使用するかで状況が異なるということがあります。
標準ライブラリのrandomを使用した場合は、
import random

x = random.randint(1, 3)
print(x)
とすると
エンドポイント3を含んだ乱数が得られます。
上記の流れから言うと、実数の時のようにエンドポイントは含まず、ひとつ前の2までの乱数であって欲しいのですが。

外部ライブラリのnumpyを使用した場合は、
import numpy as np

x = np.random.randint(1, 3)
print(x)
とすると
エンドポイントのひとつ前の2までの乱数が得られます。こちらの方が自然な考え方のように思います。

注意が必要ですね。

結論

例えば、0から10までの数直線を2つに分割したときに、どちらの数直線にも数字の5が含まれてしまうと5がダブってしまって数学的に好ましくありません。そのためエンドポイントは含まないようにするのが合理的です。