11.2. Functional Practice#
This section focuses on applied functional techniques: recursion, context managers, and functools patterns that you can use in larger programs.
11.2.1. Recursion#
Recursion is a programming technique where a function calls itself to solve a problem. In functional programming, recursion is often the natural way to repeat work because pure functions avoid mutating loop counters or other external state.
Every recursive function needs:
Base case: A condition that stops the recursion
Recursive case: The function calling itself with modified parameters
Recursion is particularly useful for problems that can be broken down into smaller, similar subproblems.
# Classic example: Factorial
def factorial(n):
# Base case
if n == 0 or n == 1:
return 1
# Recursive case
else:
return n * factorial(n - 1)
print(factorial(5)) # Output: 120
print(factorial(0)) # Output: 1
120
1
# Fibonacci sequence
# This direct recursive version is intentionally inefficient; later, @lru_cache fixes that.
def fibonacci(n):
if n <= 1:
return n
else:
return fibonacci(n - 1) + fibonacci(n - 2)
# Generate first 10 Fibonacci numbers
for i in range(10):
print(fibonacci(i), end=' ')
print() # Output: 0 1 1 2 3 5 8 13 21 34
0 1 1 2 3 5 8 13 21 34
# Tree traversal example
def print_directory(path, level=0):
"""Recursively print directory structure"""
import os
if os.path.exists(path):
print(' ' * level + os.path.basename(path))
if os.path.isdir(path):
for item in os.listdir(path)[:3]: # Limit to first 3 items
item_path = os.path.join(path, item)
print_directory(item_path, level + 1)
# Example usage (commented out to avoid file system dependency)
# print_directory('/usr/local')
11.2.1.1. Recursion vs Iteration#
Advantages of recursion:
Elegant and intuitive for certain problems
Mirrors mathematical definitions
Good for tree/graph traversal
Disadvantages:
Can be slower due to function call overhead
Risk of stack overflow with deep recursion
May use more memory
Python recursion limit: Python has a default recursion limit (usually 1000). You can check it with sys.getrecursionlimit().
### Exercise: Recursive Functions
# 1. Write `count_down(n)` that prints n, n-1, ..., 1, then "Blastoff!".
# Base case: n <= 0 → print "Blastoff!" and return.
# Recursive case: print n, then call count_down(n - 1).
# 2. Write `sum_digits(n)` that returns the sum of the digits of a positive integer.
# Example: sum_digits(1234) → 10; sum_digits(99) → 18.
# Hint: base case is n < 10; use n % 10 and n // 10 for the recursive case.
### Your code starts here.
### Your code ends here.
5
4
3
2
1
Blastoff!
10
18
11.2.2. Context Managers#
Context managers are objects that define what happens at the start and end of a with statement. They ensure proper resource management and cleanup, even if an exception occurs.
They connect back to functional programming here because @contextmanager is itself a decorator: a higher-order function that transforms a generator function into a context manager.
# Creating context managers using contextlib
from contextlib import contextmanager
@contextmanager
def timer():
import time
start_time = time.perf_counter()
print("Timer started")
try:
yield start_time
finally:
end_time = time.perf_counter()
print(f"Timer finished: {end_time - start_time:.4f} seconds")
# Using the timer context manager
with timer() as start:
import time
time.sleep(0.1) # Simulate some work
print(f"Work started at: {start}")
Timer started
Work started at: 303686.358957833
Timer finished: 0.1019 seconds
11.2.3. The functools Module#
The functools module provides higher-order functions that operate on or
return other functions. Three particularly useful tools are reduce,
partial, and lru_cache.
11.2.3.1. functools.reduce()#
reduce(function, iterable) applies a two-argument function cumulatively
to the items of an iterable, reducing it to a single value.
Think of it as a fold: it combines elements left to right.
from functools import reduce
from itertools import chain
# Sum all numbers (same as built-in sum, shown for illustration)
total = reduce(lambda acc, x: acc + x, [1, 2, 3, 4, 5])
print(total) # 15
# Product of all numbers
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])
print(product) # 120
# Flatten a list of lists
nested = [[1, 2], [3, 4], [5, 6]]
flat = reduce(lambda acc, x: acc + x, nested)
print(flat) # [1, 2, 3, 4, 5, 6]
# In practice, prefer chain.from_iterable() or a nested comprehension for flattening.
print(list(chain.from_iterable(nested)))
print([item for group in nested for item in group])
15
120
[1, 2, 3, 4, 5, 6]
[1, 2, 3, 4, 5, 6]
[1, 2, 3, 4, 5, 6]
11.2.3.2. functools.partial()#
partial(function, *args, **kwargs) returns a new function with some
arguments pre-filled. This is called partial application — useful for
creating specialized versions of general-purpose functions.
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(4)) # 16
print(cube(3)) # 27
# Practical: pre-configure print with a separator
import functools
header = functools.partial(print, sep=' | ')
header('Name', 'Score', 'Grade') # Name | Score | Grade
16
27
Name | Score | Grade
11.2.3.3. functools.lru_cache()#
@lru_cache memoizes a function’s results: the first time a function is
called with given arguments, the result is stored. On subsequent calls with
the same arguments, the cached result is returned immediately — no
recomputation needed.
LRU stands for Least Recently Used: when the cache is full, the least recently accessed entry is evicted.
from functools import lru_cache
import time
@lru_cache(maxsize=None) # unlimited cache
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
# Without cache, fib(40) would take seconds (2^40 calls)
start = time.perf_counter()
print(fib(50)) # 12586269025
elapsed = time.perf_counter() - start
print(f'Computed in {elapsed:.6f}s') # microseconds with cache
# Inspect cache statistics
print(fib.cache_info()) # CacheInfo(hits=..., misses=51, maxsize=None, currsize=51)
12586269025
Computed in 0.000069s
CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
### Exercise: functools
# 1. Use reduce() to find the maximum value in [3, 1, 4, 1, 5, 9, 2, 6].
# (Hint: lambda acc, x: acc if acc > x else x)
# 2. Use partial() to create `add10` — a function that adds 10 to any number.
# Test: add10(5) → 15, add10(-3) → 7.
# 3. Decorate `count_ways(n)` below with @lru_cache to speed it up.
# count_ways(n) returns how many ways to climb n stairs (1 or 2 at a time).
from functools import reduce, partial, lru_cache
def count_ways(n):
if n <= 1: return 1
return count_ways(n - 1) + count_ways(n - 2)
### Your code starts here.
### Your code ends here.
### Solution
from functools import reduce, partial, lru_cache
# 1. reduce to find max
nums = [3, 1, 4, 1, 5, 9, 2, 6]
maximum = reduce(lambda acc, x: acc if acc > x else x, nums)
print(maximum) # 9
# 2. partial to create add10
add = lambda a, b: a + b
add10 = partial(add, 10)
print(add10(5)) # 15
print(add10(-3)) # 7
# 3. lru_cache to speed up count_ways
@lru_cache(maxsize=None)
def count_ways(n):
if n <= 1: return 1
return count_ways(n - 1) + count_ways(n - 2)
print(count_ways(30)) # 1346269 (fast with cache)
9
15
7
1346269
11.2.4. Summary#
Concept |
Key idea |
|---|---|
Lambda |
Anonymous single-expression function: |
|
Apply a function to every element: |
|
Keep elements where function returns |
|
Sort by custom criterion: |
List comprehension |
|
Dict comprehension |
|
Set comprehension |
|
Decorator |
Wraps a function to add behavior without modifying it; use |
|
Preserves the wrapped function’s name and docstring |
Recursion |
Function that calls itself; requires a base case + recursive case |
Context manager |
|