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:

  1. Base case: A condition that stops the recursion

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

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

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