DEV Community

Cover image for The Special Protocols Room: Magic Methods and Operator Overloading
Aaron Rose
Aaron Rose

Posted on

The Special Protocols Room: Magic Methods and Operator Overloading

Timothy had built a working Book class, but something felt incomplete. He couldn't sort a list of books by year. He couldn't compare two books to see if they were equal. He couldn't use len() on a book to get page count. His custom class felt like a second-class citizen compared to Python's built-in types.

books = [
    Book("Foundation", "Asimov", 1951, 255),
    Book("Dune", "Herbert", 1965, 412),
    Book("1984", "Orwell", 1949, 328)
]

# Can't do this - TypeError!
# sorted_books = sorted(books)

# Can't do this meaningfully
dune = Book("Dune", "Herbert", 1965, 412)
dune_copy = Book("Dune", "Herbert", 1965, 412)
print(dune == dune_copy)  # False - different objects!

# Can't do this - TypeError!
# print(len(dune))
Enter fullscreen mode Exit fullscreen mode

Margaret found him frustrated. "Your class lacks the special protocols," she explained, leading him to a room filled with mystical-looking mechanisms—the Special Protocols Room. "Python provides magic methods—special names enclosed in double underscores—that let your classes speak Python's native language."

The Equality Protocol: eq

Margaret showed Timothy how to make books comparable:

class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __eq__(self, other):
        # Two books are equal if they have the same title and author
        if not isinstance(other, Book):
            return False
        return self.title == other.title and self.author == other.author

dune1 = Book("Dune", "Herbert", 1965, 412)
dune2 = Book("Dune", "Herbert", 1965, 412)
foundation = Book("Foundation", "Asimov", 1951, 255)

print(dune1 == dune2)       # True - same title and author
print(dune1 == foundation)  # False - different books
Enter fullscreen mode Exit fullscreen mode

"When you use ==, Python calls __eq__," Margaret explained. "You define what equality means for your class."

The Comparison Protocol: Rich Comparison Methods

Timothy learned to make books sortable:

class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.year == other.year

    def __lt__(self, other):
        # Less than - compare by year
        if not isinstance(other, Book):
            return NotImplemented
        return self.year < other.year

    def __le__(self, other):
        # Less than or equal
        return self == other or self < other

    def __gt__(self, other):
        # Greater than
        if not isinstance(other, Book):
            return NotImplemented
        return self.year > other.year

    def __ge__(self, other):
        # Greater than or equal
        return self == other or self > other

books = [
    Book("Foundation", "Asimov", 1951, 255),
    Book("Dune", "Herbert", 1965, 412),
    Book("1984", "Orwell", 1949, 328)
]

# Now sorting works!
sorted_books = sorted(books)
for book in sorted_books:
    print(f"{book.title} ({book.year})")
# 1984 (1949)
# Foundation (1951)
# Dune (1965)
Enter fullscreen mode Exit fullscreen mode

"The comparison methods are __lt__, __le__, __eq__, __ne__, __gt__, __ge__," Margaret noted. "Define just __eq__ and __lt__, and Python can figure out the rest using the @functools.total_ordering decorator."

The Total Ordering Shortcut

Margaret showed Timothy a simpler approach:

from functools import total_ordering

@total_ordering
class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.year == other.year

    def __lt__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.year < other.year

# Now all comparison operators work!
# Python generates __le__, __gt__, __ge__ automatically
Enter fullscreen mode Exit fullscreen mode

"The @total_ordering decorator generates the missing comparison methods," Margaret explained. "Just define __eq__ and one ordering method (__lt__ is conventional), and you get all six for free."

The String Representation Protocol: str and repr

Timothy had learned about these earlier, but Margaret emphasized their importance:

class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __str__(self):
        # For end users - readable
        return f'"{self.title}" by {self.author} ({self.year})'

    def __repr__(self):
        # For developers - unambiguous, reproducible
        return f'Book("{self.title}", "{self.author}", {self.year}, {self.pages})'

dune = Book("Dune", "Herbert", 1965, 412)

print(dune)        # Uses __str__: "Dune" by Herbert (1965)
print(repr(dune))  # Uses __repr__: Book("Dune", "Herbert", 1965, 412)
print([dune])      # Lists use __repr__: [Book("Dune", "Herbert", 1965, 412)]
Enter fullscreen mode Exit fullscreen mode

"Always implement both," Margaret advised. "If you only implement __repr__, Python uses it for __str__ too. But having both gives you flexibility."

The Length Protocol: len

Timothy learned to make len() work:

class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __len__(self):
        return self.pages

dune = Book("Dune", "Herbert", 1965, 412)
print(len(dune))  # 412
Enter fullscreen mode Exit fullscreen mode

"When you call len(obj), Python calls obj.__len__()," Margaret explained. "Your class can participate in Python's built-in functions."

The Container Protocol: getitem, setitem, and iter

Margaret showed Timothy how to make objects indexable and iterable:

class BookCollection:
    def __init__(self, books=None):
        self.books = books if books else []

    def add_book(self, book):
        self.books.append(book)

    def __len__(self):
        return len(self.books)

    def __getitem__(self, index):
        # Makes collection indexable and sliceable
        return self.books[index]

    def __setitem__(self, index, book):
        # Makes collection writable
        self.books[index] = book

    def __iter__(self):
        # Explicit iteration protocol - more efficient than __getitem__
        return iter(self.books)

    def __contains__(self, book):
        # Enables 'in' operator
        return book in self.books

collection = BookCollection()
collection.add_book(Book("Dune", "Herbert", 1965, 412))
collection.add_book(Book("Foundation", "Asimov", 1951, 255))

# Indexing works
print(collection[0].title)     # "Dune"

# Length works
print(len(collection))         # 2

# Iteration works (uses __iter__)
for book in collection:
    print(book.title)

# Slicing works (uses __getitem__)
first_two = collection[0:2]

# Membership testing works (uses __contains__)
dune = Book("Dune", "Herbert", 1965, 412)
if dune in collection:
    print("Found Dune!")
Enter fullscreen mode Exit fullscreen mode

"The __getitem__ method makes your object indexable and sliceable," Margaret explained. "The __iter__ method makes iteration explicit and efficient. The __contains__ method enables the in operator."

The Arithmetic Protocols: Operator Overloading

Timothy learned to define mathematical operations:

class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __add__(self, other):
        # Combine books into a collection
        if isinstance(other, Book):
            return BookCollection([self, other])
        return NotImplemented

    def __len__(self):
        return self.pages

    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.title == other.title and self.author == other.author

    def __hash__(self):
        return hash((self.title, self.author))

class BookCollection:
    def __init__(self, books=None):
        self.books = books if books else []

    def __len__(self):
        return sum(len(book) for book in self.books)

    def __add__(self, other):
        # Create new collection
        if isinstance(other, Book):
            return BookCollection(self.books + [other])
        elif isinstance(other, BookCollection):
            return BookCollection(self.books + other.books)
        return NotImplemented

    def __iadd__(self, other):
        # In-place addition: collection += book
        if isinstance(other, Book):
            self.books.append(other)
            return self  # Must return self!
        elif isinstance(other, BookCollection):
            self.books.extend(other.books)
            return self
        return NotImplemented

dune = Book("Dune", "Herbert", 1965, 412)
foundation = Book("Foundation", "Asimov", 1951, 255)
stranger = Book("Stranger in a Strange Land", "Heinlein", 1961, 408)

# Add books together - creates new collection
series = dune + foundation
print(len(series))  # 667 total pages

# Add more books - creates new collection each time
expanded = series + stranger
print(len(expanded))  # 1075 total pages

# In-place addition - modifies existing collection
collection = BookCollection([dune])
collection += foundation  # Uses __iadd__
print(len(collection.books))  # 2 books
Enter fullscreen mode Exit fullscreen mode

"Arithmetic operators like +, -, * all have magic methods," Margaret explained. "The __add__ method creates a new object, while __iadd__ modifies in place. Python uses __iadd__ for += if available, otherwise falls back to __add__."

The Boolean Protocol: bool

Timothy learned to define truthiness:

class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __bool__(self):
        # A book is truthy if it has pages
        return self.pages > 0

empty_book = Book("Draft", "Unknown", 2025, 0)
real_book = Book("Dune", "Herbert", 1965, 412)

if real_book:
    print("This book exists!")  # Prints

if not empty_book:
    print("This book is empty!")  # Prints
Enter fullscreen mode Exit fullscreen mode

The Context Manager Protocol: enter and exit

Margaret gave Timothy a glimpse of a more advanced pattern:

class BookLoan:
    def __init__(self, book, borrower):
        self.book = book
        self.borrower = borrower

    def __enter__(self):
        print(f"Checking out '{self.book.title}' to {self.borrower}")
        self.book.is_checked_out = True
        return self.book

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Returning '{self.book.title}'")
        self.book.is_checked_out = False
        return False  # Don't suppress exceptions

dune = Book("Dune", "Herbert", 1965, 412)

with BookLoan(dune, "Timothy") as book:
    print(f"Reading {book.title}")
# Automatic return when block exits!
Enter fullscreen mode Exit fullscreen mode

"The context manager protocol lets your objects work with with statements," Margaret noted. "We'll explore this more in the Context Managers section."

Common Magic Methods Reference

Margaret showed Timothy the full catalog:

Comparison:

  • __eq__(self, other) - ==
  • __ne__(self, other) - !=
  • __lt__(self, other) - <
  • __le__(self, other) - <=
  • __gt__(self, other) - >
  • __ge__(self, other) - >=
  • __hash__(self) - hash(obj), enables use in sets and as dict keys

Arithmetic:

  • __add__(self, other) - +
  • __sub__(self, other) - -
  • __mul__(self, other) - *
  • __truediv__(self, other) - /
  • __floordiv__(self, other) - //
  • __mod__(self, other) - %
  • __pow__(self, other) - **

In-Place Arithmetic:

  • __iadd__(self, other) - +=
  • __isub__(self, other) - -=
  • __imul__(self, other) - *=
  • __itruediv__(self, other) - /=

Container:

  • __len__(self) - len(obj)
  • __getitem__(self, key) - obj[key]
  • __setitem__(self, key, value) - obj[key] = value
  • __delitem__(self, key) - del obj[key]
  • __contains__(self, item) - item in obj
  • __iter__(self) - iter(obj), for item in obj

String Representation:

  • __str__(self) - str(obj), print(obj)
  • __repr__(self) - repr(obj), interactive prompt

Other:

  • __bool__(self) - bool(obj), if obj:
  • __call__(self, ...) - obj()

Returning NotImplemented

Margaret emphasized proper error handling:

class Book:
    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented  # Not False!
        return self.title == other.title

# Allows Python to try other.__eq__ if available
# Falls back to default behavior if needed
Enter fullscreen mode Exit fullscreen mode

"Return NotImplemented, not False," Margaret cautioned. "This lets Python try the other object's method or provide appropriate fallback behavior."

Timothy's Magic Methods Wisdom

Through exploring the Special Protocols Room, Timothy learned essential principles:

Magic methods integrate with Python's syntax: They let your classes use operators and built-in functions.

Double underscores signal special meaning: Methods like __eq__, __len__, __add__ are called by Python, not by you directly.

eq and hash work together: If you define __eq__, you must define __hash__ for objects to be usable in sets and as dict keys.

Hash must use immutable attributes: Objects that compare equal must have equal hashes, based on fields that won't change.

Defining eq makes objects unhashable: Python sets __hash__ to None when you define __eq__ without defining __hash__.

eq and lt enable comparisons: With @total_ordering, these two generate all six comparison operators.

str for users, repr for developers: Always implement both for clear output.

len enables len() function: Makes your objects work with built-in functions.

getitem enables indexing and slicing: Makes objects subscriptable.

iter enables explicit iteration: More efficient than relying on __getitem__ for iteration.

contains enables 'in' operator: Define membership testing explicitly.

Arithmetic operators are customizable: __add__, __sub__, __mul__ define what operators mean.

In-place operators modify and return self: __iadd__, __imul__ for +=, *= operations.

bool defines truthiness: Controls how objects behave in conditionals.

Return NotImplemented for type mismatches: Let Python handle fallback behavior properly.

Magic methods are called implicitly: When you write a + b, Python calls a.__add__(b).

Context managers use enter and exit: Enable with statement support.

Don't call magic methods directly: Write len(obj), not obj.__len__().

Timothy had discovered Python's secret language—the protocols that let custom classes behave like built-in types. The Special Protocols Room had revealed that the "magic" wasn't mystical at all—it was a well-defined interface between his classes and Python's syntax. By implementing __eq__ and __hash__ together, his books could live in sets and serve as dictionary keys. With __lt__ and @total_ordering, they sorted naturally. Through __iter__ and __contains__, his collections behaved like native Python sequences. His Books could now sort, compare, add, and integrate seamlessly into Pythonic code—indistinguishable from types built into the language itself.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)