10.2. Functional Practice#

This section focuses on applied functional techniques: recursion, context managers, and functools patterns that you can use in larger programs.

10.2.1. Recursion#

Recursion is taught in 4.3 Recursion: a base case stops the calls, and a recursive case calls the function on a smaller problem. Recursion suits functional programming because a recursive function repeats work without mutating a loop counter. This section compares recursion with iteration, and the lru_cache() example later in this notebook caches a recursive function.

Hide code cell source

# 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')

10.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.

Hide code cell source

### Solution
def count_down(n):
    if n <= 0:
        print("Blastoff!")
    else:
        print(n)
        count_down(n - 1)

def sum_digits(n):
    if n < 10:
        return n
    return n % 10 + sum_digits(n // 10)

count_down(5)
print(sum_digits(1234))  # 10
print(sum_digits(99))    # 18
5
4
3
2
1
Blastoff!
10
18

10.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.

The @contextmanager decorator from contextlib turns a function that contains yield into a context manager. The function runs in three parts:

  • Setup. When the with statement starts, the code before yield runs.

  • Pause. At yield, the function pauses and hands the yielded value to the with block, which binds it to the name after as. The body of the with block then runs.

  • Cleanup. When the with block ends, the function resumes after yield. Put the cleanup code in a finally clause around the yield, so it runs even if the with block raises an exception.

In the timer() example below, the setup records the start time, yield start_time binds that value to start, and the finally clause prints the elapsed time.

A function that pauses at yield is called a generator function. This section uses yield only inside @contextmanager; 11.2 Generators covers yield and generator functions fully.

# 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: 108.466186376
Timer finished: 0.1002 seconds

The finally clause matters most when something goes wrong. In the next example, the second with block raises a ValueError. The context manager prints its cleanup line first, and then the exception keeps traveling outward to the try/except that surrounds the with statement. The context manager does not hide the error; it only guarantees that the cleanup code runs.

from contextlib import contextmanager

@contextmanager
def opened(name):
    print(f"Open {name}")         # setup: runs when the with block starts
    try:
        yield name.upper()        # pause: this value is bound by `as`
    finally:
        print(f"Close {name}")    # cleanup: runs even if the block raises

# A block that finishes normally
with opened("sales") as label:
    print(f"Reading {label}")

# A block that raises: cleanup runs, then the error reaches the outer except
try:
    with opened("orders") as label:
        print(f"Reading {label}")
        raise ValueError("bad record")
except ValueError as error:
    print(f"Caught: {error}")
Open sales
Reading SALES
Close sales
Open orders
Reading ORDERS
Close orders
Caught: bad record

10.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.

10.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

# 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 a nested comprehension for flattening; it reads more clearly.
print([item for group in nested for item in group])
15
120
[1, 2, 3, 4, 5, 6]
[1, 2, 3, 4, 5, 6]

10.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

10.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.

The example below decorates the recursive Fibonacci function from 4.3 Recursion. Without the cache, fib(n) solves the same subproblems again and again; with the cache, each fib(k) is computed once and then looked up.

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.000116s
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.

Hide code cell source

### 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

10.2.4. Summary#

Concept

Key idea

Lambda

Anonymous single-expression function: lambda x: x ** 2

map()

Apply a function to every element: map(f, iterable)

filter()

Keep elements where function returns True

sorted() with key

Sort by custom criterion: sorted(items, key=lambda x: x[1])

List comprehension

[expr for item in seq if cond] — concise list creation

Dict comprehension

{key_expr: value_expr for item in iterable} — inline dict construction

Set comprehension

{expr for item in seq} — inline set construction

Decorator

Wraps a function to add behavior without modifying it; use @name

@functools.wraps

Preserves the wrapped function’s name and docstring

Recursion

Function that calls itself; requires a base case + recursive case

Context manager

with block that handles setup/teardown; @contextmanager for custom ones

@contextmanager function

Setup before yield; the yielded value is bound by as; cleanup in finally runs even if the block raises