Redirect Chapter 6: Advanced Python: Dunders, Iterators & Decorators | AI Fundamentals
← Back to Tutorials Chapter 6

Advanced Python: Dunders, Iterators & Decorators

Python hides a lot of machinery behind double-underscore methods and function objects. Once you understand that machinery, your own classes can behave like built-ins, your loops can stream huge datasets, and your functions can wrap other functions. This chapter pulls back the curtain.

Magic (Dunder) Methods

Dunder methods are the special methods with double underscores, like __init__. Python calls them automatically when you use certain syntax. For example, len(x) actually invokes x.__len__(), and str(x) invokes x.__str__().

class Point:
    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"Point({self.x}, {self.y})"

p = Point(3, 4)
print(str(p))    # (3, 4)
print(repr(p))   # Point(3, 4)

__str__ controls how humans see an object; __repr__ provides an unambiguous, often reproducible representation.

Operator Overloading

Operator overloading lets +, -, ==, and other operators work with your classes. Define the dunder and Python rewires the operator:

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __eq__(self, other):
        return self.amount == other.amount

    def __lt__(self, other):
        return self.amount < other.amount

    def __repr__(self):
        return f"${self.amount}"

a = Money(10)
b = Money(5)
print(a + b)     # $15
print(a == Money(10))   # True
print(b < a)     # True

Common operators and their dunders: + is __add__, - is __sub__, == is __eq__, and the comparison family shares __lt__, __le__, and so on.

Custom Exception Classes

Sometimes the built-in errors are too generic. Create a subclass of Exception to raise and catch errors that mean something in your domain:

class NegativeBalanceError(Exception):
    pass

class Wallet:
    def __init__(self, balance):
        self.balance = balance

    def spend(self, amount):
        if amount > self.balance:
            raise NegativeBalanceError(
                f"Can't spend {amount}, only {self.balance} available")
        self.balance -= amount

w = Wallet(20)
try:
    w.spend(50)
except NegativeBalanceError as err:
    print("Blocked:", err)   # Blocked: Can't spend 50, only 20 available
Good practice: give custom exceptions descriptive names and a useful message. Catch the specific exception, not a bare except:, so unrelated bugs stay visible.

Complete OOP Practice

  1. Create a Vector class supporting +, -, and scalar multiplication via __mul__.
  2. Add a Temperature class storing degrees in Celsius with __str__ and a __add__ that returns the mean-style sum.
  3. Define an InsufficientStockError and raise it from a Warehouse class when stock runs out.

Iterators: __iter__ and __next__

An iterator is an object that yields items one at a time. Python's for loop just calls iter() once and then next() repeatedly until StopIteration is raised. You can build your own:

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current < 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

for n in Countdown(3):
    print(n)   # 3, 2, 1, 0

Any object with __iter__ and __next__ can be looped over, passed to list(), or used with sum() — it plugs into every Python tool that expects an iterable.

Why lazy? Iterators produce values on demand, so an iterator over a billion numbers consumes almost no memory. Libraries like Pandas rely on this for reading huge files in chunks.

Generators and yield

A generator is a function with yield instead of return. Each call to next() runs the function up to the next yield, pauses, and resumes later — preserving all local variables between calls:

def squares_up_to(limit):
    n = 1
    while n * n <= limit:
        yield n * n
        n += 1

for s in squares_up_to(30):
    print(s)   # 1, 4, 9, 16, 25

Generators are the easiest way to write your own iterator, and they make infinite sequences practical because nothing is computed until requested:

def counting():
    n = 0
    while True:
        yield n
        n += 1

gen = counting()
print(next(gen))   # 0
print(next(gen))   # 1

Generator expressions look like list comprehensions but use parentheses and stay lazy:

total = sum(n * n for n in range(1, 101))

Function Copies, Closures and Decorators

In Python, functions are values like any other. They can be assigned, passed around, and returned from other functions.

Function copies and references

def shout(text):
    return text.upper()

speaker = shout              # a second reference, not a call
print(speaker("hi"))         # HI
print(shout is speaker)      # True - same function object

Closures

A closure is a function that remembers variables from the scope where it was defined, even after that scope has ended:

def make_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10))   # 20
print(triple(10))   # 30

Each call to make_multiplier creates a fresh multiply that keeps its own factor alive.

Decorators

A decorator is a function that takes another function and returns a wrapped version of it. The @ syntax is shorthand for "pass this function through the decorator".

def log_call(func):
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper

@log_call
def add(a, b):
    return a + b

print(add(3, 4))   # Calling add / 7

Decorators are how libraries add features — timing, caching, access checks — without touching the original function body.

Advanced Practice Questions

  1. Write a Timer context decorator that prints how many seconds a decorated function took.
  2. Build a generator fibonacci() that yields Fibonacci numbers forever; use next() to print the first ten.
  3. Create a Limited class whose __next__ stops cleanly after a set number of items.
  4. Add __eq__ and __hash__ to a Student class so two students with the same roll number are considered equal and can be stored in sets.
Exercise: Combine the ideas: write a @timed decorator, use it on a generator-based function that yields the first 100 square numbers, and wrap the whole thing in a custom exception if the limit is negative. Then verify the timing message appears exactly once per call.