静かなる名辞

pythonとプログラミングのこと


【python】その矛盾した__eq__は・・・

私は疑問を持った

 pythonでは比較演算子==を使うと、内部的には__eq__メソッドが呼ばれる。

 ここから、素朴な疑問が生じる。比較演算子は二項演算子なので、2つのオブジェクトに対して適用される。

 どちらのオブジェクトの__eq__が呼ばれるのだろう? また、もし2つのオブジェクトの__eq__が違う値を返すとしたら、一体どんな事態が生じるのだろう? まずいことにならないのだろうか?

 ドキュメントにはちゃんとこのことが書いてある。

x==y は x.__eq__(y) を呼び出します

 3. データモデル — Python 3.6.5 ドキュメント

 これだけ。いまいち納得がいかない。

検証した

 こんなコードを書いてみた。

class Hoge:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        print("__eq__ of Hoge!  by", self.name)
        return True

class Fuga:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        print("__eq__ of Fuga!  by", self.name)
        return False

h1 = Hoge("h1")
h2 = Hoge("h2")
f1 = Fuga("f1")
print("h1 == h1")
print(h1 == h1)
print("h1 == h2")
print(h1 == h2)
print("h2 == h1")
print(h2 == h1)
print("h1 == f1")
print(h1 == f1)
print("f1 == h1")
print(f1 == h1)

 常にTrueを返す__eq__を持つHogeクラス、常にFalseを返す__eq__を持つFugaクラスを定義し、片っ端から比較している。どの__eq__がprintされたかもこれでわかるはずだ。

 結果は、こんなものだった。

h1 == h1
__eq__ of Hoge!  by h1
True
h1 == h2
__eq__ of Hoge!  by h1
True
h2 == h1
__eq__ of Hoge!  by h2
True
h1 == f1
__eq__ of Hoge!  by h1
True
f1 == h1
__eq__ of Fuga!  by f1
False

 とりあえず、興味深いのは二回printされたりはまったくしなかったこと。そして、常に左側のオブジェクトの__eq__メソッドが呼ばれているように見えること。

 要するにドキュメントの「x==y は x.__eq__(y) を呼び出します」は極めて正しく、それ以外の動作はまったくないということだ。

 意外な感じがする・・・。

まずい事態

 ということは、__eq__を好き勝手に拡張しているpython外部ライブラリとかだと、まずい事態が生じるのではないだろうか。

 たとえばnumpyのarrayをintと比較すると、次のように動作する。

>>> import numpy as np
>>> a = np.array([0,0,0,1,1,1,2,2,2])
>>> a == 0
array([ True,  True,  True, False, False, False, False, False, False])

 慣れない頃は不思議な感じがしていたが、これはnumpy.ndarrayクラスの__eq__がbooleanのndarrayを返すように書かれているからだ、と考えるとそれほどおかしくはない。

 ここまでは良い。だけど、このコードで0==aとするとintクラスの__eq__が呼ばれて違う結果が出てしまうのでは?

>>> 0 == a
array([ True,  True,  True, False, False, False, False, False, False])

 理解不能すぎる・・・。

 いやまてよ、pythonのintがndarrayに忖度しているだけなのでは? と思い、次のコードを実行してみた。

>>> class Hoge:
...     def __init__(self, name):
...         self.name = name
...     def __eq__(self, other):
...         print("__eq__ of Hoge!  by", self.name)
...         return True
... 
>>> h = Hoge("h")
>>> a == h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
__eq__ of Hoge!  by h
array([ True,  True,  True,  True,  True,  True,  True,  True,  True])
>>> h == a
__eq__ of Hoge!  by h
True

 とりあえず、a == hで__eq__ of Hoge! by hがいっぱい出てきたことは理解可能。numpy.ndarray.__eq__は各要素に対してotherの__eq__を呼ぶ感じに動作しているのだろう。

 h == aは普通にTrueになった。ということは、intとは違う動作になっている。intの方は、一体どんな挙動なのだろう?

 ググったら出てきた。

It works because int.__eq__() returns NotImplemented and when that happens it results in a call to other.__eq__(self) and that's what is returning True and False here.

python - Why is `int.__eq__(other)` a working comparison? - Stack Overflow

 NotImplementedはpythonの組み込み定数らしい。

3. 組み込み定数 — Python 3.6.7 ドキュメント

 intの__eq__は(int型、およびその他のintと直接比較できることになっている型以外との比較時には)NotImplementedを返す。これが返ると、otherの__eq__が呼ばれ、判定が行われる、とか。

 なるほど、実験してみよう!

>>> obj = 0
>>> obj.__eq__(h)
NotImplemented
>>> obj == h
__eq__ of Hoge!  by h
True

 すげえ、よくできてる・・・。つまり、intの忖度でもなんでもなく、この機能(NotImplementedが返ったときの挙動)をうまく使ってnumpy.ndarrayは実装されていたのだった*1

 逆に、自作オブジェクトでやるときは、想定していない型が来たときはNotImplementedを返すみたいな注意が必要という話でもあるが。

まとめ

 ま、私が心配する程度のことは、聡明なpython開発陣の皆さんはしっかり対処しておられるのでした。つーか、これを考えた人たちは本当に頭が良い。とてもよく出来たシステムだと思う。

 逆に、一瞬で終わりそうな比較なのに内部ではメソッドがバンバン呼ばれていて、そのコストは実はけっこう高いという現実も見てしまった感も、あるといえばある。動的型付け言語だから、仕方ないのだけど。

 とにかく、これで安心して__eq__を使えるようになったね!*2

*1:とすると、上の「__eq__ of Hoge! by h 」がいっぱい出てきたのも、おそらく一回np.int64の__eq__が呼ばれて、NotImplementedが返ってからHoge.__eq__が呼ばれる、という流れのような気がする。確証はない

*2:現実に実装する際どんな風にすれば良いのかについては、日本語圏だとこちらの記事が参考になりました:Pythonにおける同値性比較の実装