Since 05-01 we've been carrying a debt: print(odyssey) shows <__main__.Book object at 0x...>. And there are more silent shortcomings: two Book objects with the same title are not equal for ==, sorted() can't order them without key=, and len() rejects them. The built-in types do all of this naturally because they implement magic methods (or dunder methods, for double underscore): methods named __like_this__ that Python invokes automatically when you use operators, print, len, in, sorted... In this lesson your classes will learn that language, with a new vehicle in the Papyrus storyline: the Cart class, the shopping basket of Ana's store.

Contents

  1. What a magic method is (and one you already know)
  2. __str__ vs __repr__: settling the ugly print
  3. __eq__: when two books are the same book
  4. __lt__: sorting with sorted() without key=
  5. Papyrus's Cart class
  6. __len__, __contains__ and __add__ in the cart
  7. Table of the most used dunders
  8. Don't overuse the magic
  9. Common mistakes and tips
  10. Exercises with solutions

What a magic method is (and one you already know)

A magic method is a method with a double underscore at the beginning and end that you define but never call: Python calls it when you use certain syntax. You already know one: __init__, which runs on its own when you write Book(...). The mental table is always the same:

You write... Python runs...
Book("Hamlet", 9.95) Book.__init__(obj, "Hamlet", 9.95)
print(book) / str(book) book.__str__()
book_a == book_b book_a.__eq__(book_b)
book_a < book_b book_a.__lt__(book_b)
len(cart) cart.__len__()
book in cart cart.__contains__(book)
cart_a + cart_b cart_a.__add__(cart_b)

Here is the mechanism 05-03 promised to reveal: len() is polymorphic because each type brings its own __len__. Python's operators are, deep down, polymorphic messages.

__str__ vs __repr__: settling the ugly print

Python distinguishes two text representations of an object:

  • __str__: for people. Used by print(), str() and f-strings. It should be readable and pleasant.
  • __repr__: for programmers. Used by the interactive console, debugging messages and collections (print(list_of_books) uses each element's __repr__). The golden convention: it should look like the code needed to recreate the object.
class Book:
    BOOK_VAT = 0.04
    MEMBER_DISCOUNT = 0.05

    def __init__(self, title, price, stock=0):
        self.title = title
        self.price = price
        self.stock = stock

    def final_price(self, member=False):
        discount = Book.MEMBER_DISCOUNT if member else 0
        return round(self.price * (1 - discount) * (1 + Book.BOOK_VAT), 2)

    def __str__(self):
        return f"{self.title} ({self.price:.2f} EUR, stock: {self.stock})"

    def __repr__(self):
        return f"Book({self.title!r}, {self.price}, {self.stock})"
odyssey = Book("The Odyssey", 12.50, 4)

print(odyssey)             # The Odyssey (12.50 EUR, stock: 4)         ← __str__
print(f"New: {odyssey}")   # New: The Odyssey (12.50 EUR, stock: 4)    ← __str__
print([odyssey])           # [Book('The Odyssey', 12.5, 4)]            ← __repr__
odyssey                    # Book('The Odyssey', 12.5, 4)  (in the interactive console)

Debt from 05-01 settled. Two notes: the !r inside the f-string requests the value's repr (that's why the title comes out with quotes), and if you only define __repr__, print() will use it as a fallback — which is why, if you're only going to write one of the two, make it __repr__.

__eq__: when two books are the same book

Without __eq__, == compares identity (are they the same object in memory?), not content:

a = Book("Hamlet", 9.95, 6)
b = Book("Hamlet", 9.95, 6)
print(a == b)   # False (!) — distinct objects, even though identical

For Papyrus we settle on a business rule: two books are the same if their normalized titles match — the same strip().casefold() normalization that find_book() has used since 04-05:

    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.title.strip().casefold() == other.title.strip().casefold()
  • NotImplemented (without raising an error) tells Python "I don't know how to compare myself with that": this way odyssey == 42 returns False cleanly instead of crashing, and Python can give the other operand a chance. It's one of the legitimate uses of isinstance (05-03): checking before comparing, not deciding behavior.
  • With __eq__ defined, != works on its own (Python derives it).
print(Book("Hamlet", 9.95) == Book("  hamlet ", 12.00))   # True: same normalized title

A side effect you must know about: when you define __eq__, Python disables the default hash and the object can no longer be stored in sets or used as a dictionary key (04-04). It's deliberate: equality and hash must be consistent. The fix (defining __hash__) is noted in the final table; the dataclass in the next lesson will handle it for us.

__lt__: sorting with sorted() without key=

sorted() only needs to know whether one element is "less than" another: exactly what __lt__ (less than) expresses. We define the natural order of Papyrus books as alphabetical by normalized title:

    def __lt__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.title.casefold() < other.title.casefold()
shelf = [Book("Hamlet", 9.95, 6), Book("Faust", 21.00, 0),
         Book("The Odyssey", 12.50, 4), Book("Don Quixote", 15.90, 8)]

for book in sorted(shelf):        # no key= needed!
    print(book)
Don Quixote (15.90 EUR, stock: 8)
Faust (21.00 EUR, stock: 0)
Hamlet (9.95 EUR, stock: 6)
The Odyssey (12.50 EUR, stock: 4)

min(), max() and list.sort() also work now without key=. Design criterion: define __lt__ only if your class has an obvious natural order. If you sometimes sort by price and sometimes by stock, there is no natural order: keep using key= with lambdas (03-03) — that's what it's for.

Papyrus's Cart class

Ana's customers fill baskets, and a basket is begging to behave like a collection: to have a length, to be asked "is this book in here?", to be added to another. It's composition (05-02): the cart has books.

class Cart:
    """The shopping basket of a Papyrus customer."""

    def __init__(self, customer, books=None):
        self.customer = customer
        self._books = list(books) if books else []   # defensive copy (04-01)

    def add(self, book):
        self._books.append(book)

    def total(self, member=False):
        return round(sum(book.final_price(member) for book in self._books), 2)

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

    def __contains__(self, wanted):
        return any(book == wanted for book in self._books)

    def __add__(self, other):
        if not isinstance(other, Cart):
            return NotImplemented
        name = f"{self.customer} and {other.customer}"
        return Cart(name, self._books + other._books)   # a NEW cart!

    def __str__(self):
        return f"{self.customer}'s cart: {len(self)} items, {self.total():.2f} EUR"

    def __repr__(self):
        return f"Cart({self.customer!r}, {self._books!r})"

__len__, __contains__ and __add__ in the cart

odyssey = Book("The Odyssey", 12.50, 4)
hamlet = Book("Hamlet", 9.95, 6)
quixote = Book("Don Quixote", 15.90, 8)

julia_cart = Cart("Julia", [odyssey, hamlet])
omar_cart = Cart("Omar", [quixote])

print(len(julia_cart))              # 2      → __len__
print(hamlet in julia_cart)         # True   → __contains__
print(quixote in julia_cart)        # False

joint_order = julia_cart + omar_cart          # __add__
print(joint_order)                  # Julia and Omar's cart: 3 items, 39.89 EUR
print(len(julia_cart))              # 2 → the originals remain untouched

Three design decisions worth underlining:

  • __contains__ reuses __eq__: since Book compares by normalized title, Book(" hamlet ", 0) in julia_cart is True. Dunders lean on each other — consistency for free.
  • __add__ returns a NEW cart and doesn't modify the operands, just like [1, 2] + [3] creates a new list. A + that mutated its operands would be a trap for whoever uses it.
  • __len__ gives you truthiness for free (02-01): a cart whose __len__ is 0 is falsy in an if, so if cart: means "if it has anything in it" — exactly like lists and dictionaries.
  • And did adding carts make sense? Here it does (Julia and Omar share an order to save on shipping): + has an obvious meaning in the domain. That's the yardstick, as you'll see in a moment.

Table of the most used dunders

Method Triggered by Must return At Papyrus
__init__ Class(...) None Initialize book/cart
__str__ print(), str(), f-strings readable str "The Odyssey (12.50 EUR, stock: 4)"
__repr__ Console, debugging, collections recreatable str "Book('The Odyssey', 12.5, 4)"
__eq__ ==, !=, in (via default contains) bool or NotImplemented Same normalized title
__lt__ <, sorted(), min(), max() bool or NotImplemented Alphabetical order
__hash__ sets, dict keys int consistent with __eq__ Needed if you define __eq__ and want sets
__len__ len(), truthiness int ≥ 0 Items in the cart
__contains__ x in obj bool Is this book in the cart?
__add__ + New object or NotImplemented Merging carts
__getitem__ obj[key], slicing The element (We don't need it... yet)
__iter__ for x in obj An iterator Coming with generators (M8)

Don't overuse the magic

Magic methods are extremely potent sugar, and dangerous for the same reason. The rule: implement a dunder only if the operation has an obvious, indisputable meaning in your domain.

  • cart_a + cart_b? Sure: merging orders. ✔
  • book_a + book_b? What would that be: a double volume, adding prices, concatenating titles? Ambiguous → explicit named method (bundle_with(other) or similar). ✘
  • cart - book? Debatable; cart.remove(book) is understood without opening the docs. ✘
  • book * 3? No: book.final_price() * 3 or a method subtotal(units=3). ✘

Whoever reads a + b can't look up the manual for every + in the program. If you're torn between an operator and a named method, the name wins. The representation dunders (__str__, __repr__) are the exception: define them almost always — they never get in the way.

Common Mistakes and Tips

  • Calling the dunders directly (book.__str__(), cart.__len__()): it works, but it's poor style. Write str(book) and len(cart); dunders are for defining, not for calling.
  • Returning something that isn't a string from __str__/__repr__: TypeError: __str__ returned non-string. Always return a str — don't print inside.
  • Raising an error in __eq__ for foreign types instead of returning NotImplemented: it breaks things as basic as book in mixed_list. Check with isinstance and delegate with NotImplemented.
  • Defining __eq__ and forgetting the effect on __hash__: your objects will stop fitting into sets and dicts (TypeError: unhashable type). If you need them there, define __hash__ over the same data as equality: def __hash__(self): return hash(self.title.strip().casefold()).
  • Mutating self inside __add__: + must create a new object. If you want to mutate, the operator is += (__iadd__) or, better while you're starting out, an add() method.
  • A __str__ that hides information during debugging: for inspection use repr() (or !r in f-strings) — that's why the pair exists.
  • Tip: the healthy writing order in a new class is: __init____repr____str__ → and only then, whichever operators the domain is begging for.

Exercises

Exercise 1: a presentable, comparable Member

Give the Member class (name, code) a canonical __repr__, a __str__ like "Luis (LUIS-001)" and an __eq__ that considers two members equal when they have the same code (the name doesn't matter). Verify that Member("Luis", "LUIS-001") == Member("Lewis", "LUIS-001") is True.

Exercise 2: the measurable, sorted library

Create a Library class that contains Book objects (composition) with: __len__ (total number of copies, adding up stocks — careful, not the number of titles), __contains__ (by book equality) and __str__ ("Papyrus Library: 18 copies across 4 titles"). Test it with the canonical catalog (stocks 4, 6, 8, 0).

Exercise 3: summing a list of carts

At closing time, Ana wants to merge all the pending carts with sum(list_of_carts). As is, it fails: sum starts by computing 0 + first_cart, and neither int nor our __add__ knows how to resolve that. Implement the minimal magic method __radd__ (reflected addition) in Cart so it works: it must accept the 0 + cart case by returning the cart itself, and delegate with NotImplemented in every other case.

Solutions

Solution 1:

class Member:
    def __init__(self, name, code):
        self.name = name
        self.code = code

    def __repr__(self):
        return f"Member({self.name!r}, {self.code!r})"

    def __str__(self):
        return f"{self.name} ({self.code})"

    def __eq__(self, other):
        if not isinstance(other, Member):
            return NotImplemented
        return self.code == other.code

print(Member("Luis", "LUIS-001") == Member("Lewis", "LUIS-001"))   # True

The member code is the business identifier; the name is just presentation. Deciding what equality means is design, not syntax.

Solution 2:

class Library:
    def __init__(self, name, books=None):
        self.name = name
        self._books = list(books) if books else []

    def __len__(self):
        return sum(book.stock for book in self._books)

    def __contains__(self, wanted):
        return any(book == wanted for book in self._books)

    def __str__(self):
        return f"{self.name} Library: {len(self)} copies across {len(self._books)} titles"

papyrus = Library("Papyrus", [
    Book("The Odyssey", 12.50, 4), Book("Hamlet", 9.95, 6),
    Book("Don Quixote", 15.90, 8), Book("Faust", 21.00, 0),
])
print(papyrus)                              # Papyrus Library: 18 copies across 4 titles
print(Book("faust", 0) in papyrus)          # True (equality by normalized title)

Notice that len() answers a business question (copies), not the trivial one (titles): you decide the semantics, but document it, because whoever reads len(papyrus) will assume something.

Solution 3:

class Cart:
    # ... everything as before ...

    def __radd__(self, other):
        if other == 0:               # sum() starts with 0: 0 + first_cart
            return self
        return NotImplemented

total = sum([julia_cart, omar_cart])
print(total)   # Julia and Omar's cart: 3 items, 39.89 EUR

__radd__ (reflected addition) is invoked when the left operand doesn't know how to add with us: 0 + cart fails in int.__add__ and Python tries cart.__radd__(0). Returning self in that case lets sum() chain the normal __add__ calls afterwards.

Conclusion

Debt settled: print(odyssey) now speaks clearly thanks to __str__, with __repr__ covering debugging; __eq__ fixed Papyrus's business rule (same normalized title, same book) while returning NotImplemented to strangers; __lt__ gave sorted() the alphabetical order without key=; and the Cart behaves like a full-fledged collection with __len__, __contains__ and an __add__ that creates new carts without mutating the originals. You also learned the brake: only operators with indisputable meaning, and explicit names for everything else. But let's be honest about the price paid: between __init__, __repr__, __eq__ and __lt__, the Book class has already piled up a heap of repetitive code that just recites its attributes over and over. Python knows it, and ships the fix as standard: dataclasses, which generate all that machinery automatically in one line. With them we'll close the module — and leave the Papyrus catalog in its definitive form.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved