26.7 Comparing Objects: __eq__, __lt__, and @total_ordering
The Need for Custom Comparisons
By default, Python’s == and != operators for objects compare their identities—that is, they check if two variables refer to the exact same object in memory, behaving like the is operator. This is rarely the desired behavior for data-centric classes. For instance, two distinct BankAccount objects with the same account number and balance should be considered equal for most application logic, even though they are separate instances. To enable this value-based comparison, you must provide your own implementation by defining the __eq__ method.
Implementing the __eq__ Method
The __eq__ method ("dunder-eq") is one of Python’s rich comparison methods. It is automatically called when an object is on the left-hand side of an == expression. The method should return True if the objects are deemed equal, False if they are not, and NotImplemented if the comparison with the given type is not supported, allowing the right-hand object’s __eq__ method a chance to run.
A robust implementation of __eq__ involves three key steps:
- Identity Check: If the
otherobject is the same instance asself, they are obviously equal. This is a cheap check that can optimize the common case. - Type Check: Check if the
otherobject is an instance of the same class. Usingisinstance()is generally preferred overtype()for this check because it allows for subclasses. If it’s not the correct type, returnNotImplemented. - Value Check: If the types match, proceed to compare the crucial attributes that define equality for your class.
class BankAccount:
def __init__(self, account_number, balance=0):
self.account_number = account_number
self.balance = balance
def __eq__(self, other):
# 1. Identity check
if self is other:
return True
# 2. Type check
if not isinstance(other, BankAccount):
return NotImplemented
# 3. Value check
return (self.account_number == other.account_number and
self.balance == other.balance)
# Example usage
acc1 = BankAccount("ACC123", 100.0)
acc2 = BankAccount("ACC123", 100.0)
acc3 = BankAccount("ACC456", 100.0)
print(acc1 == acc2) # True (same values)
print(acc1 == acc3) # False (different account_number)
print(acc1 == "not an account") # False (__eq__ returns NotImplemented, so Python tries str.__eq__, which returns False)
Implementing the __lt__ Method
The __lt__ method ("dunder-lt") is called to evaluate whether one object is less than another (<). It follows the same pattern as __eq__, returning a boolean or NotImplemented. Defining __lt__ is essential if you want to sort a list of your objects or use them in data structures that rely on ordering, like a sorted list or as keys in a dictionary (though for the latter, implementing __hash__ is also necessary if __eq__ is defined).
class BankAccount:
def __init__(self, account_number, balance=0):
self.account_number = account_number
self.balance = balance
def __eq__(self, other):
... # As shown above
def __lt__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
# Define ordering based on balance
return self.balance < other.balance
# Example usage
low_balance_acc = BankAccount("ACC111", 50.0)
high_balance_acc = BankAccount("ACC999", 500.0)
print(low_balance_acc < high_balance_acc) # True
print(high_balance_acc < low_balance_acc) # False
accounts = [high_balance_acc, low_balance_acc]
accounts_sorted = sorted(accounts) # This uses __lt__
print([acc.balance for acc in accounts_sorted]) # [50.0, 500.0]
The @total_ordering Decorator
Manually implementing all six rich comparison methods (__lt__, __le__, __gt__, __ge__, __eq__, __ne__) is tedious and error-prone. The functools.total_ordering class decorator mitigates this by automatically generating the missing comparison methods. You must define at least __eq__ and one of the ordering methods (e.g., __lt__). The decorator fills in the rest based on the logic you provided.
from functools import total_ordering
@total_ordering
class BankAccount:
def __init__(self, account_number, balance=0):
self.account_number = account_number
self.balance = balance
def __eq__(self, other):
if self is other:
return True
if not isinstance(other, BankAccount):
return NotImplemented
return (self.account_number == other.account_number and
self.balance == other.balance)
def __lt__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance < other.balance
# Now all comparisons are available
acc1 = BankAccount("ACC123", 100)
acc2 = BankAccount("ACC456", 200)
print(acc1 <= acc2) # True (generated by total_ordering)
print(acc1 > acc2) # False (generated by total_ordering)
print(acc1 != acc2) # True (generated by total_ordering, uses __eq__)
Common Pitfalls and Best Practices
Always Return
NotImplementedfor Unsupported Types: Do not raise aTypeErroryourself inside__eq__or__lt__. ReturningNotImplementedis the correct protocol. It tells Python to try the reflected operation on theotherobject, or if that also fails, then Python will raise aTypeErroritself. This maintains flexibility and is consistent with how built-in types behave.Hashability Implications: If you define
__eq__and your objects are intended to be immutable and used as dictionary keys or in sets, you must also define a__hash__method. The rule is: objects that compare as equal must have the same hash value. If you define__eq__but not__hash__, Python will automatically make the object unhashable (setting__hash__toNone) to prevent you from accidentally creating bugs. For mutable objects, this is the correct behavior.Ensure Consistency: Your comparison logic must be consistent. If
a == bisTrueandb == cisTrue, thena == cmust also beTrue. Similarly, ifa < bisTrueandb < cisTrue, thena < cmust beTrue. Violating these rules will lead to unpredictable behavior in sorting and other operations that depend on comparisons.isinstancevs.type: Usingisinstance(other, MyClass)in your type check, as opposed totype(other) is MyClass, is generally more flexible and adheres to the principle of duck typing. It allows your comparisons to work with subclasses of your type. If strict type checking is a requirement, thentypeis appropriate.Performance of
@total_ordering: While convenient, the methods generated by@total_orderingcan be slower than hand-written ones because they may need to perform multiple operations (e.g.,a >= bis implemented asnot a < b). For performance-critical code, manually implementing the necessary methods might be beneficial.