Skip to content
返回

__eq__ 的返回类型与里氏替换原则

发布于

在 Python 的静态类型生态里,object.__eq__ 被 typeshed 和 CPython 文档标成 -> bool。不涉及数组库时,这通常没问题;但在特定情况下会暴露矛盾:一旦涉及 NumPy 或符合 array-api 规范 的数组类型,__eq__ 实际返回的是布尔数组。若按里氏替换原则(LSP)严格理解,子类返回 ndarray[bool] 就是在违背基类契约;若把 object.__eq__ 视为 -> Any,类型系统对 == 的推理能力又会大大削弱。

本文结合 Discuss 上关于 __eq__ 类型放宽的讨论typeshed #8217#3685NumPy #17368 / #17778 以及 array-api #229 的讨论,梳理 __eq__ 返回类型与里氏替换原则的冲突从何而来、为何难解,以及各方的取舍。

CPython 的 equality 运行时机制

== 在 CPython 里的行为可以概括为:

  1. 先调左操作数__eq__(self, other)
  2. 若不存在或返回 NotImplemented,再调右操作数__eq__(other, self)
  3. 若仍不存在或返回 NotImplemented,则退化成对象同一性比较id(a) == id(b))。

因此,object 在运行时并没有一个「永远返回 bool」的 __eq__ 实现;文档里建议自定义类让 __eq__ 返回 boolNotImplemented,但语言本身并不强制。一旦某个类(比如 NumPy 的 ndarray)让 __eq__ 返回数组,a == b 在运行时的类型就可以是 ndarray,而不是 bool

换句话说:运行时的 equality 协议是「可扩展、可覆盖」的,类型系统若想同时兼顾里氏替换原则、文档约定和返回非 bool 的这类特例,就会撞墙。

typeshed 与里氏替换原则:object.__eq__ -> bool 的含义

typeshed 和 stdlib 的 builtins.pyi 里,object 和常见内建类型被标成:

def __eq__(self, __x: object) -> bool: ...

若按里氏替换原则理解:

typeshed #8217 里举了一个很好的例子:自定义 Matrix__eq__ 返回 Matrix[bool]。当写 float == matrix 时:

于是出现了静态类型与运行时类型不一致,根源正是:primitives 的 stub 把 __eq__ 标成「接受任意 object、返回 bool」,而实际对「无法处理」的类型会返回 NotImplemented,把皮球踢给右边。

typeshed #3685 中曾有人提议:将 object.__eq__(以及其他比较方法)的返回类型改为 Any,这样「子类可把返回类型重写为任意类型」,并指出「__eq__ 不返回 bool 非常常见,尤其是 NumPy 数组的逐元素比较」。该提议未获采纳,typeshed 仍维持 -> bool

Discuss 上则有人提议:让 object.__eq__ 标成 -> NotImplemented,并为 floatcomplex 等写更窄的签名(例如 float.__eq__(self, other: int | float) -> bool),以更忠实于 CPython 的 float_richcompare 等实现。这样做的代价是:几乎所有重写 __eq__ 的类都会和 object 的签名「不兼容」,需要 # type: ignore,等于把矛盾转嫁到用户代码。typeshed 维护者最终没有采纳,继续沿用 -> bool,相当于在类型层面做了一次「善意谎言」:假设绝大多数 __eq__ 都会返回 bool

返回非 bool 的 __eq__:NumPy 与 array-api 规范

文档和 typeshed 的「__eq__ 返回 bool」并非 runtime 唯一现实。典型例子是 NumPy 与 array-api 规范(array-api 是规范,NumPy 等库是实现方)。规范中定义:

array.__eq__(other: int|float|complex|bool|array, /) -> array

返回的是元素级布尔结果的数组(dtype 为 bool),而不是标量 bool,以便支持向量化、加速器库和与 equal() 等函数的语义对齐。NumPy 的 ndarray 以及遵守 array-api 规范的实现都遵循这一点:arr == 3 的类型是 ndarray[bool],不是 bool。因此,在类型层面,这些类的 __eq__ 若写清楚,就应当是 -> Self-> NDArray[bool] 一类的数组类型,与 object.__eq__ -> bool 在里氏替换原则下直接冲突。

两种应对方式:不标 vs # type: ignore[override]

NumPy #17368 中,社区把 ndarray / generic 的类型标注拆成多步。早期的 PR #17778 做比较运算的注解时,作者 BvB93 曾写明:

The equivalency operators (__eq__ and __ne__) were deliberately excluded as it is currently impossible to properly type them due to the existence of object.__eq__.

也就是说:__eq____ne__ 一旦标成 -> ndarray[bool],就会和 object.__eq__ -> bool 形成 override 不兼容,类型检查器会报错;当时 NumPy 选择了不标此后类型 stubs 持续演进,NumPy 现已对 __eq__ / __ne__ 提供标注,通常需配合 # type: ignore[override] 等方式以绕过与 object 的 override 冲突,思路与 array-api #229 一致。

array-api #229 在讨论可 vendoring 的数组 Protocol 时,对 __eq____ne__ 显式写出other: Union[bool, int, float, A]-> A,并在两处都加上 # type: ignore[override],注释理由为:object.__eq__ 接受任意 object、返回 bool,而 Protocol 需要收紧参数类型并让返回类型为数组 A,因此必须 override 且只能用 type: ignore 压制与基类签名的冲突。这可以视为在类型上如实刻画数组的 __eq__,同时承认对里氏替换原则的违背。

三种类型标注的取舍

可以把 object.__eq__ 的标注粗分为三种路线:

路线做法对里氏替换原则== 的静态推理对返回非 bool 的子类
A. 保持 -> bool维持 typeshed 现状与数组等冲突,形式上违背里氏替换原则最好:x == y 可视为 bool只能不标或 # type: ignore
B. 改为 -> Any基类返回 Any子类可返回任意类型,override 合法很差:x == y 失去具体类型可标 -> NDArray[bool]
C. 特例化object-> bool,子类单独标并接受违背里氏替换原则对子类显式违背里氏替换原则在已知类型上较好;object 上仍 bool# type: ignore[override] 或 checker 特判

object.__eq__ -> Any 的问题

若采用 B,把 object.__eq__ 视为 -> Any

  1. 丧失对 == 的静态约束 对类型为 object 的变量,x == y 只能是 Any,无法在 if x == y: 等分支中利用「结果为 bool」做窄化或优化。

  2. 掩盖错误的 __eq__ 实现 当前 -> bool 至少能在子类写出 -> int-> list 时给出不兼容提示;改成 -> Any 后,任何返回类型都「合法」,类型检查器无法再帮你抓这类错误。

  3. 与文档和教学不一致 官方教程和 PEP 都鼓励 __eq__ 返回 boolNotImplemented;类型系统若退到 Any,就相当于承认「我们放弃用类型表达这份契约」,对新手和规范遵守者都不友好。

  4. 不解决 primitives 的 NotImplemented 建模问题 Discuss 的讨论 更关注的是:float.__eq__(str) 等会在运行时返回 NotImplemented,若把 other 标成 object,就无法在类型上区分「会返回 bool」和「会返回 NotImplemented」的情况。把 object.__eq__ 改成 Any 对 primitives 的精确建模没有帮助,只是把不精确往上挪了一层。

结论

长远来看,若希望返回数组的 __eq__ 在类型系统中不被当成异类,要么需要类型系统对 __eq__特殊重载规则(例如对 NDArray== 单独推断为 NDArray[bool]),要么需要在 PEP / typeshed 层面对「可返回非 bool 的 __eq__」给出显式例外说明,把「违背里氏替换原则」从隐式变成显式约定。在此之前,object.__eq__ -> bool 加上「不标」或 # type: ignore[override],是各方在当下最务实的妥协。

参考资料


建议修改

上一篇
PyCapsule:Python 与其他语言之间的指针传递桥梁
下一篇
Pytest:Python 测试框架入门指南