Functions

Easy40 min read

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

DecoratorPurpose
@propertyTurn a method into a read-only attribute
@staticmethodMethod that doesn't take self/cls
@classmethodMethod that takes the class instead of instance
@functools.cacheMemoize a pure function
@functools.lru_cache(maxsize=128)Bounded memoization
@dataclassAuto-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.