Session 25 of the Python Fundamentals series, covered in depth. It covers the special double-underscore methods that hook Python's own syntax onto your classes: __init__, which you already know; __str__ against __repr__, which serve print and the shell against containers; __eq__ for value equality; __len__ for len(); __lt__ for sorting; and __add__ for +. The traps are defining __str__ but expecting a list of your objects to use it, when containers print each item with __repr__, and comparing objects with == when there is no __eq__, since Python falls back to identity and two objects with equal values come out False. Every snippet, output line, and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 97 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 25
The double-underscore methods that hook Python's syntax onto your own objects
Objectives
You already write classes with __init__. That name has a shape - two underscores on each side - and it is one of a family Python calls automatically. By the end you can:
<...object at 0x...> repr.__str__ and __repr__, and say which one print, the shell, and lists use.__eq__ so == compares values, not identity.__len__ so len() works on your object.__lt__ so sorted(), min(), and max() work.__add__ so + builds a new object - and avoid the __str__-in-a-list and ==-without-__eq__ traps.Warm-up
Discussion prompt
Before we open Session 25 - Dunder Methods: without looking back, what was the main idea of Session 24 - Methods & Encapsulation, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 24 of the Python Fundamentals series, in depth. Methods that read state versus methods that change it; one method calling another through self; encapsulation - keeping internals private-by-convention with a leading underscore and exposing behavior through methods; protecting an invariant (a balance that must never go negative) by raising ValueError; and the difference between class attributes (shared by every instance) and instance attributes (one per object).
Section
Part 1
Concept
When you write len(x), x == y, or print(x), Python quietly looks for a specially-named method on x and calls it. Define that method and the syntax works on your objects.
dunder method — A method whose name is wrapped in double underscores, like __init__ or __len__. 'Dunder' = 'double underscore'. Python calls them for you when the matching syntax runs - you rarely call them by name.
Counterexample
Discussion prompt
When you write len(x), x == y, or print(x), Python quietly looks for a specially-named method on x and calls it. Define that method and the syntax works on your objects.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
__init__ is a dunder. You never call obj.__init__() yourself - Python runs it when you write ClassName(...). Every dunder works the same way: a name Python watches for.
This session adds five more of the most useful ones, each tied to a piece of syntax you already use on built-in types.
Analogy
Discussion prompt
Explain You already know one: __init__ by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
__init__ is a dunder. You never call obj.__init__() yourself - Python runs it when you write ClassName(...). Every dunder works the same way: a name Python watches for.
Fill the middle
Fill in the blanks
From __init__ builds the object's data — one line has had its right-hand side removed. Put it back.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y)
Why: p is what everything below it consumes, so the wrong expression here fails later and somewhere else. You wrote Point(1, 2), not p.__init__(...).
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y)Point(1, 2) runs __init__ for you
Why: You wrote Point(1, 2), not p.__init__(...). Python called the dunder and passed 1 and 2.
The attributes are now stored on p
Why: Verified by execution: prints 1 2. The rest of this deck adds more dunders to this same Point class.
| expression | value |
|---|---|
| p.x | 1 |
| p.y | 2 |
Comparison
Comparison matrix
From __init__ builds the object's data: refill the value column from what you know. The rest of the table is as it appeared.
| expression | value |
|---|---|
| p.x | 1 |
| p.y | 2 |
Intuition
Think of Python's syntax - +, ==, len(), print(), sorting - as sockets. A dunder method is the plug on your class that fits one of those sockets.
No plug, and the socket either does something generic (print an address, compare identity) or refuses (raises a TypeError). Add the plug and your object behaves like a built-in.
Explain it
Discussion prompt
Explain Dunders are Python's connectors to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Think of Python's syntax - +, ==, len(), print(), sorting - as sockets. A dunder method is the plug on your class that fits one of those sockets.
Section
Part 2
Concept
Print an object with no dunders and Python still shows something: a fallback built into every object. It is technically correct but useless to a human.
That fallback is the default repr - the class name and the object's memory address.
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p)Point has no __str__ or __repr__ yet
Why: So print falls back to the default: the class location and a memory address.
Read the (verified) output
Why: Verified by execution: prints <__main__.Point object at 0x...>. The 0x... hex differs every run - it is where the object lives in memory, not its data.
| you print | you get |
|---|---|
| print(p) | <__main__.Point object at 0x...> |
Cost model
Annotate
In What print shows with no dunders, before reading the notes: mark where the time actually goes. Which line dominates?
Concept
<__main__.Point object at 0x...> says it is a Point and where it sits in memory - but not that x is 1 and y is 2. For debugging, that is exactly what you want to see.
The fix is to define a dunder that returns a useful string. There are two of them, and the difference matters.
Section
Part 3
Concept
__str__ returns the string shown to a human. print(x) and str(x) both call it. Make it readable - it is what an end user or a log line sees.
__str__ — Returns the 'nice' string for an object. Called by print(x) and str(x). If it is missing, Python uses __repr__ instead.
Definition probe
Sort into buckets
Every line below is part of the definition of dunder method or of __str__ — one or the other, never both. Put each where it belongs.
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Point({self.x}, {self.y})"
p = Point(1, 2)
print(p)print(p) now calls __str__
Why: Instead of the memory address, print asks the object for its __str__ string.
Read the (verified) output
Why: Verified by execution: prints Point(1, 2). The f-string builds it from the object's own x and y.
| you print | you get |
|---|---|
| print(p) (no __str__) | <__main__.Point object at 0x...> |
| print(p) (with __str__) | Point(1, 2) |
Trade off
Comparison matrix
From Add __str__ and print improves: every row here is a choice with a cost. Fill the you get column, then say which row you would actually pick and what you give up for it.
| you print | you get |
|---|---|
| print(p) (no __str__) | <__main__.Point object at 0x...> |
| print(p) (with __str__) | Point(1, 2) |
Concept
__repr__ returns a string aimed at a programmer - ideally one that shows exactly what the object is. repr(x) calls it, and so does the interactive shell when you type x and hit Enter.
__repr__ — Returns the 'developer' string for an object. Called by repr(x), by the interactive shell (echoing a value), and by containers (lists, dicts) when they print their items.
Sorting
Sort into buckets
These are the pieces of Session 25 - Dunder Methods, out of order. Put each one back under the part of the lesson it belongs to.
Pattern
Predict first
The table runs: print(p) | __repr__ (no __str__) | Point(1, 2) · repr(p) | __repr__ | Point(1, 2)
In Add __repr__: print, repr, and the shell, given the rows so far: what is the next one — the row where call is p in the shell?
Correct: p in the shell | __repr__ | Point(1, 2)
| call | uses | output |
|---|---|---|
| print(p) | __repr__ (no __str__) | Point(1, 2) |
| repr(p) | __repr__ | Point(1, 2) |
| p in the shell | __repr__ | Point(1, 2) |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. print prefers __str__, but if there is no __str__ it falls back to __repr__ - so both lines use the same string here.
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
p = Point(1, 2)
print(p)
print(repr(p))With only __repr__, print uses it too
Why: print prefers __str__, but if there is no __str__ it falls back to __repr__ - so both lines use the same string here.
Read the (verified) output
Why: Verified by execution: prints Point(1, 2) on both lines. In the shell, just typing p would also echo Point(1, 2).
| call | uses | output |
|---|---|---|
| print(p) | __repr__ (no __str__) | Point(1, 2) |
| repr(p) | __repr__ | Point(1, 2) |
| p in the shell | __repr__ | Point(1, 2) |
Error analysis
Annotate
Walk the callouts on Add __repr__: print, repr, and the shell. Each one is a place this is easy to get subtly wrong.
Intuition
__str__ is the label on the box - short and friendly. __repr__ is the packing slip - precise enough that another programmer (or future you) knows exactly what is inside.
A common habit: always write __repr__, and add __str__ only when the friendly form should differ from the precise one.
Concept
If a class defines __repr__ but not __str__, then str(x) and print(x) use __repr__. The reverse is not true: defining only __str__ does not give you a good __repr__.
That is why __repr__ is the one to reach for first - it covers more situations.
Faded example
Fill in the blanks
Only __repr__ covers str and lists, with the scaffolding fading: two lines are gone now — fill both.
class Dog:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Dog(Dog("Rex"))"
d = ___
print(d)
print(str(d))
print([d])
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. With no __str__, all three routes fall back to __repr__.
Worked example
class Dog:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Dog({self.name!r})"
d = Dog("Rex")
print(d)
print(str(d))
print([d])__repr__ serves print, str, and the list
Why: With no __str__, all three routes fall back to __repr__. The !r in the f-string inserts repr('Rex'), so the name shows with quotes.
Read the (verified) output
Why: Verified by execution: prints Dog('Rex') three times. One well-written __repr__ covered every case.
| call | output |
|---|---|
| print(d) | Dog('Rex') |
| print(str(d)) | Dog('Rex') |
| print([d]) | [Dog('Rex')] |
Pattern
Step through it
Step through Only __repr__ covers str and lists one row at a time. What is driving the change, and what would the row after the last one be?
Concept
Define both when the human form and the precise form are genuinely different. A temperature might print as 20 degrees but repr as Temp(20) - the first reads nicely, the second shows the exact object.
Each route then picks its own: print and str() take __str__; repr(), the shell, and containers take __repr__.
Worked example
class Temp:
def __init__(self, c):
self.c = c
def __str__(self):
return f"{self.c} degrees"
def __repr__(self):
return f"Temp({self.c})"
t = Temp(20)
print(t)
print(repr(t))
print([t])print takes __str__; repr and the list take __repr__
Why: print(t) uses the friendly __str__; repr(t) and the list both use __repr__ - each syntax routes to the dunder it is tied to.
Read the (verified) output
Why: Verified by execution: prints 20 degrees, then Temp(20), then [Temp(20)]. Same object, two purpose-built strings.
| call | dunder used | output |
|---|---|---|
| print(t) | __str__ | 20 degrees |
| repr(t) | __repr__ | Temp(20) |
| [t] | __repr__ | [Temp(20)] |
Pattern
Step through it
Step through Both defined: each route picks its own one row at a time. What is driving the change, and what would the row after the last one be?
Anomaly
Predict first
A student writes this, and it looks reasonable:
You define __str__, then print a list of objects and expect the friendly strings.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: A container prints each item with __repr__, not __str__.
Define __repr__ (the form containers use). Then lists show it - and print/str fall back to it too.
Why: A container prints each item with __repr__, not __str__. Point has no __repr__, so you get the default addresses back - exactly what you were trying to avoid.
Trap
You define __str__, then print a list of objects and expect the friendly strings.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Point({self.x}, {self.y})"
points = [Point(1, 2), Point(3, 4)]
print(points)The list ignores __str__
Why: A container prints each item with __repr__, not __str__. Point has no __repr__, so you get the default addresses back - exactly what you were trying to avoid.
| you print | you get |
|---|---|
| print(points) | [<__main__.Point object at 0x...>, <__main__.Point object at 0x...>] |
Define __repr__ (the form containers use). Then lists show it - and print/str fall back to it too.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
points = [Point(1, 2), Point(3, 4)]
print(points)The list now shows each __repr__
Why: Verified by execution: prints [Point(1, 2), Point(3, 4)]. Rule of thumb: for anything you will see inside a list or dict, you want __repr__.
| you print | you get |
|---|---|
| print(points) | [Point(1, 2), Point(3, 4)] |
Break the constraint
Discussion prompt
The rule this trap just fixed:
Verified by execution: prints [Point(1, 2), Point(3, 4)]. Rule of thumb: for anything you will see inside a list or dict, you want __repr__.
Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?
Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.
Answer:
A container prints each item with __repr__, not __str__. Point has no __repr__, so you get the default addresses back - exactly what you were trying to avoid.
Section
Part 4
Concept
Without an __eq__, a == b asks a narrow question: are a and b the same object in memory? Two separately-built objects are never the same object, even with identical data.
So Point(1, 2) == Point(1, 2) is False by default - which usually surprises people.
Concept
identity vs equality — Identity (is): the very same object. Equality (==): objects that count as equal by their values. Default == uses identity; __eq__ lets you define value equality instead.
For numbers and strings, == already compares values - because their classes define __eq__. Your own class starts with only the identity check.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of __str__, __repr__, identity vs equality as Session 25 - Dunder Methods uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
a = Point(1, 2)
b = Point(1, 2)
print(a == b)
print(a == a)a and b hold the same numbers but are different objects
Why: a == b uses the default identity check, and a is not the same object as b.
Read the (verified) output
Why: Verified by execution: prints False then True. a == a is True only because a is literally the same object as itself.
| comparison | same object? | result |
|---|---|---|
| a == b | no | False |
| a == a | yes | True |
Blank canvas
Draw it
Draw what Equal-looking Points, == is False just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You built two objects with the same data and check whether they match.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: got and wanted carry identical values but are distinct objects, so == is False and the else branch runs - even though a human would call them equal.
Define __eq__ so == compares the values you care about.
Why: got and wanted carry identical values but are distinct objects, so == is False and the else branch runs - even though a human would call them equal.
Trap
You built two objects with the same data and check whether they match.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
wanted = Point(1, 2)
got = Point(1, 2)
if got == wanted:
print("match")
else:
print("no match")== falls back to identity and says no
Why: got and wanted carry identical values but are distinct objects, so == is False and the else branch runs - even though a human would call them equal.
| expression | checks | prints |
|---|---|---|
| got == wanted | same object? (no) | no match |
Define __eq__ so == compares the values you care about.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
wanted = Point(1, 2)
got = Point(1, 2)
if got == wanted:
print("match")
else:
print("no match")Now == compares x and y
Why: Verified by execution: prints match. __eq__ receives the other object and returns True when the values line up.
| expression | checks | prints |
|---|---|---|
| got == wanted | x and y equal? (yes) | match |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
__init__ is a dunder. You never call obj.__init__() yourself - Python runs it when you write ClassName(...). Every dunder works the same way: a name Python watches for.; Think of Python's syntax - +, ==, len(), print(), sorting - as sockets. A dunder method is the plug on your class that fits one of those sockets.; Print an object with no dunders and Python still shows something: a fallback built into every object. It is technically correct but useless to a human.__str__, then print a list of objects and expect the friendly strings.; You built two objects with the same data and check whether they match.Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
seen = [Point(1, 2), Point(3, 4)]
print(Point(1, 2) in seen)
print(Point(9, 9) in seen)
print(Point(1, 2) != Point(3, 4))in scans the list using ==
Why: Membership tests compare each item with ==, which now runs your __eq__. So a value-equal Point counts as present.
!= is derived from ==
Why: Verified by execution: prints True, False, True. Defining __eq__ gives you != for free - Python negates the result.
| expression | result |
|---|---|
| Point(1, 2) in seen | True |
| Point(9, 9) in seen | False |
| Point(1, 2) != Point(3, 4) | True |
Comparison
Comparison matrix
From __eq__ powers != and in too: refill the result column from what you know. The rest of the table is as it appeared.
| expression | result |
|---|---|
| Point(1, 2) in seen | True |
| Point(9, 9) in seen | False |
| Point(1, 2) != Point(3, 4) | True |
Section
Part 5
Concept
len(x) does not measure anything on its own - it calls x.__len__() and hands back whatever integer that returns. Lists and strings work with len() because their classes define it.
Give your class a __len__ and it decides what its own 'length' means.
Fill the middle
Fill in the blanks
From len() before __len__: TypeError — one line has had its right-hand side removed. Put it back.
class Team:
def __init__(self, members):
self.members = members
t = Team(["Ana", "Ben", "Cid"])
print(len(t))
Why: self.members is what everything below it consumes, so the wrong expression here fails later and somewhere else. len() looks for the dunder, does not find it, and refuses - it will not guess.
Worked example
class Team:
def __init__(self, members):
self.members = members
t = Team(["Ana", "Ben", "Cid"])
print(len(t))Team has no __len__
Why: len() looks for the dunder, does not find it, and refuses - it will not guess.
Read the (verified) error
Why: Verified by execution: raises TypeError: object of type 'Team' has no len(). The error even names the missing capability.
| you write | result |
|---|---|
| len(t) | TypeError: object of type 'Team' has no len() |
Worked example
class Team:
def __init__(self, members):
self.members = members
def __len__(self):
return len(self.members)
t = Team(["Ana", "Ben", "Cid"])
print(len(t))__len__ returns the count you choose
Why: Here 'length of a Team' means how many members it has, so __len__ returns len(self.members).
Read the (verified) output
Why: Verified by execution: prints 3. len(t) now calls __len__, which reports 3.
| members | __len__ returns | len(t) |
|---|---|---|
| ['Ana', 'Ben', 'Cid'] | 3 | 3 |
Section
Part 6
Concept
sorted() orders items by repeatedly asking 'is this one less than that one?'. That question is <, which calls __lt__. No __lt__, and Python has no way to order your objects.
Define __lt__ and you pick the key that ranks them.
Worked example
class Player:
def __init__(self, name, score):
self.name = name
self.score = score
players = [Player("Ana", 30), Player("Ben", 10)]
print(sorted(players))sorted() tries to compare two Players
Why: To order them it needs Player < Player, but Player defines no __lt__.
Read the (verified) error
Why: Verified by execution: raises TypeError: '<' not supported between instances of 'Player' and 'Player'. Python is telling you the missing operator.
| you write | result |
|---|---|
| sorted(players) | TypeError: '<' not supported between instances of 'Player' and 'Player' |
Worked example
class Player:
def __init__(self, name, score):
self.name = name
self.score = score
def __repr__(self):
return f"{self.name}({self.score})"
def __lt__(self, other):
return self.score < other.score
players = [Player("Ana", 30), Player("Ben", 10), Player("Cid", 20)]
print(sorted(players))__lt__ ranks by score
Why: self < other now means 'my score is lower'. sorted() uses that to order lowest-score first.
Read the (verified) output
Why: Verified by execution: prints [Ben(10), Cid(20), Ana(30)]. The __repr__ makes the list readable; __lt__ decides the order.
| sorted position | player | score |
|---|---|---|
| 1st | Ben | 10 |
| 2nd | Cid | 20 |
| 3rd | Ana | 30 |
Pattern
Step through it
Step through Add __lt__ and sorted() ranks them one row at a time. What is driving the change, and what would the row after the last one be?
Concept
min(), max(), and .sort() all lean on the same < comparison. Define __lt__ once and every one of them works on your objects.
That is the payoff of hooking a dunder: one method, many built-in tools.
Worked example
class Player:
def __init__(self, name, score):
self.name = name
self.score = score
def __repr__(self):
return f"{self.name}({self.score})"
def __lt__(self, other):
return self.score < other.score
players = [Player("Ana", 30), Player("Ben", 10), Player("Cid", 20)]
print(min(players))
print(max(players))min and max reuse __lt__
Why: min finds the object nothing is less than; max finds the greatest. Both use the same score comparison.
Read the (verified) output
Why: Verified by execution: prints Ben(10) then Ana(30). No extra code - defining __lt__ was enough.
| call | picks | output |
|---|---|---|
| min(players) | lowest score | Ben(10) |
| max(players) | highest score | Ana(30) |
Section
Part 7
Concept
a + b is shorthand for a.__add__(b). Numbers add, strings join, lists concatenate - all because each class defines __add__ to mean something sensible.
Your class defines what + should build. Usually that is a brand-new object, leaving the originals unchanged.
Worked example
class Money:
def __init__(self, cents):
self.cents = cents
a = Money(150)
b = Money(75)
print(a + b)Money has no __add__
Why: + looks for the dunder on the left operand, does not find it, and gives up.
Read the (verified) error
Why: Verified by execution: raises TypeError: unsupported operand type(s) for +: 'Money' and 'Money'. Python will not invent addition for you.
| you write | result |
|---|---|
| a + b | TypeError: unsupported operand type(s) for +: 'Money' and 'Money' |
Blank canvas
Draw it
Draw what + before __add__: TypeError just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Faded example
Fill in the blanks
Add __add__: + builds a new object, with the scaffolding fading: two lines are gone now — fill both.
class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money(Money(75))"
def __add__(self, other):
return Money(self.cents + other.cents)
a = Money(150)
b = ___
print(a + b)
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. It reads both cents totals, adds them, and builds a new Money.
Worked example
class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money({self.cents})"
def __add__(self, other):
return Money(self.cents + other.cents)
a = Money(150)
b = Money(75)
print(a + b)__add__ returns a fresh Money
Why: It reads both cents totals, adds them, and builds a new Money. a and b are untouched.
Read the (verified) output
Why: Verified by execution: prints Money(225). 150 + 75 cents, wrapped back into a Money object you can keep using.
| expression | cents | result |
|---|---|---|
| a | 150 | Money(150) |
| b | 75 | Money(75) |
| a + b | 150 + 75 | Money(225) |
Error analysis
Annotate
Walk the callouts on Add __add__: + builds a new object. Each one is a place this is easy to get subtly wrong.
Fill the middle
Fill in the blanks
From Return a new object, and + chains — one line has had its right-hand side removed. Put it back.
class Vec:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vec(Vec(3, 4), ___)"
def __add__(self, other):
return Vec(self.x + other.x, self.y + other.y)
a = Vec(1, 2)
b = ___
c = Vec(10, 10)
print(a + b + c)
Why: b is what everything below it consumes, so the wrong expression here fails later and somewhere else. a + b builds Vec(4, 6); that result has __add__ too, so + c works on it.
Worked example
class Vec:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vec({self.x}, {self.y})"
def __add__(self, other):
return Vec(self.x + other.x, self.y + other.y)
a = Vec(1, 2)
b = Vec(3, 4)
c = Vec(10, 10)
print(a + b + c)Because + returns a Vec, you can add again
Why: a + b builds Vec(4, 6); that result has __add__ too, so + c works on it. Returning a new object of the same type is what makes chaining possible.
Read the (verified) output
Why: Verified by execution: prints Vec(14, 16). Left to right: (a + b) is Vec(4, 6), then + c gives Vec(14, 16).
| step | x | y |
|---|---|---|
| a + b | 4 | 6 |
| (a + b) + c | 14 | 16 |
Trade off
Comparison matrix
From Return a new object, and + chains: every row here is a choice with a cost. Fill the x column, then say which row you would actually pick and what you give up for it.
| step | x | y |
|---|---|---|
| a + b | 4 | 6 |
| (a + b) + c | 14 | 16 |
Intuition
+, ==, <, and len(...) look like built-in machinery, but each is just a readable spelling of a method call. a + b is a.__add__(b).
Once you see that, adding operator support to your own class stops feeling like magic - you are only filling in the method Python was already going to call.
Explain it
Discussion prompt
Explain Operators are method calls in disguise to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
+, ==, <, and len(...) look like built-in machinery, but each is just a readable spelling of a method call. a + b is a.__add__(b).
Section
Part 8
Concept
A class can define as many dunders as it needs. Each one wires up a different piece of syntax, and they do not interfere with each other.
Add only the ones your object should support - a Money wants __add__, __eq__, and a __repr__; it probably has no use for __len__.
Analogy
Discussion prompt
Explain One class, many dunders by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
A class can define as many dunders as it needs. Each one wires up a different piece of syntax, and they do not interfere with each other.
Pattern
Predict first
The table runs: Money(150) + Money(75) | __add__ | Money(225) · print(wallet) | __repr__ | Money(225)
In A Money class that feels built-in, given the rows so far: what is the next one — the row where expression is wallet == Money(225)?
Correct: wallet == Money(225) | __eq__ | True
| expression | dunder used | result |
|---|---|---|
| Money(150) + Money(75) | __add__ | Money(225) |
| print(wallet) | __repr__ | Money(225) |
| wallet == Money(225) | __eq__ | True |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. The + builds Money(225); print asks __repr__ for its string; == runs __eq__ to compare cents.
Worked example
class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money({self.cents})"
def __eq__(self, other):
return self.cents == other.cents
def __add__(self, other):
return Money(self.cents + other.cents)
wallet = Money(150) + Money(75)
print(wallet)
print(wallet == Money(225))__add__, then __repr__, then __eq__ all fire
Why: The + builds Money(225); print asks __repr__ for its string; == runs __eq__ to compare cents. Three dunders, one expression each.
Read the (verified) output
Why: Verified by execution: prints Money(225) then True. The object now adds, prints, and compares like a number.
| expression | dunder used | result |
|---|---|---|
| Money(150) + Money(75) | __add__ | Money(225) |
| print(wallet) | __repr__ | Money(225) |
| wallet == Money(225) | __eq__ | True |
Comparison
Comparison matrix
From A Money class that feels built-in: refill the dunder used column from what you know. The rest of the table is as it appeared.
| expression | dunder used | result |
|---|---|---|
| Money(150) + Money(75) | __add__ | Money(225) |
| print(wallet) | __repr__ | Money(225) |
| wallet == Money(225) | __eq__ | True |
Intuition
Built-in types feel effortless because they answer to +, ==, len(), and print already. Dunders are how you give the same fluency to a class you wrote.
You are not learning new syntax - you are teaching your objects to speak the syntax you already know.
Counterexample
Discussion prompt
Built-in types feel effortless because they answer to +, ==, len(), and print already. Dunders are how you give the same fluency to a class you wrote.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
You are not learning new syntax - you are teaching your objects to speak the syntax you already know.
Section
Part 9
Pattern
Want print / str / f-strings to read well? define __repr__ (add __str__ only if the friendly form differs)
Why: __repr__ is the fallback for str and the form containers use, so it covers the most cases.
Want == to compare values? define __eq__(self, other)
Why: Otherwise == checks identity and two equal-valued objects come out False. You also get != for free.
Want len(x)? define __len__ returning an int
Why: len() calls __len__; without it you get TypeError: object of type '...' has no len().
Want sorted / min / max? define __lt__(self, other)
Why: They all rank items with <; without __lt__ you get TypeError: '<' not supported between instances.
Want a + b? define __add__(self, other) returning a new object
Why: + calls __add__; returning a fresh same-type object leaves the originals alone and lets + chain.
Pattern
1. Return a string, built from the object's own attributes
Why: The whole point is to show the data - self.x, self.cents - not a memory address.
2. Prefer an f-string that looks like how you'd build the object
Why: f"Point({self.x}, {self.y})" reads like Point(1, 2). Use !r on strings so quotes show.
3. Reach for __repr__ before __str__
Why: print, str(), the shell, and lists all fall back to __repr__ - one method covers them.
Real world
Discussion prompt
Outside this lesson: where does Session 25 - Dunder Methods actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Writing a solid __repr__ is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 25 of the Python Fundamentals series, in depth. Special double-underscore methods that hook Python's own syntax onto your classes: __init__ (already known), __str__ vs __repr__ (print and the shell vs containers), __eq__ for value equality, __len__ for len(), __lt__ for sorting, and __add__ for +.
Check
No __str__ or __repr__ is defined.
class Cat:
def __init__(self, name):
self.name = name
c = Cat("Mimi")
print(c)| has __repr__? | prints |
|---|---|
| no | ? |
Check your understanding
What does print(c) show?
Answer: C
Why: With no __str__ or __repr__, print falls back to the default repr: the class location and a memory address. Verified by execution.
Check
__str__ is defined, but you print a list of the objects.
class P:
def __init__(self, n):
self.n = n
def __str__(self):
return f"P{self.n}"
print([P(1), P(2)])| container uses | prints |
|---|---|
| __repr__ | ? |
Check your understanding
What does this print?
Answer: B
Why: A list prints each item with __repr__, not __str__. P defines only __str__, so the items fall back to the default address repr. Verified by execution.
Check
Two objects with the same data, no __eq__ defined.
class Box:
def __init__(self, v):
self.v = v
print(Box(5) == Box(5))| == checks | result |
|---|---|
| identity | ? |
Check your understanding
What does this print?
Answer: B
Why: With no __eq__, == compares identity. The two Box objects are built separately, so they are different objects and == is False. Verified by execution.
Check
Now __eq__ compares the value.
class Box:
def __init__(self, v):
self.v = v
def __eq__(self, other):
return self.v == other.v
seen = [Box(1), Box(2)]
print(Box(2) in seen)| in uses | result |
|---|---|
| == | ? |
Check your understanding
What does this print?
Answer: A
Why: in compares each item with ==, which now runs __eq__. Box(2) is value-equal to the second item, so it is found. Verified by execution.
Check
__len__ returns the length of the wrapped list.
class Bag:
def __init__(self, items):
self.items = items
def __len__(self):
return len(self.items)
b = Bag(["a", "b", "c", "d"])
print(len(b))| items | returns |
|---|---|
| 4 items | ? |
Check your understanding
What does this print?
Answer: A
Why: len(b) calls __len__, which returns len(self.items) - the list has 4 items, so it returns 4. Verified by execution.
Check
__lt__ compares by score.
class P:
def __init__(self, s):
self.s = s
def __repr__(self):
return str(self.s)
def __lt__(self, other):
return self.s < other.s
print(sorted([P(3), P(1), P(2)]))| sorts by | result |
|---|---|
| __lt__ (s) | ? |
Check your understanding
What does this print?
Answer: A
Why: sorted() orders lowest-first using __lt__ (self.s < other.s), and __repr__ prints each score, giving [1, 2, 3]. Verified by execution.
Check
__add__ combines the counts into a new Jar.
class Jar:
def __init__(self, n):
self.n = n
def __repr__(self):
return f"Jar({self.n})"
def __add__(self, other):
return Jar(self.n + other.n)
print(Jar(3) + Jar(4))| + calls | result |
|---|---|
| __add__ | ? |
Check your understanding
What does this print?
Answer: A
Why: __add__ adds the two n values (3 + 4) and returns a new Jar(7); __repr__ then prints it as Jar(7). Verified by execution.
Check
Only __repr__ is defined - no __str__.
class Tag:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Tag({self.name!r})"
t = Tag("sale")
print(str(t))| str() falls back to | prints |
|---|---|
| __repr__ | ? |
Check your understanding
What does print(str(t)) show?
Answer: A
Why: With no __str__, str() falls back to __repr__, which returns Tag('sale') - the !r adds the quotes around 'sale'. Verified by execution.
Check
Both are defined, and you print the object directly.
class Coin:
def __init__(self, v):
self.v = v
def __str__(self):
return f"{self.v}c"
def __repr__(self):
return f"Coin({self.v})"
print(Coin(25))| print uses | prints |
|---|---|
| __str__ | ? |
Check your understanding
What does print(Coin(25)) show?
Answer: A
Why: print prefers __str__ when it exists, so it uses the friendly form and shows 25c. __repr__ would be used by repr(), the shell, or a list. Verified by execution.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Hooks into Syntax · The Default repr · __str__ and __repr__ · __eq__: Value Equality · __len__: len() · __lt__: Sorting. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
A dunder method is a double-underscore name Python calls for you when matching syntax runs. Define the plug and your object fits the socket - print, ==, len(), sorting, +.
| Syntax | Dunder it calls | Without it |
|---|---|---|
| print(x) / str(x) | __str__ (else __repr__) | <...object at 0x...> |
| x in the shell / [x] | __repr__ | <...object at 0x...> |
| a == b | __eq__ | identity: False for equal values |
| len(x) | __len__ | TypeError: ... has no len() |
| sorted / min / max | __lt__ | TypeError: '<' not supported |
| a + b | __add__ | TypeError: unsupported operand type(s) |
Reach for __repr__ first (it backs str, the shell, and containers), and remember the two traps: lists print items with __repr__, and == without __eq__ compares identity. Next session we build a full small class using all of these together.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.