As any class that returns a non-boolean in its __eq__
is effectively breaking the Liskov substitution principle, I don’t think we’ll be able to create a consistent type for __eq__
without forcing object
’s subclasses to use a # type: ignore
due to incompatible overwrite.
Solution:
This is my suggestion regarding the __eq__
's type hint in order for the hint to correctly reflect CPython’s implementation of the equality check process:
For object
:
class object:
def __eq__(self, other: object) -> NotImplemented: ...
For other builtin classes, look at the CPython
’s implementation of __eq__
to determine their appropriate type hints. For example, the float
’s and complex
’s __eq__
will be defined as:
class float:
def __eq__(self, other: int | float) -> bool: ...
class complex:
def __eq__(self, other: int | float | complex) -> bool: ...
That’s because based on CPython’s code float’s __eq__
will return an UnimplementedError if the other operand is not an int or float, while complex’s __eq__
will return an UnimplementedError if the other operand is not an int, float, or complex. Take a look at float_richcompare
and complex_richcompare
functions in CPython’s source code.
Rational
I understand that this will require almost all __eq__
overwrites to have a # type: ignore
comment due to them being incompatible with the object
’s one, but the reality of the matter is that the way that Python’s equality check works forces us to do so if we want to have type hints that reflects the runtime’s behavior.
Also I understand that defining the object’s __eq__
in a way that always returns a NotImplemented
is controversial, but that’s how the CPython’s interpreter is working. It’s a little hard to explain my rational for this, so bare with me.
First, Keep in mind that when python encounters ==
it first calls the LHS’s __eq__
, if it does not exist or returns a NotImplemented
, then it calls __eq__
of the RHS. If it does not exist or returns a NotImplemented
then it compare’s the object pointers (i.e: if the LHS and RHS are same object in the momory it returns True, otherwise False).
Now take a look at following code:
class A:
def __eq__(self, other):
print("A")
return NotImplemented
class B:
def __eq__(self, other):
print("B")
return NotImplemented
class C:
def __eq__(self, other):
print("C")
return True
object() == object() # (1) Evaluates to False, as their distinct objects
A() == B() # (2) Prints 'A' then 'B' then evaluates to False, as their distinct objects
B() == A() # (3) Prints 'B' then 'A' then evaluates to False, as their distinct objects
object() == A() # (4) Prints 'A' then evaluates to False, as their distinct objects
A() == object() # (5) Prints 'A' then evaluates to False, as their distinct objects
object() == C() # (6) Prints 'C' then evaluates to True, as C's __eq__ always return True
C() == object() # (7) Prints 'C' then evaluates to True, as C's __eq__ always return True
A() == C() # (8) Prints 'A' then 'C' then evaluates to True, as C's __eq__ always return True
C() == A() # (9) Prints 'C' then evaluates to True, as C's __eq__ always return True
These results tells me that object()
does not have an __eq__
implementation (look at (4)
& (5)
) and unless the other value defines a valid __eq__
, the equality check will fall back to the object pointer comparison (look at (6)
& (7)
).
Again, I know this proposal in controversial, but I think it’s the only way to type hint __eq__
in a way the lets type checkers infer the correct result of a comparison when comparing a builtin object with a custom object with a non-standard __eq__
overwrite.