5.3 Dunder Methods and Object Lifecycle
Key Takeaways
- Dunder (double-underscore) methods allow user-defined classes to hook into Python's built-in syntax, protocols, and standard functions.
- `__init__(self)` initializes instance state after memory allocation, whereas `__del__(self)` is the destructor invoked when an object's reference count drops to zero during garbage collection.
- `__str__(self)` generates a readable, user-facing representation used by `print()` and `str()`, while `__repr__(self)` generates an unambiguous, developer-focused representation used for debugging, container display, and as a fallback for `__str__`.
- `__eq__(self, other)` overrides the `==` operator for value comparison; implementing `__eq__` automatically sets `__hash__ = None` unless explicitly overridden.
- Container and arithmetic protocols are implemented via `__len__()`, `__getitem__()`, and operator overloading methods like `__add__()`, `__sub__()`, and `__mul__()`.
Dunder Methods and Object Lifecycle
In Python, special methods—conventionally referred to as dunder methods (short for "double underscore" methods) or magic methods—are predefined methods whose identifiers begin and end with double underscores (such as __init__, __str__, or __add__). Dunder methods form the backbone of Python's data model and object-oriented architecture. By implementing these methods, custom user-defined classes can seamlessly integrate with Python's built-in operators, protocols, and built-in functions.
1. The Anatomy and Philosophy of Dunder Methods
Dunder methods are rarely called directly in idiomatic Python code (e.g., you write len(obj) rather than obj.__len__()). Instead, Python's runtime environment dispatches built-in language operations and syntax constructs to their corresponding dunder hooks:
# Built-in operation dispatches to underlying dunder method
numbers = [10, 20, 30]
print(len(numbers)) # Dispatches to numbers.__len__()
print(numbers[1]) # Dispatches to numbers.__getitem__(1)
print(10 in numbers) # Dispatches to numbers.__contains__(10)
2. Object Lifecycle: Creation, Initialization, and Destruction
The lifecycle of a Python object encompasses three primary stages:
+--------------------------------------------------------------------------------+
| OBJECT LIFECYCLE |
| |
| 1. ALLOCATION 2. INITIALIZATION 3. DESTRUCTION |
| __new__(cls, ...) ──▶ __init__(self, ...) ──▶ __del__(self) |
| (Allocates memory) (Initializes state) (Garbage Collection) |
+--------------------------------------------------------------------------------+
A. Allocation (__new__) vs. Initialization (__init__)
__new__(cls, *args, **kwargs): A static method responsible for physically allocating and returning a new, empty instance of the class in memory. It is rarely overridden except when subclassing immutable types (likeint,str,tuple) or implementing custom metaclasses and singletons.__init__(self, *args, **kwargs): Receives the newly allocated instance asselfand populates its initial attributes.
B. Destruction and Finalization (__del__)
The __del__(self) method is Python's destructor. It is invoked by CPython's garbage collector when an object's reference count drops to zero and the memory is about to be reclaimed.
class ResourceHandle:
def __init__(self, name):
self.name = name
print(f"Resource '{self.name}' initialized.")
def __del__(self):
print(f"Resource '{self.name}' destroyed and cleaned up.")
# Creating an object
res = ResourceHandle("DB_Buffer_A")
# Deleting reference
del res # Reference count reaches 0 -> __del__() is invoked immediately
Critical PCAP Caveats Regarding del and __del__
- The
delStatement: Executingdel xdoes not directly call__del__(). It merely removes the variable identifierxfrom the current namespace and decrements the object's reference counter by 1. - Reference Counting: If other references to the object still exist elsewhere in memory,
__del__()will not execute until all remaining references are deleted or go out of scope:
obj1 = ResourceHandle("Shared_Resource")
obj2 = obj1 # Reference count is now 2
del obj1 # Ref count decreases to 1; __del__ is NOT called yet!
print("obj1 deleted, but object still alive via obj2.")
del obj2 # Ref count reaches 0; __del__ executes now
3. String Representations: __str__ vs. __repr__
Python provides two distinct dunder methods for converting an object into a string representation:
| Feature | __str__(self) | __repr__(self) |
|---|---|---|
| Intended Audience | End users / Human readers | Developers / Debuggers / Loggers |
| Goal | Readability, concise formatting | Unambiguity, precise debugging representation |
| Invocations | print(obj), str(obj), f"{obj}", "{}".format(obj) | repr(obj), interactive shell REPL, inside collections ([obj]) |
| Ideal Format | User-friendly text (e.g., "Alice ($500)") | Valid Python code to recreate object (e.g., "User('Alice', 500)") |
| Fallback Rule | If __str__ is omitted, Python falls back to __repr__ | If __repr__ is omitted, Python falls back to object.__repr__ |
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Vector(x={self.x}, y={self.y})"
v = Vector(3, 4)
print(str(v)) # (3, 4) -- invokes __str__
print(repr(v)) # Vector(x=3, y=4) -- invokes __repr__
print(v) # (3, 4) -- print() prefers __str__
print([v]) # [Vector(x=3, y=4)] -- Collections always display __repr__
The Fallback Mechanism
If a class defines __repr__ but omits __str__, calling str(obj) or print(obj) will automatically use __repr__:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
pt = Point(1, 2)
print(pt) # Point(1, 2) -- __repr__ serves as fallback
print(str(pt)) # Point(1, 2)
However, the inverse is not true: if you implement __str__ but omit __repr__, evaluating repr(pt) or printing a list containing pt falls back to the default object.__repr__ (e.g., <Point object at 0x102...>).
4. Object Equality: __eq__ and Hashability
By default, user-defined class instances inherit equality behavior from object, which compares memory identity (is), meaning two separate instances are unequal even if their attributes match.
Implementing Value Equality with __eq__
To enable meaningful value-based comparison (==), override __eq__(self, other):
class Employee:
def __init__(self, emp_id, name):
self.emp_id = emp_id
self.name = name
def __eq__(self, other):
if isinstance(other, Employee):
return self.emp_id == other.emp_id
return NotImplemented
e1 = Employee(101, "Sarah")
e2 = Employee(101, "Sarah")
e3 = Employee(102, "James")
print(e1 == e2) # True (same emp_id)
print(e1 == e3) # False (different emp_id)
print(e1 is e2) # False (distinct objects in memory)
Hashability and the __hash__ Rule
In Python, objects stored in set collections or used as dict keys must be hashable. By default, custom classes are hashable based on their memory address (id()).
PCAP Rule: If a class overrides
__eq__(), Python automatically sets its__hash__attribute toNone, making instances of the class unhashable by default:
e = Employee(101, "Sarah")
try:
badge_set = {e}
except TypeError as err:
print("Hash Error:", err)
# Output: Hash Error: unhashable type: 'Employee'
To restore hashability for immutable objects, you must explicitly implement __hash__(self) alongside __eq__:
class ImmutableEmployee:
def __init__(self, emp_id, name):
self._emp_id = emp_id
self._name = name
def __eq__(self, other):
return isinstance(other, ImmutableEmployee) and self._emp_id == other._emp_id
def __hash__(self):
return hash(self._emp_id)
emp = ImmutableEmployee(200, "David")
s = {emp} # Works perfectly!
print(emp in s) # True
5. Length and Container Methods (__len__, __getitem__)
Custom objects can mimic Python's built-in collection types (lists, tuples, dictionaries) by implementing container protocols:
A. The __len__ Method
Invoked by the built-in len(obj) function. It must return a non-negative integer (>= 0):
class Playlist:
def __init__(self):
self._tracks = []
def add_track(self, title):
self._tracks.append(title)
def __len__(self):
return len(self._tracks)
pl = Playlist()
pl.add_track("Song A")
pl.add_track("Song B")
print(len(pl)) # 2
Exam Trap: If
__len__()returns a negative number, a float, or a non-integer type, Python raises a runtimeTypeErrororValueError.
B. The __getitem__ Method
Invoked during index or key subscript access (obj[key]):
class PlaylistWithIndex(Playlist):
def __getitem__(self, index):
return self._tracks[index]
pl2 = PlaylistWithIndex()
pl2.add_track("Track 1")
pl2.add_track("Track 2")
print(pl2[0]) # Track 1
# Implementing __getitem__ also enables iteration out-of-the-box!
for track in pl2:
print("-", track)
6. Arithmetic Operator Overloading
Python allows user-defined classes to overload arithmetic and comparison operators by implementing specific dunder methods:
| Operator | Expression | Dunder Method | Reflected Method | In-Place Method |
|---|---|---|---|---|
Addition (+) | a + b | __add__(self, other) | __radd__(self, other) | __iadd__(self, other) |
Subtraction (-) | a - b | __sub__(self, other) | __rsub__(self, other) | __isub__(self, other) |
Multiplication (*) | a * b | __mul__(self, other) | __rmul__(self, other) | __imul__(self, other) |
True Division (/) | a / b | __truediv__(self, other) | __rtruediv__(self, other) | __itruediv__(self, other) |
Floor Division (//) | a // b | __floordiv__(self, other) | __rfloordiv__(self, other) | __ifloordiv__(self, other) |
Modulo (%) | a % b | __mod__(self, other) | __rmod__(self, other) | __imod__(self, other) |
Power (**) | a ** b | __pow__(self, other) | __rpow__(self, other) | __ipow__(self, other) |
Implementing Operator Overloading
class Currency:
def __init__(self, amount, symbol="USD"):
self.amount = float(amount)
self.symbol = symbol
def __str__(self):
return f"{self.symbol} {self.amount:.2f}"
def __add__(self, other):
if isinstance(other, Currency):
if self.symbol != other.symbol:
raise ValueError(f"Cannot add different currencies: {self.symbol} and {other.symbol}")
return Currency(self.amount + other.amount, self.symbol)
elif isinstance(other, (int, float)):
return Currency(self.amount + other, self.symbol)
return NotImplemented
def __sub__(self, other):
if isinstance(other, Currency) and self.symbol == other.symbol:
return Currency(self.amount - other.amount, self.symbol)
return NotImplemented
def __mul__(self, factor):
if isinstance(factor, (int, float)):
return Currency(self.amount * factor, self.symbol)
return NotImplemented
c1 = Currency(50.00, "USD")
c2 = Currency(25.50, "USD")
c3 = c1 + c2
print(c3) # USD 75.50
print(c1 * 3) # USD 150.00
print(c1 - c2) # USD 24.50
A class defines __repr__() but does not define __str__():
What is the output of executing class Coordinate:
def __init__(self, lat, lon):
self.lat = lat
self.lon = lon
def __repr__(self):
return f"Coord({self.lat}, {self.lon})"
pos = Coordinate(37.7749, -122.4194)
print(pos)
print(pos)?
A developer writes the following custom class:
What is the result of executing this script?class InventoryCount:
def __init__(self, count):
self.count = count
def __add__(self, other):
if isinstance(other, InventoryCount):
return InventoryCount(self.count + other.count)
return NotImplemented
a = InventoryCount(15)
b = InventoryCount(25)
c = a + b
print(c.count)
What is the requirement for the return value of a custom __len__() method in Python?
Consider the following Python code:
What output is printed when running this script in standard CPython?class Worker:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"{self.name} terminated")
w1 = Worker("Alice")
w2 = w1
del w1
print("Checkpoint reached")