nikkie-ftnextの日記

イベントレポートや読書メモを発信

Pythonで「x == y は x.__eq__(y) を呼び出す」とドキュメントにあるけれど、継承が絡むと成り立たない!?

はじめに

アイの歌声を聴かせて』、公開1周年おめでとう!🎉
都内では立川、新宿、下北沢、田端で上映するので、見に行ける全人類はみんな見て!ゼッタイ見て!!1 nikkieです。

Python==演算子と特殊メソッド__eq__について、Python言語リファレンスの記述に"秘密"があったようで、ドキュメントの記述とコードの挙動が矛盾していてハマるという経験をしました。
分かったことをまとめます。

目次

まとめ:x == yx.__eq__(y) を呼び出すとは限らない

  • クラスXのインスタンスをx、クラスYのインスタンスをyとする
  • XとYに継承関係がないとき
    • x == yx.__eq__(y) を呼び出す
    • xとyを入れ替えた y == xy.__eq__(x) を呼び出す
  • YがXを継承しているとき(Xが基底クラス、Yが派生クラス
    • x == yy == xy.__eq__(x)派生クラス側の__eq__)を呼び出す
    • つまり、継承関係があるとき「x == yx.__eq__(y) を呼び出す」は成り立たない

Python 3.9.4, 3.10.2 で動かしています。

(追記 2022/10/30)__eq__のドキュメントの最終段落に記載あり!

公開直後、hoboroさんに教えていただきました。ありがとうございます!

演算子が異なる型で右の被演算子の型が左の被演算子の直接的または間接的サブクラスの場合、右被演算子の反射されたメソッドが優先されます。 そうでない場合左の被演算子のメソッドが優先されます。

x == y を例にすると、yxのサブクラス(=YがXの派生クラス)の場合、x.__eq__(y)が優先されるということですね。
演算子(=オペランド)の継承関係は__eq__に限らず他の「拡張比較メソッド」にも該当するそうです。

__eq__のドキュメントの冒頭「x == yx.__eq__(y) を呼び出す」の部分は「(詳細は後述)」って書いてあったらより親切ですね、
もしくは冒頭は「演算子シンボルとメソッド名の対応」を示すのが目的なので、「==演算子__eq__メソッドを呼び出します」くらいの方がそこで読むのが止まらなくてよりよさそうに思います。

データモデルのドキュメントによると x == yx.__eq__(y) を呼び出す

特殊メソッド__eq__

Pythonには特殊メソッドというものがあります。
用語集を引くと、

ある型に特定の操作、例えば加算をするために Python から暗黙に呼び出されるメソッド。

とあり、特殊メソッド名はデータモデルのドキュメントで一覧できます。

先日のPyCon JP 2022の発表「Pythonとアスタリスク」でも、a * ba.__mul__(b)と特殊メソッド__mul__を呼び出していると紹介しました。

今回注目するのは__eq__という特殊メソッドです。
Python言語リファレンスのデータモデルのドキュメントには

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

とあります。

x == yx.__eq__(y) を呼び出す」の確認

簡単なコードを書いて検証してみましょう。
==__eq__メソッド呼び出しの関係を理解するため2のサンプルコードです。

class X:
    def __eq__(self, other: object) -> bool:
        print("--- X's __eq__ called ---")
        return True


class Y:
    def __eq__(self, other: object) -> bool:
        print("--- Y's __eq__ called ---")
        return False


x = X()
y = Y()

上記スクリプトpython -i script.pyと実行3し、スクリプト内のコードを実行したあとに対話モードに入ります。

x==yx.__eq__(y) を呼び出す」を確認します。

>>> x == y
--- X's __eq__ called ---
True
>>> x.__eq__(y)
--- X's __eq__ called ---
True

xとyを入れ替えたら「y==xy.__eq__(x) を呼び出す」はずですよね。
確認しましょう。

>>> y == x
--- Y's __eq__ called ---
False
>>> y.__eq__(x)
--- Y's __eq__ called ---
False

ここまでで x == yx.__eq__(y) を呼び出している(そして、xとyを入れ替えても成り立つ)ことが確認できました。

解せない動き:datetime.dateを継承したクラスにて

以上の理解でいたのですが、このたび解せない挙動と出逢いました。

datetime.dateを継承したMyDateクラス

まずdatetime.dateクラスを継承したMyDateクラスを作ります(dateインスタンスに何度も同様の書式指定をしていた4ので、「書式指定を共通化したクラスを作れるのではないか」と考えたのです)。

from datetime import date


class MyDate(date):
    def __eq__(self, other: object) -> bool:
        print("--- MyDate's __eq__ called ---")
        if not isinstance(other, self.__class__):
            return False
        return super().__eq__(other)


d = date(2022, 12, 31)
md = MyDate(2022, 12, 31)

MyDatedateが指す日付(年月日)が同じでも、型が違うので「等しくない」と実装しました。
動作確認しましょう。

>>> md.__eq__(d)
--- MyDate's __eq__ called ---
False

ではd.__eq__(md)と呼び出したら何が返るでしょうか。
これはdateクラスの__eq__の呼び出しです。
実際にはCの実装が使われている5のですが、参考までにLib/datetime.py6を覗いてみましょう。

# https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L1026-L1029
    def __eq__(self, other):
        if isinstance(other, date):
            return self._cmp(other) == 0
        return NotImplemented

仮引数otherに渡される実引数mdは(dateを継承した)MyDateクラスのインスタンスですから、isinstance(other, date)Trueと評価されます。
_cmpメソッドは(年, 月, 日)のタプルを作って比較する7ので、dmdが日付として同じであればd.__eq__(md)Trueを返します。

>>> d.__eq__(md)
True

ここまでをまとめると

  • dateを継承してMyDateクラスを定義した
  • 日付を同じとして
  • md.__eq__(d)は型が違うのでFalseを返すように実装した
  • d.__eq__(md)は日付が同じときTrueを返す

dateMyDateインスタンスの比較では「x == yx.__eq__(y) を呼び出す」が成り立っていない!?

では==演算子を使ってみましょう。

先ほど x == yx.__eq__(y) を呼び出すことを見たので、md == dmd.__eq__(d) を呼び出すはずです。

>>> md == d
--- MyDate's __eq__ called ---
False

md.__eq__(d)が呼び出され、Falseが返っています。

次に演算子の右と左を入れ替えた d == md です。
y == xy.__eq__(x) を呼び出すことを見ているので、d.__eq__(md) が呼び出されTrueが返るはずですよね?

>>> d == md
--- MyDate's __eq__ called ---
False

d.__eq__(md) で返ったのはTrueではなくFalseです。
この動きが理解できなくて大変苦しみました🤯(「y == xのときはy.__eq__(x)だったじゃん!」と)。

y == xのときとd == mdのときの差分はクラスに継承関係があるかどうかです。

  • y == x ではクラスYXに継承関係はない
  • d == md ではdateクラスがMyDateクラスのベースクラスになっている

この差分が==__eq__の呼び出しに影響したのかと、XYに継承関係を導入してみます。

継承により親子関係にある2つのクラスのインスタンス__eq__

継承関係を導入

最初に見たスクリプトをクラスYXを継承するようにだけ変更します。
※これは__eq__の動きを理解するのが目的のコードです。この書き方を真似てプロダクションコードとしては使うのはオススメしません8

class X:
    def __eq__(self, other: object) -> bool:
        print("--- X's __eq__ called ---")
        return True


class Y(X):  # クラスXを継承してクラスYを定義(ここだけ変更)
    def __eq__(self, other: object) -> bool:
        print("--- Y's __eq__ called ---")
        return False


x = X()
y = Y()

Xdateクラス、YMyDateクラスに相当します。
md == dy == x に、d == mdx == y に対応します。

クラスに継承関係があるとき x == y はどちらの__eq__を呼び出すか

継承関係がある2つのクラスのインスタンス==演算子で比較してみましょう。

>>> y == x
--- Y's __eq__ called ---
False

y == xy.__eq__(x) が呼び出されているのはここまでの理解とも一致します。
では x == y の場合はどうでしょうか?

>>> x == y
--- Y's __eq__ called ---
False

y.__eq__(x) が呼び出されて False が返りました。
これは d == mdmd(MyDateインスタンス)の__eq__が呼び出された動きと同様です。
このことから、「x == yx.__eq__(y) を呼び出します」は正確には以下となると考えています。

  • クラスXのインスタンスをx、クラスYのインスタンスをyとする
  • 2つのクラスXとYに継承関係がないとき
    • x == yx.__eq__(y) を呼び出す
    • y == xy.__eq__(x) を呼び出す
  • 2つのクラスXとYに継承関係があるとき、XがYのベースクラス(class Y(X))とすると
    • x == yy == xy.__eq__(x) を呼び出す
    • (基底クラスではなく)派生クラスの __eq__ が呼び出されている

終わりに

データモデルのドキュメントの記載「x==yx.__eq__(y) を呼び出します」と矛盾する挙動を発見し、クラスに継承関係があるとき(class Y(X))には x == yy == x も派生クラスの __eq__ メソッドを呼び出していることを確認しました。
言語リファレンスの記載とPythonの動きが違うとは思っていなかった(詳細に説明し尽くしていると思っていた)ので、今回の発見は想定外でした。

クラスが継承関係にある2つのインスタンスへの==演算子の適用について記載があるとしたらどこなのか(ドキュメントにあるのか、ソースコードを見ることになるのか)、いま一番気になっています。
==演算子がオブジェクトの特殊メソッドをどう呼び分けるのか、「ドキュメントでもソースコードでもここを見たら載ってるよ」という情報をお寄せいただけると大変助かります。

参照したい情報源としては以下かなと浮かんでいます。

  • 文法詳解などのオライリー
  • 開発者メーリス(質問してもいいかも。質問でいうと #pyhack Slackが一番手軽?)
  • 『Internal CPython』?

この話題に関して、どうか次回がありますように🙏(迷宮入りしませんように)

P.S. 自分のつぶやきにいただいた反応

今後の調査を進める上でヒントになるかもしれないと思い、見返せるようにこちらに埋め込ませていただきます。


  1. ここまでがブログ冒頭の挨拶です。最近は「ういっす」と短めですが、今回は長かった!

  2. return Falsereturn Trueという点は、プロダクションコードとしてはやらない方がよい実装と考えます

  3. 「(略)スクリプトかコマンドを実行した後にインタラクティブモードに入ります。」 https://docs.python.org/ja/3/using/cmdline.html#cmdoption-i

  4. 具体的にはこんなコードです https://github.com/ftnext/sing-a-bot-of-harmony/blob/26dcf1f065817c3be48b3b738d2190c255d90ad8/src/harmonizer_bot/datetime.py#L22

  5. https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L2536-L2539 でCの実装を読み込んでいるそうです。ref: https://stackoverflow.com/a/55093081

  6. datetime --- 基本的な日付型および時間型 — Python 3.11.0b5 ドキュメントの冒頭でソースコードが示されています。

  7. https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L1051-L1055https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L15-L16 です

  8. 継承は本来specializeするために使うので、class SpecialY(Y)というような関係で使うのが望ましい(Y(X)という継承は名前だけ見たらspecializeしているように見えないので不適切な継承)と考えます