range(1, 2, 2) == range(1, 0, -2)

説明しにくい話ではあるんだけれども。

Python 3.3 での変更点のひとつ:

Equality comparisons on :func:`range` objects now return a result reflecting the equality of the underlying sequences generated by those range objects. (#13201)

そのまんま受け取ればなんてことはないことなんだけれども、初見の時点からなんか違和感あって、真面目に #13201 を追っかけてしまった。

最初に感じた違和感の正体がわかったのはしばらく元となった議論を眺めてから。その話はのちほど。

その前に。上記原文の翻訳は

:func:`range` オブジェクトどうしの等値性比較が、それぞれの range オブジェクトによって生成されるシーケンスの等値性を反映して行われるようになりました (#13201)。

ね。で、「じゃぁ 3.0~3.2 はどうだったのよ」という疑問。これが最初に真面目に追っかけようと思った動機。これは identity based 、つまり「object のデフォルトの振る舞い」のみ。なので参照を共有しない限りは絶対に「r1 == r2」は真にならなかった、ということ。まずはこの点は調べるまでもなくきっとそうであろうと思ってはいたのだけれども、これがわかってもなぜだかスッキリしない。なんだこのモヤモヤは。

と議論を斜め読みしながら思考を巡らせていて、まず最初にわかったこと。「identity based をやめたことは腑に落ちるけれど、なんで same sequence based?」ということ。ところが「なんで?」の疑問の正体がまだ晴れない。何かが引っかかっているのだが、一方では賛同出来てもいて、厄介なのは「是」も「非」もその理由が未だ判然としない点。

しばらくあまり深く理解も出来ぬままなんとなく読みつつ、やはり思考を続けていて、また一つ理解した。あぁ、納得出来ているのは 2.x の振る舞いとの関係からなのだな、と。これはこういうこと:

 1 Python 2.7.9 (default, Dec 10 2014, 12:28:03) [MSC v.1500 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> range(1, 2, 2)
 4 [1]
 5 >>> type(range(1, 2, 2))  # 2.x では range は list *を返す*
 6 <type 'list'>
 7 >>> range(1, 0, -2)
 8 [1]
 9 >>> range(1, 2, 2) == range(1, 0, -2)  # これは list の __eq__ が発動しておる
10 True
 1 Python 3.5.1 (v3.5.1:37a07cee5969, Dec  6 2015, 01:54:25) [MSC v.1900 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> range(1, 2, 2)  # 3.x では range は range オブジェクト
 4 range(1, 2, 2)
 5 >>> list(range(1, 2, 2))
 6 [1]
 7 >>> list(range(1, 2, 2)) == list(range(1, 0, -2))  # これが 2.x での range どうし比較
 8 True
 9 >>> range(1, 2, 2) == range(1, 0, -2)  # 2.x での range どうし比較とは正体は別物
10 True

これで気分も晴れやか、になれば良かったのが、これでもなお何かが引っかかる。なんだ?

と、ようやくここでハタと気付いた。最初に感じた違和感の正体。それは、

range オブジェクトを比較するのって食べれるの?
オイシイの?

てことなんであった。そして「なんかおいしそうな気もする(real なリストを生成せずに比較できるから)」気分と「ていうかそれする機会なんかあるけ?」気分の闘いに、心中では落ち着く。

これに気付いたらもう、Python-ideas ML の議論がすんなり読めて…であればハッピーだったんだけどね、そうはいかない。だってこの議論、まさしくワタシが気持ち悪いと思ったまさにそのものを皆で悶々と議論していたんだから。Guido がまさしく何度も「それ、喰えんの?」と繰り返している。

議論のポイントはいくつかあったみたいで、GvR は「range オブジェクトの等値比較は start, stop, step の比較で良い」とずっと言っていたが、「ただの 3 要素タプルと同じがいい」ということらしい。無論反対論者の主張の根底にはやはり 2.x の振る舞いに合わせたいというのがあったようだ。「range(1, 2, 2) == range(1, 0, -2)」は、生成されるシーケンスベースでの等値比較では真、 start, stop, step の比較では偽となる。

でな。結局どういう顛末で決定仕様の決断をしたのかを知りたくて全部を「眺めてみた」のに、あらら、なんか途中で切れてる? 肝心なところはわからずじまい。なんかずっと「ユースケースは?」の答えが出てないまんまだし(unittest で有用、以外のものが一つも出てない)。なのに決定仕様は「3. Behaviour based: range objects are equal if they produce the same sequence of values when iterated over」。うーん、なんかスッキリしないなぁ。

もうひとつスッキリしないのが、変更履歴でもドキュメントでも結局「なにゆえに range オブジェクトを hashable にしたのか」の説明がなされなかったこと(というより hashable になったことさえ説明されてない)。自明か? 確かに eq と hash は歩み寄らねばならぬ。そういうことか?

というわけで、結局のところは動機付けの部分は最後までわからずじまいだったんだけれども、ただ、「これを受け容れたその先」の話は、(きっと役には立たないけれど)面白いことは面白い、「3.3 では range どうしの == がシーケンスベースの比較をし、かつ、range オブジェクトは hashable」という世界と 2.x ワールドとの比較が。

こういうことである:

2.x 世界
 1 Python 2.7.9 (default, Dec 10 2014, 12:28:03) [MSC v.1500 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> d = dict()
 4 >>> d[(1, 2)] = "a"  # タプルは辞書のキーに出来る
 5 >>> d[[1, 3]] = "b"  # リストは辞書のキーに出来ない
 6 Traceback (most recent call last):
 7   File "<stdin>", line 1, in <module>
 8 TypeError: unhashable type: 'list'
 9 >>> d[range(0, 4, 2)] = "c"  # range は 2.x ではリストを「返す」のであるからして
10 Traceback (most recent call last):
11   File "<stdin>", line 1, in <module>
12 TypeError: unhashable type: 'list'
13 >>>
3.3+ 世界
 1 Python 3.5.1 (v3.5.1:37a07cee5969, Dec  6 2015, 01:54:25) [MSC v.1900 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> list(range(0, 9, 3))
 4 [0, 3, 6]
 5 >>> list(range(0, 7, 3))
 6 [0, 3, 6]
 7 >>> range(0, 9, 3) == range(0, 7, 3)
 8 True
 9 >>> d = dict()
10 >>> d[range(0, 9, 3)] = "aaa"
11 >>> d
12 {range(0, 9, 3): 'aaa'}
13 >>> d[range(0, 7, 3)] = "bbb"  # recall the fact that "range(0, 9, 3) == range(0, 7, 3)"!
14 >>> d
15 {range(0, 9, 3): 'bbb'}

普段使いにはこの手のものには素直にタプルを使うか、もしくは集合(set)オブジェクトを使うんじゃないかと思う。何かの範囲をそのままキーにするのに、あえて range そのものを使うであろうか、ということ。けどまぁ、「出来ることは出来る」というわけで、「やりたければやれば?」程度のもん。

まぁそんな程度だからこそ、What’s New で「その他の言語の変更」に、小さくしれ~っと書いたんであろうね。










さて、ここまでの話は、「実世界」のはなし。つまり現実に起こった Python の進化の話。ここから先の話は、ちょっと書こうかどうしようか迷ったんだけどね、思いついちゃったもんだからさ、書いといてみる。

実世界の話を一通り書き終わった後、飯を買いに外に出て、帰り道でふと思ったんである。

よくよく考えてみると、
「範囲」って「集合」のことだよな

今回のこの Python の仕様変更では Guido は「==、!= 以外の演算はサポートしない」と言っているんだけれども、「範囲って集合のことデショ」論に立つとだな、実は set と同じ演算を提供出来るはず。

と思いついてしまうと、なかなかにこれで広がる世界が面白くみえてくる。Python 組み込みの集合「set」は、リストと同じく「要素のリスト」なわけね。つまりは「1~10000の整数の集合」を表現するには set では「1, 2, …, 10000」という10000要素を実際にメモリ上に持つ必要がある。けれども「(実在しない)拡張 range」はメモリ上の表現は「start=1, stop=10001, step=1」だけでいい。仮にこれが出来るとしたら、今はこう:

現実
 1 Python 3.5.1 (v3.5.1:37a07cee5969, Dec  6 2015, 01:54:25) [MSC v.1900 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> set(range(3))
 4 {0, 1, 2}
 5 >>> set(range(5))
 6 {0, 1, 2, 3, 4}
 7 >>> set(range(3)) > set(range(5))
 8 False
 9 >>> set(range(3)) < set(range(5))
10 True
11 >>> range(3) < set(range(5))
12 Traceback (most recent call last):
13   File "<stdin>", line 1, in <module>
14 TypeError: unorderable types: range() < set()

だけれども、こう出来るはずだ:

夢見がち
1 >>> range(3) > set(range(5))  # doesn't work, actually
2 False
3 >>> range(3) < set(range(5))  # doesn't work, actually
4 True

まぁ…、たぶんこれは理想かもしれないけれども「やり過ぎ」かもね。確かにこれがなければないで(というかないわけだし)、なんならこうすりゃいいわけで:

目が覚めた
1 >>> outer = set(range(5))
2 >>> all((i in outer for i in range(3)))  # doesn't create `real' set(range(3))
3 True

(ちなみに intersection は上でやってる all を any にすりゃぁいい。)

この「理想の range」をスッキリ実現しようとするなら何が必要だろう? set のオペレータ群が range オブジェクトについての知識を持ってればいいのかな? range は「単なるイテラブル」ではなくて、重複要素も持たない start, stop, step で制限された特殊なイテラブルなので、やはり「isinstance(o, range)」とかで明示的に特別扱いすることになるかいね。range オブジェクト自身のオペレータも set の知識を持つ必要がある?

うーん、まぁいいか。あれば面白いけれども、なくて絶望的に困るニーズなんか考えられないしな。