Python

Object Oriented Programming

Dunder Methods & Operator Overloading

len(my_list) → actually calls my_list.__len__()

JrCodex·8 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Chapter 6 Time to complete: ~30 minutes


Table of Contents

  1. What Are Dunder Methods?
  2. __init__ and __str__ — Ones You Already Know
  3. __repr__ vs __str__
  4. Operator Overloading: __add__, __sub__, etc.
  5. Comparison Dunders: __eq__, __lt__, etc.
  6. Making Objects Iterable: __len__, __getitem__, __iter__
  7. __call__ — Making an Object Callable Like a Function
  8. A Complete Example
  9. 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 call

3. __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 defined

4. 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)
OperatorDunder 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__ defined
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
 
    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
OperatorDunder 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 called

This 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 for print(); __str__ is user-facing — define at least __repr__.
  • Implementing __eq__ and __lt__ unlocks == comparisons and sorted()/min()/max() on your own objects.
  • __len__/__getitem__ make an object work with len(), indexing, and basic for...in iteration; __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

  1. Why does print(my_list_of_objects) show each object's __repr__, not its __str__?
  2. What two dunder methods do you need to define to make your own objects sortable with sorted()?
  3. What's the difference between defining __call__ on a class versus just writing a regular method?

Next Chapter

Module 5: Advanced Python


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index