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.
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 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.
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
except:, so unrelated bugs stay visible.
Vector class supporting +, -, and scalar multiplication via __mul__.Temperature class storing degrees in Celsius with __str__ and a __add__ that returns the mean-style sum.InsufficientStockError and raise it from a Warehouse class when stock runs out.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.
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))
In Python, functions are values like any other. They can be assigned, passed around, and returned from other functions.
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
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.
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.
Timer context decorator that prints how many seconds a decorated function took.fibonacci() that yields Fibonacci numbers forever; use next() to print the first ten.Limited class whose __next__ stops cleanly after a set number of items.__eq__ and __hash__ to a Student class so two students with the same roll number are considered equal and can be stored in sets.@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.