How do you implement operator overloading correctly, and what pitfalls come with `__eq__`/`__hash__`?
Quick Answer
Implement the relevant dunder method (`__add__`, `__lt__`, `__eq__`, etc.) and return `NotImplemented` (not raise an error) when the other operand's type isn't supported, so Python can try the reflected method (`__radd__`) or the other object's comparison. The biggest pitfall: overriding `__eq__` without also defining `__hash__` makes instances **unhashable**, since Python sets `__hash__ = None` automatically whenever `__eq__` is customized.
Detailed Answer
Returning NotImplemented, not raising
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented # let Python try other.__radd__(self)
return Vector(self.x + other.x, self.y + other.y)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
Vector(1, 2) + Vector(3, 4) # Vector(4, 6)
Vector(1, 2) + 5 # TypeError: unsupported operand type(s)
Returning NotImplemented (a sentinel, not an exception) tells Python
"I don't know how to handle this operand" — Python then tries the
right-hand operand's __radd__, and only raises TypeError if that also
fails. Raising an exception directly from __add__ would skip that
fallback and could produce a worse error, or wrongly prevent a valid
reflected operation.
The __eq__/__hash__ contract
Python's rule: if two objects are equal (a == b), they must have the
same hash, because dicts/sets rely on this to find matching keys.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
hash(Point(1, 2)) # TypeError: unhashable type: 'Point'
The moment you define __eq__, Python sets __hash__ = None on the class
automatically, because the inherited identity-based __hash__ from
object would now violate the contract (two equal-by-value Points would
have different hashes, breaking dict/set lookups).
The fix
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y)) # consistent with __eq__
Hash based on exactly the same fields used in __eq__ — this guarantees
equal objects hash equally. If a class is meant to be mutable and
therefore intentionally unhashable (a common convention: mutable objects
shouldn't be dict keys, since mutating them after insertion would corrupt
the hash table), it's fine to leave __hash__ as None deliberately —
just make that choice consciously rather than by accident.
Rich comparison methods
For ordering, implement __lt__, __le__, __gt__, __ge__
individually, or use functools.total_ordering to derive the rest from
just __eq__ and one of them.
Interview-ready summary: Dunder operator methods should return
NotImplemented (never raise) for unsupported operand types, so Python
can fall back to the reflected method. Defining __eq__ silently disables
the default __hash__; if instances need to be hashable, define
__hash__ explicitly using the same fields as __eq__.