11.1. Functional Programming#

from functools import wraps

11.1.1. FP in Python#

Functional programming (FP) is a programming paradigm that treats computation as the evaluation of mathematical functions. Rather than describing how to do something step-by-step by mutating program state such as in imperative programming paradigm, functional programs describe what to compute by transforming inputs into outputs.

                         ┌──────────────────────┐
                         │ Programming Paradigm │
                         └──────────┬───────────┘
                  ┌─────────────────┴─────────────────┐     
                  ▼                                   ▼
          ┌───────────────┐                   ┌───────────────┐
          │  Imperative   │                   │  Declarative  │
          └───────┬───────┘                   └───────┬───────┘
          ┌───────┴───────┐                   ┌───────┴───────┐
          ▼               ▼                   ▼               ▼
┌────────────────┐ ┌────────────────┐ ┌────────────┐ ┌─────────┐
│  Procedural    │ │ Object-Oriented│ │ Functional │ │ Logic   │
└───────┬────────┘ └───────┬────────┘ └─────┬──────┘ └────┬────┘
        ▼                  ▼                ▼             ▼
  C, Basic, Fortran     C++, Java      Haskell, Elm   Prolog, Datalog
        

Programming Paradigms

Popular Functional Programming Languages:

  • Pure: Haskell, Elm

  • Hybrid/Multi-paradigm: Scala (dual-first), Kotlin (OO-first), F#, (function-first) Elixir, Clojure.

  • FP-enabled: JavaScript, Python, Rust, Swift

Key principles of functional programming:

  • Pure functions: a function always returns the same output for the same input and has no side effects (it doesn’t modify external state, e.g., no modifying external state, no I/O, no randomness). For example, math.sqrt(4) is pure; random.random() is not.

  • Immutability: data is not modified in place; new values are produced instead. For example, instead of modifying a list, you produce a new one. This eliminates an entire class of bugs: shared mutable state is the root cause of most concurrency (multithread) bugs.

  • First-class functions: functions are treated like any other value: they can be assigned to variables, passed as arguments, and returned from other functions.

  • Higher-order functions: functions that accept other functions as arguments or return them (e.g., map(), filter(), sorted()).

The FP ideas that have leaked into mainstream programming over the past 20 years: map/filter/reduce, lambda expressions, immutable data structures, list comprehensions, pipelines; all trace back to functional languages. Even Java and C++ have absorbed them. Understanding FP conceptually makes you better at Python even if you never write a line of Haskell, because you start to recognize when a pure function + transformation pipeline is cleaner than a loop with mutation.

Benefits of functional programming:

  • Easier testing and maintenance: Pure functions depend only on their inputs, simplifying testing.

  • Reduced side effects: By eliminating hidden state changes, debugging becomes more straightforward.

  • Easy parallelization: Immutable data means no locks or race conditions, making parallel programming easier.

  • Improved modularity: Reusable components are easier to build and combine.

Drawbacks of functional programming:

  • Steep learning curve: Concepts like monads, immutability, and higher-order functions can be difficult for beginners.

  • Performance concerns: Immutability can cause high memory usage due to frequent creation of new objects, which may impact performance in resource-constrained systems.

  • Recursion overhead: Lacking loops, recursive functions can lead to stack overflow if not handled properly. (FP discourages loops for immutability and referential transparency, and uses recursion instead.)

Python is not a purely functional language. It is multi-paradigm: you can write imperative code, object-oriented code, and functional-style code in the same program. In practice, Python’s functional tools are most useful when they make data transformations clearer, reduce hidden side effects, or let you reuse behavior through functions.

These ideas are not just theory. They directly explain why Python features like comprehensions, map(), filter(), sorted(key=...), decorators, and functools are useful.

The goal is not to make Python look like a different language. The goal is to write transformations that are easy to test, easy to reason about, and hard to accidentally break with hidden shared state.

11.1.2. Purity & Immutability#

11.1.2.1. Pure functions#

A pure function has two properties:

  1. Deterministic: given the same inputs, it always returns the same output

  2. No side effects: it doesn’t modify anything outside itself (e.g., define a variable outside loop then update it through looping)

These two properties together mean you can understand a pure function in complete isolation. You don’t need to know the history of the program, what other functions have run, or what global state looks like. The function is a black box: inputs in, output out, nothing else changes. This makes pure functions:

  • Easy to test: just call the function with inputs and check the output; no setup or teardown needed

  • Easy to reason about: you can read the function body without tracking down every variable it might touch

  • Safe to reuse: calling the function twice won’t produce different results or corrupt shared state

11.1.2.2. Immutability#

Immutability means producing new values instead of modifying existing ones. For example, rather than changing a list in place, you return a new list with the change applied.

This matters because Python passes objects by reference. When you pass a list to a function and the function calls .append() on it, the original list outside the function is also changed; this is a common source of bugs that can be hard to track down.

The fix is simple: instead of mutating, return something new. lst + [x] creates a new list. {**d, 'key': val} creates a new dictionary. The original is untouched.

An impure function mutates the list passed in — the caller’s list is silently changed.

def append_zero_impure(lst):
    lst.append(0)               ### modifies the input list, which is a side effect
    return lst

data = [1, 2, 3]
append_zero_impure(data)
print(data)                     ### [1, 2, 3, 0]  ← original changed, even though we didn't ask for it
[1, 2, 3, 0]

A pure function returns a new list — the original is untouched.

def append_zero_pure(lst):
    return lst + [0]

data = [1, 2, 3]
result = append_zero_pure(data)

print(data)           # [1, 2, 3] -> original data unchanged
print(result)         # [1, 2, 3, 0]
[1, 2, 3]
[1, 2, 3, 0]

The same idea applies to dictionaries. In the following example, we use dictionary unpacking operator ** creates a new dictionary by copying the key-value pairs from the original. The original settings dictionary remains unchanged, and we get a new dictionary with the updated theme.

settings = {'theme': 'light', 'font_size': 12}

updated_settings = {**settings, 'theme': 'dark'}

print(settings)         # {'theme': 'light', 'font_size': 12}   ### settings is unchanged
print(updated_settings) # {'theme': 'dark', 'font_size': 12}
{'theme': 'light', 'font_size': 12}
{'theme': 'dark', 'font_size': 12}

In the dictionary example above we see that:

  • Original State: settings is defined in memory.

  • Unpacking: {**settings, ...} copies the key-value pairs from the original dictionary into a new one.

  • Overwriting: By placing 'theme': 'dark' after the unpacking, you tell Python to use the new value if the key already exists.

  • Purity: The original settings remains untouched, preventing side effects in other parts of your program that might be relying on those original values.

11.1.3. Functions as Values#

In Python, functions are first-class values; they can be assigned to variables, stored in data structures, passed as arguments, and returned from other functions.

Lambda expressions create small anonymous functions in a single line. They are most useful as short key function or callback functions:

# Syntax: lambda arguments: expression
square = lambda x: x ** 2
add    = lambda x, y: x + y

Lambdas are commonly used with map(), filter(), sorted(), and reduce(): the core higher-order functions of functional programming.

Function

Syntax

Description

map()

map(function, iterable)

Applies a function to every item in an iterable

filter()

filter(function, iterable)

Keeps only the items that match a condition

reduce()

reduce(f, seq)

Repeatedly combines items into a single value by applying a function cumulatively from left to right: f(f(f(f(1,2),3),4),5)

Here we see some examples of map(), filter(), and sorted() working with lambda.

# Using lambda with map(): apply a function to each item in a list
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x ** 2, numbers))          ### map(function, iterable)
print(squared)  # [1, 4, 9, 16, 25]                     ### function applies to each element of numbers
                                                        ### nowadays list comprehensions are often preferred for
                                                        ### readability, but map with lambda can be concise for simple transformations
# Using lambda with filter(): decide which items to keep based on a condition
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))  ### filter(function, iterable) keeps only 
print(even_numbers)  # [2, 4]                               ### elements where the function returns True (even numbers in this case)     

# Using lambda with sorted(): sort a list of tuples by a specific element
students = [('Alice', 85), ('Bob', 90), ('Charlie', 78)]
sorted_by_grade = sorted(students, key=lambda student: student[1])  ### key function extracts the grade 
                                                                    ### (the second element of the tuple) for sorting

print(sorted_by_grade)  # [('Charlie', 78), ('Alice', 85), ('Bob', 90)]
[1, 4, 9, 16, 25]
[2, 4]
[('Charlie', 78), ('Alice', 85), ('Bob', 90)]

With reduce(), you have to import reduce from functools first. When accumulator is not initialized, it takes the first element from the iterable to begin with.

from functools import reduce

numbers = [1, 2, 3, 4, 5]

# reduce(f, seq) applies f cumulatively: f(f(f(f(1,2),3),4),5)
total = reduce(lambda acc, x: acc + x, numbers) ### acc == accumulator, x == current element
print(total)   # 15

product = reduce(lambda acc, x: acc * x, numbers)   # acc starts at 1 and multiplies each number in the list
print(product) # 120
15
120

Modern Python often prefers clearer built-ins alternatives for common cases like:

  • sum() # prefer over reduce for addition

  • max() # prefer over reduce for maximum

  • loops

  • comprehensions

Because reduce() can become harder to read for complex logic. You would reach for reduce() only when there is genuinely no built-in equivalent. Custom accumulations are where reduce() starts to earn its place.

11.1.4. Higher-Order Functions#

A higher-order function is a function that either accepts a function as an argument or returns a function as its result. Python’s built-in map(), filter(), and sorted() are all higher-order functions, but You can write your own higher-order functions:

def apply_twice(f, x):
    return f(f(x))

apply_twice(lambda x: x + 3, 10)  # 16

Decorators are a Python idiom built entirely on this idea: a decorator is a higher-order function that takes a function and returns an enhanced version of it.

11.1.4.1. Decorators#

A decorator is a higher-order function; it takes a function as input and returns a modified version. The @decorator_name syntax is Python’s shorthand for func = decorator(func), making it easy to add cross-cutting behavior (logging, timing, validation) without changing the original function’s code.

How decorators work:

  1. They take a function as input

  2. Wrap it in a new function that adds behavior

  3. Return the wrapper, applied automatically with @decorator_name

11.1.4.2. Built-in Decorators#

  • @property: Creates getter/setter methods

  • @staticmethod: Method doesn’t need self or cls (covered in ch12)

  • @classmethod: Method receives class as first argument (covered in ch12)

@functools.wraps is a helper used inside custom decorators. It preserves the wrapped function’s name, docstring, and other metadata.

Let’s take a look at @property decorator, which we have used when creating access to internal/private attribute.

class BankAccount:
    def __init__(self):
        self._balance = 100

    @property
    def balance(self):
        """Current balance in dollars (read-only)."""
        return self._balance

Here at first glance, the @property decorator helps us so that we don’t have to type parentheses () when using it as an API for accessing the attribute.

acct = BankAccount()
print(acct.balance)

But the bigger idea is that it turns a method into a managed attribute, which means you can:

  • compute values dynamically

  • validate data

  • make attributes read-only

  • keep internal implementation hidden

For example, you may:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14 * self.radius ** 2

then,

c = Circle(5)
print(c.area)

So now you know the @property works more than passing data.

Now, to see an example of how a basic decorator can be built and used.

# Basic decorator example
from functools import wraps

def my_decorator(func): ### the wrapper function is defined inside the decorator and takes the original function as an argument. It adds some behavior before and after calling the original function, and then returns the wrapper.
    @wraps(func)        ### replaces the wrapper's metadata with the original function's metadata, so that the decorated function retains its name and docstring
    def wrapper():
        print("Something before the function")
        result = func() ### calls the original function and stores its result
        print("Something after the function")
        return result
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()
# Output:
# Something before the function
# Hello!
# Something after the function
Something before the function
Hello!
Something after the function
# Class-based decorator
class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0
    ### __call__ makes an instance of the class behave like a function — it's invoked whenever you call the object with ().
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count} of {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls     ### say_hi = CountCalls(say_hi)  
def say_hi():   ### # say_hi is now a CountCalls instance
    print("Hi there!")

say_hi()        ### triggers __call__
say_hi()
say_hi()
Call #1 of say_hi
Hi there!
Call #2 of say_hi
Hi there!
Call #3 of say_hi
Hi there!
# Decorator for functions with arguments
from functools import wraps

def timer_decorator(func):      
    from time import perf_counter
    @wraps(func)    ### preserves decorated function's metadata (like name and docstring) in the wrapper, so that the decorated function retains its identity
    def wrapper(*args, **kwargs):   ### the wrapper function takes the original function as an argument. It adds some behavior before and after calling the original function, and then returns the wrapper.
        start_time = perf_counter()
        result = func(*args, **kwargs)
        end_time = perf_counter()
        print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@timer_decorator        ### apply decorator timer_decorator function to decorated slow_function()
def slow_function():    ### is passed to timer_decorator as the argument func, and the wrapper function is returned and replaces slow_function
    import time
    time.sleep(1)
    return "Done!"

result = slow_function()
print(result)
slow_function took 1.0051 seconds
Done!

11.1.5. Comprehensions#

Comprehensions provide a concise way to create lists, dictionaries, and sets. They’re more Pythonic and often more efficient than traditional loops.

Advantages of comprehensions:

  • More concise and readable

  • Often faster execution

  • More Pythonic

  • Can be used in-line

When to use traditional loops:

  • Complex logic that doesn’t fit in one line

  • Multiple operations per iteration

  • Need to break or continue based on conditions

  • Debugging complex transformations

# Basic list comprehension syntax: [expression for item in iterable if condition]

# Traditional loop approach
squares = []
for x in range(10):
    squares.append(x ** 2)
print("Traditional:", squares)

# List comprehension — same result, one line
squares = [x ** 2 for x in range(10)]
print("Comprehension:", squares)

# With a filter condition
even_squares = [x ** 2 for x in range(10) if x % 2 == 0]
print("Even squares:", even_squares)

# Dictionary comprehension
word_lengths = {word: len(word) for word in ["apple", "banana", "cherry"]}
print("Word lengths:", word_lengths)

# Set comprehension (automatically deduplicates)
unique_remainders = {x % 4 for x in range(12)}
print("Unique remainders mod 4:", unique_remainders)
Traditional: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Comprehension: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Even squares: [0, 4, 16, 36, 64]
Word lengths: {'apple': 5, 'banana': 6, 'cherry': 6}
Unique remainders mod 4: {0, 1, 2, 3}
# Advanced comprehension techniques

# Conditional expression (ternary operator) in comprehension
numbers = range(-5, 6)
absolute_or_zero = [x if x >= 0 else -x for x in numbers]
print("Absolute values:", absolute_or_zero)

# Multiple conditions
filtered_numbers = [x for x in range(20) if x % 2 == 0 if x % 3 == 0]
print("Divisible by 2 and 3:", filtered_numbers)

# Nested dictionary comprehension
students = ['Alice', 'Bob', 'Charlie']
subjects = ['Math', 'Science', 'English']
grades = {student: {subject: 0 for subject in subjects} for student in students}
print("Grade book:", grades)
Absolute values: [5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5]
Divisible by 2 and 3: [0, 6, 12, 18]
Grade book: {'Alice': {'Math': 0, 'Science': 0, 'English': 0}, 'Bob': {'Math': 0, 'Science': 0, 'English': 0}, 'Charlie': {'Math': 0, 'Science': 0, 'English': 0}}

11.1.6. Choosing a Style#

Both comprehension and map/filter approaches transform or filter sequences, but they communicate intent differently.

Situation

Prefer

Simple transformation (x * 2, s.upper())

Comprehension: reads like English

Complex multi-argument function already defined

map(fn, seq): no need to rewrap in a lambda

Transforming and filtering in one pass

Comprehension: [f(x) for x in seq if cond(x)] is cleaner

Chaining multiple transformations

map/filter: function composition can stay readable

Result must be lazy / memory-sensitive

map/filter: they return iterators, not lists

Readability rule of thumb: if the lambda inside map or filter is longer than ~30 characters, replace it with a named function or a comprehension.

# Hard to read — lambda is too complex
result = list(filter(lambda s: len(s) > 3 and s.startswith('a'), words))

# Clearer as a comprehension
result = [s for s in words if len(s) > 3 and s.startswith('a')]

Pipeline readability check: a functional pipeline of 2–3 steps is fine. For longer chains, break steps into named intermediate variables so the intent stays obvious.

### Exercise: Sorting and Filtering with Lambda
#   Given the student records below (name, grade, year):
#   1. Use filter() with a lambda to keep students with grade >= 80.
#   2. Use sorted() with a lambda to rank passing students by grade, highest first.
#   3. Use map() with a lambda to extract just the names.
#   4. Print the final list of names.
### Your code starts here.
students = [
    ('Alice', 85, 'junior'), ('Bob', 72, 'senior'),
    ('Charlie', 91, 'sophomore'), ('Diana', 65, 'junior'), ('Eve', 88, 'senior')
]

### Your code ends here.

Hide code cell source

### Solution
students = [
    ('Alice', 85, 'junior'), ('Bob', 72, 'senior'),
    ('Charlie', 91, 'sophomore'), ('Diana', 65, 'junior'), ('Eve', 88, 'senior')
]
passing = list(filter(lambda s: s[1] >= 80, students))
ranked  = sorted(passing, key=lambda s: s[1], reverse=True)
names   = list(map(lambda s: s[0], ranked))
print(names)  # ['Charlie', 'Eve', 'Alice']
['Charlie', 'Eve', 'Alice']

11.1.7. Summary#

  • Pure functions always return the same output for the same input and have no side effects — they are easy to test and reason about.

  • Immutability means producing new values instead of mutating existing ones, avoiding hidden bugs from shared state.

  • First-class functions let you pass and return functions like any other value.

  • Lambda expressions create short anonymous functions for use with map(), filter(), and sorted().

  • Decorators are higher-order functions that wrap and extend behavior without modifying the original function.

  • Comprehensions offer a concise, readable syntax for transforming and filtering sequences.

  • Choose comprehensions for readability; prefer map/filter when working with lazy iterators or pre-defined functions.

Next: recursion, context managers, and functools utilities.