はじめに
『アイの歌声を聴かせて』、公開1周年おめでとう!🎉
都内では立川、新宿、下北沢、田端で上映するので、見に行ける全人類はみんな見て!ゼッタイ見て!!1 nikkieです。
Pythonの==演算子と特殊メソッド__eq__について、Python言語リファレンスの記述に"秘密"があったようで、ドキュメントの記述とコードの挙動が矛盾していてハマるという経験をしました。
分かったことをまとめます。
目次
- はじめに
- 目次
- まとめ:x == y は x.__eq__(y) を呼び出すとは限らない
- (追記 2022/10/30)__eq__のドキュメントの最終段落に記載あり!
- データモデルのドキュメントによると x == y は x.__eq__(y) を呼び出す
- 解せない動き:datetime.dateを継承したクラスにて
- 継承により親子関係にある2つのクラスのインスタンスの__eq__
- 終わりに
- P.S. 自分のつぶやきにいただいた反応
まとめ:x == y は x.__eq__(y) を呼び出すとは限らない
- クラスXのインスタンスをx、クラスYのインスタンスをyとする
- XとYに継承関係がないとき
x == yはx.__eq__(y)を呼び出す- xとyを入れ替えた
y == xはy.__eq__(x)を呼び出す
- YがXを継承しているとき(Xが基底クラス、Yが派生クラス)
x == yもy == xもy.__eq__(x)(派生クラス側の__eq__)を呼び出す- つまり、継承関係があるとき「
x == yはx.__eq__(y)を呼び出す」は成り立たない
Pythonのデータモデルのドキュメントには
— nikkie にっきー 🎤10/1 XP祭り 10/14-15 PyCon JP (@ftnext) 2022年10月30日
>x==y は x.__eq__(y) を呼び出しますhttps://t.co/38cK1S1Ygr
とありますが、
xはXクラスのインスタンスで、yはYクラスのインスタンス、YはXを継承しているとき、x==yはy.__eq__(x)を呼び出すみたいです。
dateを継承したMyDateクラスを作ってハマってました
Python 3.9.4, 3.10.2 で動かしています。
(追記 2022/10/30)__eq__のドキュメントの最終段落に記載あり!
公開直後、hoboroさんに教えていただきました。ありがとうございます!
> 被演算子が異なる型で右の被演算子の型が左の被演算子の直接的または間接的サブクラスの場合、右被演算子の反射されたメソッドが優先されます。
— ほぼろ (@rhoboro) 2022年10月30日
わたしも知らなかったのですが、ドキュメントに明記されていますね。https://t.co/ZW0UZNUawR
被演算子が異なる型で右の被演算子の型が左の被演算子の直接的または間接的サブクラスの場合、右被演算子の反射されたメソッドが優先されます。 そうでない場合左の被演算子のメソッドが優先されます。
x == y を例にすると、yがxのサブクラス(=YがXの派生クラス)の場合、x.__eq__(y)が優先されるということですね。
被演算子(=オペランド)の継承関係は__eq__に限らず他の「拡張比較メソッド」にも該当するそうです。
__eq__のドキュメントの冒頭「x == y は x.__eq__(y) を呼び出す」の部分は「(詳細は後述)」って書いてあったらより親切ですね、
もしくは冒頭は「演算子シンボルとメソッド名の対応」を示すのが目的なので、「==演算子は__eq__メソッドを呼び出します」くらいの方がそこで読むのが止まらなくてよりよさそうに思います。
データモデルのドキュメントによると x == y は x.__eq__(y) を呼び出す
特殊メソッド__eq__
Pythonには特殊メソッドというものがあります。
用語集を引くと、
ある型に特定の操作、例えば加算をするために Python から暗黙に呼び出されるメソッド。
とあり、特殊メソッド名はデータモデルのドキュメントで一覧できます。
先日のPyCon JP 2022の発表「Pythonとアスタリスク」でも、a * bはa.__mul__(b)と特殊メソッド__mul__を呼び出していると紹介しました。
今回注目するのは__eq__という特殊メソッドです。
Python言語リファレンスのデータモデルのドキュメントには
x==yはx.__eq__(y)を呼び出します
とあります。
「x == y は x.__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==y は x.__eq__(y) を呼び出す」を確認します。
>>> x == y --- X's __eq__ called --- True >>> x.__eq__(y) --- X's __eq__ called --- True
xとyを入れ替えたら「y==x は y.__eq__(x) を呼び出す」はずですよね。
確認しましょう。
>>> y == x --- Y's __eq__ called --- False >>> y.__eq__(x) --- Y's __eq__ called --- False
ここまでで x == y は x.__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)
MyDateとdateが指す日付(年月日)が同じでも、型が違うので「等しくない」と実装しました。
動作確認しましょう。
>>> 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ので、dとmdが日付として同じであればd.__eq__(md)はTrueを返します。
>>> d.__eq__(md)
True
ここまでをまとめると
dateを継承してMyDateクラスを定義した- 日付を同じとして
md.__eq__(d)は型が違うのでFalseを返すように実装したd.__eq__(md)は日付が同じときTrueを返す
dateとMyDateのインスタンスの比較では「x == y は x.__eq__(y) を呼び出す」が成り立っていない!?
では==演算子を使ってみましょう。
先ほど x == y は x.__eq__(y) を呼び出すことを見たので、md == d は md.__eq__(d) を呼び出すはずです。
>>> md == d --- MyDate's __eq__ called --- False
md.__eq__(d)が呼び出され、Falseが返っています。
次に演算子の右と左を入れ替えた d == md です。
y == x は y.__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ではクラスYとXに継承関係はないd == mdではdateクラスがMyDateクラスのベースクラスになっている
この差分が==と__eq__の呼び出しに影響したのかと、XとYに継承関係を導入してみます。
継承により親子関係にある2つのクラスのインスタンスの__eq__
継承関係を導入
最初に見たスクリプトをクラスYがXを継承するようにだけ変更します。
※これは__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()
Xがdateクラス、YがMyDateクラスに相当します。
md == d は y == x に、d == md は x == y に対応します。
クラスに継承関係があるとき x == y はどちらの__eq__を呼び出すか
継承関係がある2つのクラスのインスタンスを==演算子で比較してみましょう。
>>> y == x --- Y's __eq__ called --- False
y == x で y.__eq__(x) が呼び出されているのはここまでの理解とも一致します。
では x == y の場合はどうでしょうか?
>>> x == y --- Y's __eq__ called --- False
y.__eq__(x) が呼び出されて False が返りました。
これは d == md で md(MyDateインスタンス)の__eq__が呼び出された動きと同様です。
このことから、「x == y は x.__eq__(y) を呼び出します」は正確には以下となると考えています。
- クラスXのインスタンスをx、クラスYのインスタンスをyとする
- 2つのクラスXとYに継承関係がないとき
x == yはx.__eq__(y)を呼び出すy == xはy.__eq__(x)を呼び出す
- 2つのクラスXとYに継承関係があるとき、XがYのベースクラス(
class Y(X))とするとx == yもy == xもy.__eq__(x)を呼び出す- (基底クラスではなく)派生クラスの
__eq__が呼び出されている
終わりに
データモデルのドキュメントの記載「x==y は x.__eq__(y) を呼び出します」と矛盾する挙動を発見し、クラスに継承関係があるとき(class Y(X))には x == y も y == x も派生クラスの __eq__ メソッドを呼び出していることを確認しました。
言語リファレンスの記載とPythonの動きが違うとは思っていなかった(詳細に説明し尽くしていると思っていた)ので、今回の発見は想定外でした。
クラスが継承関係にある2つのインスタンスへの==演算子の適用について記載があるとしたらどこなのか(ドキュメントにあるのか、ソースコードを見ることになるのか)、いま一番気になっています。
==演算子がオブジェクトの特殊メソッドをどう呼び分けるのか、「ドキュメントでもソースコードでもここを見たら載ってるよ」という情報をお寄せいただけると大変助かります。
参照したい情報源としては以下かなと浮かんでいます。
- 文法詳解などのオライリー本
- 開発者メーリス(質問してもいいかも。質問でいうと #pyhack Slackが一番手軽?)
- 『Internal CPython』?
この話題に関して、どうか次回がありますように🙏(迷宮入りしませんように)
P.S. 自分のつぶやきにいただいた反応
今後の調査を進める上でヒントになるかもしれないと思い、見返せるようにこちらに埋め込ませていただきます。
言われて考えるとなるほどありそう(YがXを継承しているなら継承先の実装で eq を見てほしくもなりそう)となるがこれは難しい挙動 https://t.co/7SNfRYOYV0
— Takumi Sueda (@puhitaku) 2022年10月30日
Xを継承したZがあって、z==yとしたら、(YはZのサブクラスではないので)Zの実装を呼び出すらしい。
— 中村 (@_osna329_) 2022年10月30日
サブクラスは継承元の実装を把握している前提の仕様なんだろうけど、y==zの結果と一貫して実装するの難しいだろうなぁ https://t.co/wvcripm3L6
-
ここまでがブログ冒頭の挨拶です。最近は「ういっす」と短めですが、今回は長かった!↩
-
return Falseやreturn Trueという点は、プロダクションコードとしてはやらない方がよい実装と考えます↩ -
「(略)スクリプトかコマンドを実行した後にインタラクティブモードに入ります。」 https://docs.python.org/ja/3/using/cmdline.html#cmdoption-i↩
-
具体的にはこんなコードです https://github.com/ftnext/sing-a-bot-of-harmony/blob/26dcf1f065817c3be48b3b738d2190c255d90ad8/src/harmonizer_bot/datetime.py#L22↩
-
https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L2536-L2539 でCの実装を読み込んでいるそうです。ref: https://stackoverflow.com/a/55093081↩
-
datetime --- 基本的な日付型および時間型 — Python 3.11.0b5 ドキュメントの冒頭でソースコードが示されています。↩
-
https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L1051-L1055 や https://github.com/python/cpython/blob/3.9/Lib/datetime.py#L15-L16 です↩
-
継承は本来specializeするために使うので、
class SpecialY(Y)というような関係で使うのが望ましい(Y(X)という継承は名前だけ見たらspecializeしているように見えないので不適切な継承)と考えます↩