Functions in Practice: Five Use Cases
Introduction
In 2.3 we learned that a function is a value: it can be passed, returned, and built on the fly. This page spends that capital. We work through five classic patterns that every Python programmer reaches for — decorators, recursion, map/filter/reduce, generators, and error handling. The first three are higher-order functions through and through; the last two round out the everyday toolkit for writing functions that are lazy where they should be and robust when things go wrong. Each builds directly on Chapters 1–2, so watch for the callbacks.
As always, every cell is runnable.
1. Decorators
Imagine you have written a dozen functions and now want every one of them to announce when it starts and finishes — for logging, timing, or debugging. You could paste the same two prints into all twelve bodies, but that is exactly the repetition functions were meant to kill. And if someone else wrote the functions, you may not be able to edit them at all.
The way out is a decorator: a higher-order function that takes a function, wraps it in some extra behavior, and returns the wrapped version. It is the pass-through wrapper from 2.3 §3.2, given a name and a purpose.
Example: a logging decorator
Two ideas from 2.3 are doing all the work here. wrapper is a closure — it remembers func from its enclosing scope — and it forwards *args, **kwargs so it works for any function, whatever its arguments. The @announce line is pure convenience: it means exactly add = announce(add), rebinding the name add to the wrapped version.
Key concept: decorator
A decorator is a higher-order function that takes a function and returns a
new function wrapping it. Writing @decorator above a def is shorthand for
name = decorator(name). The wrapper uses a closure (to remember the original)
and *args/**kwargs (to pass arguments through untouched).
A close cousin of decoration is the assert statement, which a wrapper often uses to guard a function's inputs. assert condition, "message" does nothing if the condition is true, but raises AssertionError with your message if it is false — a quick way to state an assumption and fail loudly when it is violated.
Example: assert as a guard
Pitfall: a wrapper must forward and return
If your wrapper forgets to pass *args, **kwargs to func, or forgets to
return func(...)'s result, the decorated function will silently lose its
arguments or its return value. A wrapper that does not return becomes a
function that returns None — the 2.1 trap, one level up.
Deep dive: memoization and functools.wraps
A decorator can do more than log — it can remember. A memoizing decorator caches results so repeated calls with the same arguments are instant:
def memoize(func):
cache = {} # closure state
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
One blemish: wrapping replaces the original, so add.__name__ becomes
"wrapper". Decorating wrapper with @functools.wraps(func) copies the
original's name and docstring across, keeping introspection honest.
In-class exercise: decorators
- Write a decorator
timedthat prints how long the wrapped function took (usetime.perf_counter()before and after). - Apply
announceto a function that takes keyword arguments, and confirm they still arrive correctly. - Explain in one sentence why
wrapperneeds*args, **kwargsrather than fixed parameters.
2. Recursion
A recursive function is one that calls itself. It is the natural shape for any problem that is defined in terms of a smaller copy of itself — and it leans directly on the call stack from 2.1, because each call gets its own frame.
Every recursion needs two parts: a base case that stops the descent, and a recursive case that moves one step toward it.
Example: factorial
Why does this work without a loop? Because every call to factorial gets its own frame on the call stack, each with its own n. The picture below freezes factorial(3) at its deepest point, just as factorial(0) is about to return. Four frames are stacked, paused mid-multiplication, each waiting on the one above it.
As each call hits its base case or finishes, its frame returns a value and is discarded, and the frame beneath it resumes — factorial(0) returns 1, then factorial(1) returns 1*1, then factorial(2) returns 2*1, and so on back down the stack.
The same shape solves many problems. Summing a list, for instance, is "the first element plus the sum of the rest."
Example: recursive sum and Fibonacci
Pitfall: forgetting the base case
A recursion with no reachable base case never stops calling itself. Python does
not loop forever, though — each call consumes a frame, and when the stack gets
too deep (about a thousand frames by default) it stops with a
RecursionError. If you see that error, your base case is missing or never
reached.
In-class exercise: recursion
- Write
count_down(n)that printsn, n-1, …, 1, "liftoff!"using recursion, not a loop. - Write a recursive
power(base, exp)computingbase ** exp(base caseexp == 0returns1). fibabove recomputes the same values many times. Decorate it with thememoizedecorator from §1 and compare how far up you can compute.
3. map, filter, and reduce
Three built-in higher-order functions capture the most common things you do to a sequence: transform every element, keep some elements, or boil the whole thing down to one value. Each takes a function plus an iterable — the perfect home for the lambda from 2.3.
map(func, iterable)appliesfuncto every element.filter(func, iterable)keeps the elements for whichfuncreturnsTrue.reduce(func, iterable)(from thefunctoolsmodule) combines elements pairwise, left to right, into a single result.
Example: map, filter, reduce
Notice the list(...) around map and filter: like range in 1.2, they return lazy iterators, computing each value only as you ask for it (the iterator protocol from 1.4). reduce is different — it consumes the whole iterable to produce one value, so it needs no list.
Key concept: map / filter / reduce
These are higher-order functions over an iterable: map transforms,
filter selects, reduce aggregates. The first two are lazy iterators;
reduce returns a single value and lives in functools.
Pitfall: often a comprehension is clearer
map and filter overlap with the comprehensions from 1.3. Many Pythonistas
write [x*x for x in nums] rather than list(map(lambda x: x*x, nums)), and
[x for x in nums if x % 2 == 0] rather than the filter version — they read
better. Reach for map/filter when you already have a named function to pass
(map(str, nums)), and keep reduce for genuine running aggregations.
In-class exercise: map / filter / reduce
- Use
mapto turn["1", "2", "3"]into[1, 2, 3]. (Hint: passint.) - Use
filterto keep only the words longer than three letters from["hi", "there", "ok", "world"]. - Use
reduceto find the maximum of[3, 9, 2, 7]without callingmax.
4. Generators
A generator is a function that produces a stream of values, one at a time, instead of computing them all up front. We met generators briefly in 1.4 as a way to make iterators; here is the mechanism. A function becomes a generator function the moment its body contains the keyword yield. Calling it does not run the body — it hands back a generator object, and each value is produced only when asked for.
The magic of yield is what it does to the frame. In 2.1, a normal return discarded the call's frame for good. yield does the opposite: it hands back a value but suspends the frame instead of discarding it — local variables, the current line, all of it — and keeps that frame alive, attached to the generator object out in the heap. When you ask for the next value, the very same frame is resumed exactly where it paused, on the line after the yield. A generator is, in effect, a function whose frame you can set down and pick back up.
Example: a generator function
Because values are produced on demand, a generator can describe a sequence too large — even infinite — to ever hold in memory. You simply stop asking when you have enough.
Example: an endless stream of squares
And the compact cousin from 1.3, the generator expression, is the same idea in one line — parentheses instead of the square brackets of a list comprehension:
Example: a generator expression
Key concept: generator
A generator is an iterator produced either by a function containing yield
or by a generator expression (… for …). It computes values lazily, pausing at
each yield and resuming on the next request, so it never needs to store the
whole sequence.
Pitfall: a generator is single-use
Like every iterator (1.4), a generator runs once. After you have looped over it
or exhausted it with list(), it is empty — looping again yields nothing. Call
the generator function again to get a fresh one.
5. Handling errors with try / except
Even correct functions meet bad input: a missing file, a zero divisor, a None where a number should be. The assert from §1 is for catching your own mistaken assumptions during development; try / except is for gracefully handling errors you expect might happen at runtime, so one bad value does not crash the whole program.
The shape is: run the risky code in a try block; if it raises an exception, the matching except block handles it instead of the program stopping.
Example: catching a specific error
The full form has two more optional blocks. else runs only if the try block raised nothing; finally runs no matter what, and is the place for cleanup that must always happen.
Example: else and finally
def read_int(text):
try:
value = int(text)
except ValueError:
print(f"{text!r} is not a whole number")
return None
else:
print("parsed successfully")
return value
finally:
print("done trying")
read_int("42") # parsed successfully / done trying -> 42
read_int("oops") # not a whole number / done trying -> None
This is exactly what lets a batch job skip the few broken records and finish the rest: wrap the risky step per item in try/except, log the failure, and carry on.
Key concept: try / except / else / finally
Put risky code in try; handle a named exception in except SomeError;
use else for code that should run only when no error occurred; use
finally for cleanup that must run either way.
Pitfall: don't catch everything blindly
A bare except: (or except Exception) swallows every error — including
typos in your own code and the user pressing Ctrl-C — and hides real bugs.
Catch the specific exceptions you actually expect (ZeroDivisionError,
ValueError, FileNotFoundError), and let the unexpected ones surface.
In-class exercise: error handling
- Write
safe_index(seq, i)that returnsseq[i], orNoneif the index is out of range (catchIndexError). - Wrap
int(input(...))in atry/exceptloop that keeps asking until the user types a valid number. - Explain when you would use
assertversustry/except.
Summary
Five everyday patterns, all resting on the idea that a function is a value:
| Use case | What it is | Builds on |
|---|---|---|
| Decorator | a higher-order wrapper that extends a function | closures, *args/**kwargs (2.3) |
| Recursion | a function that calls itself | the call stack (2.1) |
| map / filter / reduce | higher-order functions over an iterable | lambda (2.3), iterators (1.4) |
| Generators | functions that yield a lazy stream | iterators (1.4), comprehensions (1.3) |
| try / except | handling runtime errors gracefully | — |
Next, 2.5 Loose Ends and Style steps back from features to the smaller finishing touches — documenting functions, finer parameter lists, and the shared style conventions (PEP 8) that make Python code, including everything you have just written, readable to everyone else.