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:

  1. Identity Check: If the other object is the same instance as self, they are obviously equal. This is a cheap check that can optimize the common case.
  2. Type Check: Check if the other object is an instance of the same class. Using isinstance() is generally preferred over type() for this check because it allows for subclasses. If it’s not the correct type, return NotImplemented.
  3. 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

  1. Always Return NotImplemented for Unsupported Types: Do not raise a TypeError yourself inside __eq__ or __lt__. Returning NotImplemented is the correct protocol. It tells Python to try the reflected operation on the other object, or if that also fails, then Python will raise a TypeError itself. This maintains flexibility and is consistent with how built-in types behave.

  2. 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__ to None) to prevent you from accidentally creating bugs. For mutable objects, this is the correct behavior.

  3. Ensure Consistency: Your comparison logic must be consistent. If a == b is True and b == c is True, then a == c must also be True. Similarly, if a < b is True and b < c is True, then a < c must be True. Violating these rules will lead to unpredictable behavior in sorting and other operations that depend on comparisons.

  4. isinstance vs. type: Using isinstance(other, MyClass) in your type check, as opposed to type(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, then type is appropriate.

  5. Performance of @total_ordering: While convenient, the methods generated by @total_ordering can be slower than hand-written ones because they may need to perform multiple operations (e.g., a >= b is implemented as not a < b). For performance-critical code, manually implementing the necessary methods might be beneficial.