Core Python Mechanisms: Dunder Methods, Iteration Protocols, and Argument Handling

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 using from 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__variable to 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:

  1. Allocasion (__new__): A static method responsible for creating and returning the new instance. It executes first and must return an object (typically via super().__new__(cls)).
  2. Initialization (__init__): An instance method that configures the newly created object. It receives the instance returned by __new__ and implicitly returns None.
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 raises StopIteration when exhausted.
  • Generators: A streamlined approach using functions with the yield keyword 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:

  1. Arithmetic: * performs multiplication, while ** calculates exponents.
  2. Parameter Packing: In function definitions, *args collects extra positional arguments into a tuple, and **kwargs gathers extra keyword arguments into a dictionary.
  3. 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.

Tags: python Interview Preparation Object-Oriented Programming Advanced Python Software Engineering

Posted on Sun, 01 Nov 2026 16:48:00 +0000 by Rayhan Muktader