Study guide
5.3 Dunder Methods and Object Lifecycle
OpenEDG PCAP — Certified Associate in Python Programming Exam
Track your progress as you read
Finished sections get a check mark.
1 Chapter 1: The PCAP-31-03 Exam Blueprint and Test-Day Logistics
1.1 The PCAP-31-03 Blueprint, Scoring, and Exam-Day Logistics
2 Chapter 2: Modules, Packages, and the Standard Library
2.1 Module Imports and Namespace Mechanics 2.2 Module Construction and Execution 2.3 Package Hierarchy and Navigation 2.4 Standard Library Modules and Pip
3 Chapter 3: Exceptions and Error Handling
3.1 Exception Control Flow and Syntax 3.2 The Built-in Exception Hierarchy 3.3 Raising Exceptions and Assertions 3.4 Custom Exceptions and Exception Chaining
4 Chapter 4: Strings, Encodings, and Text Processing
4.1 Character Encodings and String Immutability 4.2 String Indexing, Slicing, and Comparison 4.3 String Inspection and Classification Methods 4.4 String Transformation and Formatting Methods 4.5 Iterating, Sorting, and Comparing Strings Against Other Types
5 Chapter 5: Object-Oriented Programming Fundamentals
5.1 Classes, Instances, and Attributes 5.2 Encapsulation, Privacy, and Name Mangling 5.3 Dunder Methods and Object Lifecycle 5.4 Discovering Class Structure: __name__, __module__, __bases__, and Identity
6 Chapter 6: Advanced OOP, Inheritance, and Metaprogramming
6.1 Single Inheritance, Method Overriding, and Polymorphism 6.2 Multiple Inheritance and Method Resolution Order (MRO) 6.3 Class Methods, Static Methods, and Properties
7 Chapter 7: Functional Python, Comprehensions, and Closures
7.1 List, Dictionary, and Set Comprehensions 7.2 Lambda Expressions and Higher-Order Functions 7.3 Variable Scopes, the LEGB Rule, and Closures
8 Chapter 8: Iterators, Generators, and File I/O
8.1 Iterators and the Iteration Protocol 8.2 Generator Functions and Generator Expressions 8.3 File Input/Output and Context Managers 8.4 Predefined Streams, Handles vs. Streams, and errno Diagnostics
5.3 Dunder Methods and Object Lifecycle
Key Takeaways Explain This Topic AI
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__() .
Last updated: August 2026
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 (like int , str , tuple ) or implementing custom metaclasses and singletons.
__init__(self, *args, **kwargs) : Receives the newly allocated instance as self and 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 del Statement : Executing del x does not directly call __del__() . It merely removes the variable identifier x from 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 to None , 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 runtime TypeError or ValueError .
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