Defining Functions
Why Functions Matters
The Problem: Code without functions becomes a copy-paste swamp where one bug must be fixed in 17 places.
The Solution: Functions package logic behind a name, accept inputs, return outputs, and form the basic unit of reuse. In Python they're first-class objects — you can pass, return, store, and decorate them.
Real Impact: Decorators in particular turn cross-cutting concerns (timing, retries, caching, logging) into one-line annotations instead of scattered boilerplate.
Real-World Analogy
Think of a function as a vending machine:
- Parameters = the slot where you put coins (inputs)
- Body = the mechanism inside that processes the input
- Return value = the snack that drops out (output)
- Closure = a machine that remembers your favorite snack between visits
- Decorator = a wrapper that adds a logo or warranty without changing the machine
Functions are first-class objects in Python — they can be passed as arguments, returned from other functions, and assigned to variables. Define them with def.
def greet(name: str) -> str:
return f"Hello, {name}!"
# Functions with no explicit return give None
def log(msg: str) -> None:
print(f"[LOG] {msg}")
# Docstrings are the canonical way to document
def distance(p1: tuple, p2: tuple) -> float:
"""Return Euclidean distance between two 2D points."""
return ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** 0.5
Arguments: Positional, Keyword, Default
def connect(host: str, port: int = 5432, ssl: bool = True):
...
connect("db.example.com") # positional + defaults
connect("db.example.com", 5433) # override port
connect(host="db.example.com", ssl=False) # keyword args
⚠️ Mutable default argument trap
Default values are evaluated once at function definition time. def f(x=[]): reuses the same list every call — old data accumulates! Use None and create inside:
def append_item(item, target: list | None = None):
if target is None:
target = []
target.append(item)
return target
Positional-only and Keyword-only Parameters (3.8+)
def api_call(method, url, /, *, timeout=30, retries=3):
# `/` — params before must be positional
# `*` — params after must be keyword
...
api_call("GET", "/users", timeout=60) # OK
api_call(method="GET", url="/users") # TypeError — method/url are positional-only
*args and **kwargs
Variable-length arguments. *args collects extra positional args as a tuple; **kwargs collects extra keyword args as a dict.
def log(level: str, *messages: str, **metadata):
print(f"[{level}]", *messages, metadata)
log("INFO", "user", "signed in", user_id=42, ip="1.2.3.4")
# [INFO] user signed in {'user_id': 42, 'ip': '1.2.3.4'}
Unpacking with * and **
args = ("GET", "/users")
opts = {"timeout": 60, "retries": 5}
api_call(*args, **opts) # forward args and kwargs
Lambdas, Closures, and First-Class Functions
Lambda Expressions
Anonymous one-line functions. Use sparingly — a named def is almost always clearer.
square = lambda x: x * x
print(square(5)) # 25
# Idiomatic: as a key function for sort, min, max, sorted
pairs = [(1, "b"), (3, "a"), (2, "c")]
sorted_by_letter = sorted(pairs, key=lambda p: p[1])
# [(3, 'a'), (1, 'b'), (2, 'c')]
Closures
Functions capture variables from their enclosing scope.
def make_counter(start: int = 0):
count = start
def increment():
nonlocal count
count += 1
return count
return increment
c = make_counter(10)
print(c(), c(), c()) # 11 12 13
nonlocal vs global
Without nonlocal, assigning count = ... in the inner function would create a new local. nonlocal binds to the enclosing function scope; global binds to the module top level.
Decorators
A decorator is a function that wraps another function. The @decorator syntax is sugar for fn = decorator(fn).
import functools
import time
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__}: {elapsed*1000:.2f}ms")
return result
return wrapper
@timed
def slow_sum(n: int) -> int:
return sum(range(n))
slow_sum(1_000_000) # slow_sum: 18.42ms
Always use @functools.wraps
Without it, the wrapped function loses its name, docstring, and signature — breaking tools like help(), debuggers, and pytest fixtures.
Built-in Decorators You'll See Often
| Decorator | Purpose |
|---|---|
@property | Turn a method into a read-only attribute |
@staticmethod | Method that doesn't take self/cls |
@classmethod | Method that takes the class instead of instance |
@functools.cache | Memoize a pure function |
@functools.lru_cache(maxsize=128) | Bounded memoization |
@dataclass | Auto-generate __init__/__repr__/__eq__ |
🎯 Practice Exercises
Exercise 1: Retry decorator
Write @retry(times=3) that re-runs the wrapped function on exception, up to times attempts. Sleep with exponential backoff between attempts.
Exercise 2: Memoize Fibonacci
Write a recursive fib(n) then decorate it with @functools.cache. Compare performance for n=35 before and after.
Exercise 3: Argument validator
Write a decorator @validate(name=str, age=int) that checks argument types at call time and raises TypeError on mismatch.
Exercise 4: Callable counter
Write a Counter class whose instances are callable — calling them increments and returns the count.