Methods enclosed by double underscores, commonly referred to as dunder or magic methods, serve as integration hooks that allow custom classes to interact seamlessly with Python’s built-in syntax and functions. Rather than calling these methods directly, the interpreter invokes them implicitly during specific operations. For instance, __init__ handles instance setup, while __len__ bridges custom objects with the built-in len() function.
class MetricTracker:
def __init__(self, readings):
self.readings = list(readings)
def __len__(self):
return len(self.readings)
def __repr__(self):
return f"MetricTracker(count={len(self.readings)})"
tracker = MetricTracker([10.5, 12.1, 9.8])
print(tracker) # MetricTracker(count=3)
print(len(tracker)) # 3
Beyond initialization and representation, Python provides hooks for arithmetic (__add__, __mul__), container emulation (__getitem__, __setitem__), context management (__enter__, __exit__), and callable behavior (__call__). Implementing these allows objects to behave like native types.
Naming Conventions: Single vs. Double Leading Underscores
Python uses leading underscores to signal intended visibility, though enforcement varies:
- Single leading underscore (
_variable): Indicates internal use. It is a strong convention rather than a strict rule. Import statements usingfrom module import *will ignore names starting with a single underscore, but direct access remains possible. - Double leading underscore (
__variable): Triggers name mangling. The interpreter rewrites the attribute name to_ClassName__variableto prevent accidental overrides in subclasses. While technically accessible via the mangled name, bypassing this mechanism breaks encapsulation and is discouraged.
class NetworkConfig:
def __init__(self):
self._timeout = 30 # Internal convention
self.__api_key = "x9z2" # Mangled to _NetworkConfig__api_key
cfg = NetworkConfig()
print(cfg._timeout) # 30
print(cfg._NetworkConfig__api_key) # x9z2 (mangled access)
Instance Creation: __new__ Versus __init__
Object instantiation in Python occurs in two distinct phases:
- Allocasion (
__new__): A static method responsible for creating and returning the new instance. It executes first and must return an object (typically viasuper().__new__(cls)). - Initialization (
__init__): An instance method that configures the newly created object. It receives the instance returned by__new__and implicitly returnsNone.
class AuditedRecord:
def __new__(cls, identifier):
print(f"Allocating memory for {identifier}")
instance = super().__new__(cls)
return instance
def __init__(self, identifier):
print(f"Initializing state for {identifier}")
self.id = identifier
rec = AuditedRecord("TX-99")
# Output:
# Allocating memory for TX-99
# Initializing state for TX-99
Iteration Protocols: Iterators and Generators
Both iterators and generators enable sequential data access without loading entire collections into memory, but they differ in implementation:
- Iterators: Objects that implement
__iter__()(returning self) and__next__(). Each call to__next__()yields the next item or raisesStopIterationwhen exhausted. - Generators: A streamlined approach using functions with the
yieldkeyword or generator expressions. The interpreter automatically handles the iterator protocol, preserving local state between yields.
# Custom Iterator
class BoundedCounter:
def __init__(self, ceiling):
self.ceiling = ceiling
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current >= self.ceiling:
raise StopIteration
self.current += 1
return self.current
# Generator Function
def bounded_counter_gen(ceiling):
val = 0
while val < ceiling:
val += 1
yield val
for n in BoundedCounter(3):
print(n) # 1, 2, 3
for n in bounded_counter_gen(3):
print(n) # 1, 2, 3
Generators are generally preferred for their concise syntax and automatic state management, especially when processing streams or large datasets.
Structured Error Management
Exception handling isolates fault-prone operations and defines recovery paths. The try block monitors execution, except catches specific errors, else runs only when no exceptions occur, and finally executes unconditionally for cleanup.
def parse_ratio(raw_input):
try:
numerator = int(raw_input)
result = 100 / numerator
except ValueError:
print("Invalid integer format")
except ZeroDivisionError:
print("Division by zero is undefined")
else:
print(f"Computed ratio: {result}")
finally:
print("Parse attempt finished")
parse_ratio("0")
Runtime State Verification with Assertions
The assert statement evaluates a condition and raises an AssertionError if the condition evaluates to false. It is designed for debugging and validating internal program state during development, not for handling expected runtime errors. Assertions can be globally disabled using the -O optimization flag.
def configure_pool(size):
assert isinstance(size, int) and size > 0, "Pool size must be a positive integer"
print(f"Pool configured with {size} workers")
configure_pool(5) # Success
configure_pool(-1) # Raises AssertionError
Argument Packing and Unpacking Operators
The * and ** symbols serve multiple roles depending on context:
- Arithmetic:
*performs multiplication, while**calculates exponents. - Parameter Packing: In function definitions,
*argscollects extra positional arguments into a tuple, and**kwargsgathers extra keyword arguments into a dictionary. - Argument Unpacking: In function calls,
*expands an iterable into positional arguments, and**expands a mapping into keyword arguments.
def compile_report(report_id, *tags, **metrics):
print(f"ID: {report_id}")
print(f"Tags: {tags}")
print(f"Metrics: {metrics}")
# Packing during call
tag_list = ["urgent", "finance"]
metric_map = {"cpu": 45.2, "mem": 88.1}
compile_report("RPT-01", *tag_list, **metric_map)
# Output:
# ID: RPT-01
# Tags: ('urgent', 'finance')
# Metrics: {'cpu': 45.2, 'mem': 88.1}
When combining both in a signature, positional packing (*args) must precede keyword packing (**kwargs). This mechanism enables highly flexible APIs and dynamic function routing.