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
- What a magic method is (and one you already know)
__str__vs__repr__: settling the uglyprint__eq__: when two books are the same book__lt__: sorting withsorted()withoutkey=- Papyrus's
Cartclass __len__,__contains__and__add__in the cart- Table of the most used dunders
- Don't overuse the magic
- Common mistakes and tips
- 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 byprint(),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 identicalFor 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 wayodyssey == 42returnsFalsecleanly instead of crashing, and Python can give the other operand a chance. It's one of the legitimate uses ofisinstance(05-03): checking before comparing, not deciding behavior.- With
__eq__defined,!=works on its own (Python derives it).
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 untouchedThree design decisions worth underlining:
__contains__reuses__eq__: sinceBookcompares by normalized title,Book(" hamlet ", 0) in julia_cartisTrue. 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 anif, soif 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() * 3or a methodsubtotal(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. Writestr(book)andlen(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 astr— don'tprintinside. - Raising an error in
__eq__for foreign types instead of returningNotImplemented: it breaks things as basic asbook in mixed_list. Check withisinstanceand delegate withNotImplemented. - 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
selfinside__add__:+must create a new object. If you want to mutate, the operator is+=(__iadd__) or, better while you're starting out, anadd()method. - A
__str__that hides information during debugging: for inspection userepr()(or!rin 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")) # TrueThe 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
