Object Oriented Programming
Dunder Methods & Operator Overloading
len(my_list) → actually calls my_list.__len__()
Jr Codex Python Notes
Level: Advanced Prerequisites: Chapter 6 Time to complete: ~30 minutes
Table of Contents
- What Are Dunder Methods?
__init__and__str__— Ones You Already Know__repr__vs__str__- Operator Overloading:
__add__,__sub__, etc. - Comparison Dunders:
__eq__,__lt__, etc. - Making Objects Iterable:
__len__,__getitem__,__iter__ __call__— Making an Object Callable Like a Function- A Complete Example
- Summary & Next Steps
1. What Are Dunder Methods?
Dunder = "Double UNDERscore" — methods named __like_this__. They're how Python lets your own classes plug into the language's built-in syntax: +, ==, len(), for...in, print(), and more, all dispatch to dunder methods behind the scenes.
The Pattern You've Seen All Along
─────────────────────────────────────────
len(my_list) → actually calls my_list.__len__()
my_list[0] → actually calls my_list.__getitem__(0)
my_a + my_b → actually calls my_a.__add__(my_b)
print(my_obj) → actually calls str(my_obj) → my_obj.__str__()
─────────────────────────────────────────
This is the mechanism behind Chapter 3's observation that len() and + behave differently per type — every built-in type defines its own versions of these dunder methods, and so can yours.
2. __init__ and __str__ — Ones You Already Know
You've used __init__ since Chapter 1 without necessarily thinking of it as "one dunder method among many." __str__ controls what print() and str() show for your object:
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(3, 4)
print(p) # Point(3, 4) — without __str__, this would print something like <__main__.Point object at 0x...>
print(str(p)) # "Point(3, 4)" — same thing, explicit call3. __repr__ vs __str__
Both control string representation, but for different audiences:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
"""Unambiguous, developer-facing — ideally valid Python to recreate the object."""
return f"Point(x={self.x}, y={self.y})"
def __str__(self):
"""Readable, user-facing."""
return f"({self.x}, {self.y})"
p = Point(3, 4)
print(p) # ({3, 4}) — uses __str__
print(repr(p)) # Point(x=3, y=4) — uses __repr__
points = [Point(1, 2), Point(3, 4)]
print(points) # [Point(x=1, y=2), Point(x=3, y=4)] — lists always use __repr__ on their items!If you only define one, define __repr__. Python falls back to __repr__ for print() if __str__ is missing, but not the other way around — and __repr__ is what you see in debuggers, REPLs, and inside containers like lists.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
p = Point(1, 2)
print(p) # Point(x=1, y=2) — falls back to __repr__ since __str__ isn't defined4. Operator Overloading: __add__, __sub__, etc.
Define these to make +, -, *, etc. work meaningfully on your own objects:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)
v2 = Vector(1, 1)
print(v1 + v2) # Vector(3, 4) — calls v1.__add__(v2)
print(v1 - v2) # Vector(1, 2) — calls v1.__sub__(v2)
print(v1 * 3) # Vector(6, 9) — calls v1.__mul__(3)| Operator | Dunder Method |
|---|---|
+ | __add__ |
- | __sub__ |
* | __mul__ |
/ | __truediv__ |
// | __floordiv__ |
% | __mod__ |
** | __pow__ |
5. Comparison Dunders: __eq__, __lt__, etc.
By default, Python compares custom objects by identity (is, from Module 1 Chapter 3) — two Point(1, 2) objects are considered unequal even with identical data, unless you teach Python otherwise.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # False! — different objects in memory, no __eq__ definedclass 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
def __lt__(self, other):
"""Enables <, and sorted()/min()/max() by extension."""
return (self.x, self.y) < (other.x, other.y)
def __repr__(self):
return f"Point({self.x}, {self.y})"
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(5, 5)
print(p1 == p2) # True — now compares VALUES, not identity
print(p1 < p3) # True — compares (1,2) < (5,5) via tuple comparison
points = [Point(3, 1), Point(1, 5), Point(2, 2)]
print(sorted(points)) # [Point(1, 5), Point(2, 2), Point(3, 1)] — sorted() uses __lt__ automatically| Operator | Dunder Method |
|---|---|
== | __eq__ |
!= | __ne__ |
< | __lt__ |
> | __gt__ |
<= | __le__ |
>= | __ge__ |
Practical tip: defining just __eq__ and __lt__ is usually enough — Python's functools.total_ordering decorator (standard library) can fill in the rest (<=, >, >=) automatically from those two, if needed.
6. Making Objects Iterable: __len__, __getitem__, __iter__
class Playlist:
def __init__(self, songs):
self.songs = songs
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
playlist = Playlist(["Song A", "Song B", "Song C"])
print(len(playlist)) # 3 — calls __len__
print(playlist[0]) # "Song A" — calls __getitem__(0)
print(playlist[-1]) # "Song C" — negative indexing works automatically too!
for song in playlist: # for...in works automatically once __getitem__ exists!
print(song)Defining __getitem__ alone is enough for basic iteration to "just work" — Python falls back to calling __getitem__(0), __getitem__(1), etc. until it raises IndexError. For full control over iteration (especially for non-sequence collections), define __iter__ explicitly — covered in depth alongside custom iterators in Module 5.
7. __call__ — Making an Object Callable Like a Function
__call__ lets you use an instance of your class as if it were a function — instance() instead of instance.some_method().
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # 10 — calling the OBJECT directly, like a function!
print(triple(5)) # 15
print(callable(double)) # True — Python confirms this object supports being calledThis connects back to Module 2's closures (make_multiplier) — a class with __call__ is essentially a closure implemented with full object state instead of a nested function, useful when the "remembered" behavior needs more structure (multiple methods, inheritance, etc.) than a plain closure can offer.
8. A Complete Example
Putting it together — a Money class that behaves naturally with Python's operators and built-ins:
class Money:
def __init__(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount}, '{self.currency}')"
def __str__(self):
return f"{self.amount:.2f} {self.currency}"
def __add__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
def __eq__(self, other):
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
if self.currency != other.currency:
raise ValueError("Cannot compare different currencies")
return self.amount < other.amount
def __len__(self):
"""A slightly unusual but valid use — number of whole currency units."""
return int(self.amount)
wallet = Money(50.00) + Money(25.50)
print(wallet) # 75.50 USD — uses __str__
print(repr(wallet)) # Money(75.5, 'USD') — uses __repr__
prices = [Money(30), Money(10), Money(20)]
print(sorted(prices)) # [Money(10, 'USD'), Money(20, 'USD'), Money(30, 'USD')] — uses __lt__
print(Money(10) == Money(10)) # True — uses __eq__
print(len(Money(75.50))) # 75 — uses __len__9. Summary & Next Steps
Key Takeaways
- Dunder methods (
__add__,__eq__,__len__, ...) are how Python's built-in syntax (+,==,len(),for...in) dispatches to behavior your own classes define. __repr__is developer-facing and used as the fallback forprint();__str__is user-facing — define at least__repr__.- Implementing
__eq__and__lt__unlocks==comparisons andsorted()/min()/max()on your own objects. __len__/__getitem__make an object work withlen(), indexing, and basicfor...initeration;__call__lets an instance be invoked like a function.
Module 4 Complete — Next Module
You now have a full grasp of OOP in Python: classes and objects, inheritance, polymorphism, composition, encapsulation, abstraction, and how to make your own classes integrate naturally with Python's operators and built-ins. This is exactly the foundation used by every major Python library — scikit-learn's estimators, PyTorch's nn.Module, pandas' DataFrame — all rely on these same mechanisms. Module 5 builds on this with more advanced language features: generators, decorators, context managers, and type hints.
Concept Check
- Why does
print(my_list_of_objects)show each object's__repr__, not its__str__? - What two dunder methods do you need to define to make your own objects sortable with
sorted()? - What's the difference between defining
__call__on a class versus just writing a regular method?
Next Chapter
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index