Home Fp Basics Python Article 1
Part 1 Functional Programming & Category Theory

Functional Programming with Python – The Fundamentals

Image: AI-generated


Functional Programming with Python – The Fundamentals

How pure functions, immutability, and composition make your code predictable, testable, and composable.


1. Pure Functions: Predictable Instead of Surprising

A pure function has two properties:

  1. Same input → same output — always, without exception
  2. No side effects — it changes nothing outside itself

"Purity means that functions have no side effects. The output of a function is predictably the same as long as the input is the same." — Functional Anthology, p. 34

❌ Impure: Global State

_discount_rate = 0.1  # global, mutable state

def calculate_discount_impure(price: float) -> float:
    return price * (1 - _discount_rate)

If someone changes _discount_rate elsewhere, calculate_discount_impure(100) suddenly returns 80.0 instead of 90.0. The function itself looks correct — the bug is invisible.

✅ Pure: Everything Explicit

def calculate_discount(price: float, rate: float) -> float:
    return price * (1 - rate)

calculate_discount(100, 0.1) always returns 90.0. No hidden state, no surprises.

Referential Transparency

Because a pure function always returns the same output for the same input, every function call can be replaced by its return value — and the program behaves identically. This is called referential transparency.

def total_after_discount(prices: list[float], rate: float) -> float:
    return sum(calculate_discount(p, rate) for p in prices)

total_after_discount([100, 200, 50], 0.1)  # → 315.0

▶ Try it yourself

def calculate_discount(price: float, rate: float) -> float:
    return price * (1 - rate)

def total_after_discount(prices: list[float], rate: float) -> float:
    return sum(calculate_discount(p, rate) for p in prices)

print(calculate_discount(100, 0.1))
print(total_after_discount([100, 200, 50], 0.1))

You can mentally replace calculate_discount(100, 0.1) with 90.0 — the result stays the same. Code becomes not only testable but also reasonble about.

"Pure functions promote referential transparency. Pure functions can be easily rearranged and reordered for execution on multiple threads." — Functional Anthology, p. 34

Testability is the immediate win: Pure functions need no setUp(), no tearDown(), no mocks.

def test_calculate_discount():
    assert calculate_discount(100, 0.1) == 90.0
    assert calculate_discount(100, 0.0) == 100.0
    assert calculate_discount(0, 0.5) == 0.0

→ Full code: src/pure_functions.py

2. Immutability: Created Once, Never Changed

The second foundation: Data is not changed but transformed. Instead of mutating an object, a new one is created with the desired changes.

"Two of the main characteristics that make your code functional are immutability and the use of pure functions." — Functional Programming in Kotlin by Tutorials, p. 158

The Aliasing Problem

Python lists are mutable — a classic bug:

cart_a = [{"item": "Book", "price": 29.99}]
cart_b = cart_a  # no copy, just an alias!
cart_b.append({"item": "Pen", "price": 1.99})

len(cart_a)  # → 2, not 1!

cart_a and cart_b point to the same list. A change to one affects the other.

Python's Tools for Immutability

Tool Replaces Immutable?
tuple list
frozenset set
@dataclass(frozen=True) Regular classes
NamedTuple Dicts / classes

Practice: An Immutable Shopping Cart

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class CartItem:
    name: str
    price: float
    quantity: int = 1

@dataclass(frozen=True)
class Cart:
    items: tuple[CartItem, ...] = ()

    @property
    def total(self) -> float:
        return sum(item.price * item.quantity for item in self.items)

Instead of modifying the cart, a new one is created:

def add_item(cart: Cart, item: CartItem) -> Cart:
    return replace(cart, items=cart.items + (item,))

def apply_discount(cart: Cart, rate: float) -> Cart:
    discounted_items = tuple(
        replace(item, price=round(item.price * (1 - rate), 2))
        for item in cart.items
    )
    return replace(cart, items=discounted_items)
cart = Cart()
cart2 = add_item(cart, CartItem("Book", 29.99))
cart.total   # → 0.0 (original unchanged!)
cart2.total  # → 29.99

cart always remains the same. Mutating a frozen object fails immediately:

item = CartItem("Book", 29.99)
item.price = 19.99  # → AttributeError: frozen dataclass

→ Full code: src/immutability.py

3. First-Class Functions & Higher-Order Functions

In Python, functions are first-class citizens: they can be assigned to variables, passed as parameters, and returned from other functions. A higher-order function (HOF) takes a function as an argument or returns one.

"Higher-order functions accept a lambda expression as input [or] return a lambda expression as an output value." — Functional Programming in Kotlin by Tutorials, p. 115

Functions as Parameters

def shout(text: str) -> str:
    return text.upper() + "!"

def whisper(text: str) -> str:
    return text.lower() + "..."

def greet(name: str, style):
    return style(name)

greet("Python", shout)    # → 'PYTHON!'
greet("Python", whisper)  # → 'python...'

greet is a HOF: it receives a strategy (function) instead of hard-coding the behavior.

The Classic HOFs: map, filter, reduce

from functools import reduce

prices = [120, 80, 45, 200, 30]

discounted = map(lambda p: p * 0.9, prices)
above_50 = filter(lambda p: p >= 50, discounted)
total = reduce(lambda acc, p: acc + p, above_50, 0.0)
# → 360.0

No loops, no mutable accumulators. Each step describes what should happen, not how.

Custom HOFs: The retry Decorator

def retry(max_attempts=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
            raise last_error
        return wrapper
    return decorator

@retry(max_attempts=3)
def fetch_data():
    # ... might fail ...
    pass

Lambda: When Yes, When No?

# ✅ Good: short, clear, one-off
sorted(products, key=lambda p: p[1])

# ❌ Bad: too complex for lambda
sorted(products, key=lambda p: p[1] * (1 - p[2]) if p[2] > 0 else p[1])

Rule of thumb: If a lambda would need a name, make it a named function.

→ Full code: src/higher_order.py

4. Closures: Functions with Memory

A closure is a function that remembers variables from its creation context — even after that context has been left.

"Closures are a bit magical, so it's worth examining them in more detail." — Functional Patterns, p. 61

The Function Factory

def make_multiplier(factor: float):
    def multiply(x: float) -> float:
        return x * factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

double(5)  # → 10.0
triple(5)  # → 15.0

double and triple are different functions with different enclosed state. The factor lives on in the closure even though make_multiplier has long finished.

Closure vs. Class

Closures are a lightweight alternative to classes when only one operation with configuration is needed:

# With class
class Greeter:
    def __init__(self, greeting):
        self.greeting = greeting
    def __call__(self, name):
        return f"{self.greeting}, {name}!"

# As closure
def make_greeter(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

The closure is more compact and communicates more clearly: this is about a configured function, not an object with state.

Enclosing Configuration

def make_formatter(decimal_places=2, currency="€"):
    def format_price(amount: float) -> str:
        return f"{amount:.{decimal_places}f} {currency}"
    return format_price

format_eur = make_formatter(2, "€")
format_usd = make_formatter(2, "$")

format_eur(1234.5)  # → '1234.50 €'
format_usd(1234.5)  # → '1234.50 $'

Python allows mutating closure variables with nonlocal. In FP we avoid that — a closure should read its state, not write it.

→ Full code: src/closures.py

5. Function Composition: Small Parts, Big Whole

Perhaps the most important principle: Instead of writing one large function that does everything, small functions are built and plugged together.

"Function composition [means] combining functions by connecting the output of the first to the input of the second. [...] An important aspect of this kind of composition is information hiding." — Domain Modeling Made Functional, p. 164

Mathematical Composition

In mathematics: (f ∘ g)(x) = f(g(x)) — first g, then f. In Python:

def compose(*funcs):
    def composed(x):
        result = x
        for f in reversed(funcs):
            result = f(result)
        return result
    return composed

Pipe: Left to Right

The mathematical notation reads backwards. More natural — like Unix pipes — is the pipe direction:

"The filter composition operation is an example of function composition [...] like Unix pipes." — Functional Programming in Swift, p. 28

def pipe(*funcs):
    def piped(x):
        result = x
        for f in funcs:
            result = f(result)
        return result
    return piped

Practice: Text Pipeline

def strip_whitespace(text): return text.strip()
def lowercase(text): return text.lower()
def remove_punctuation(text):
    return "".join(c for c in text if c.isalnum() or c.isspace())
def split_words(text): return text.split()
def unique_sorted(words): return sorted(set(words))

clean_and_tokenize = pipe(
    strip_whitespace,
    lowercase,
    remove_punctuation,
    split_words,
    unique_sorted,
)

clean_and_tokenize("  Hello, World! Hello Python.  ")
# → ['hello', 'python', 'world']

Each function is independently testable. The pipeline reads top to bottom. Steps can be added or removed at any time without touching the rest.

→ Full code: src/fp_composition.py

6. Comprehensions & Generators: Python's Declarative Style

Python has an ace that many FP languages don't offer this way: comprehensions.

Imperative vs. Declarative

# Imperative: HOW it's done
result = []
for item in items:
    if item["price"] >= min_price:
        result.append(item["name"].upper())

# Declarative: WHAT is done
result = [
    item["name"].upper()
    for item in items
    if item["price"] >= min_price
]

Same logic, same output — but the comprehension communicates the intent, not the mechanism.

Generators: Lazy Evaluation

A generator computes values on demand — ideal for large or infinite data streams:

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
first_8 = [next(fib) for _ in range(8)]
# → [0, 1, 1, 2, 3, 5, 8, 13]

Generator expressions are the lazy variant of list comprehensions:

# Eager: creates the entire list in memory
sum([x * x for x in range(1_000_000)])

# Lazy: computes on-the-fly, needs barely any memory
sum(x * x for x in range(1_000_000))

→ Full code: src/comprehensions.py

7. Putting It All Together: Log Analysis

All concepts combined in a realistic example: a log analysis tool.

The Problem

Log lines like:

2024-01-15 10:30:00 INFO Server started
2024-01-15 10:32:00 ERROR Database connection lost
2024-01-15 10:34:00 WARNING Memory at 90%

❌ Imperative: Mutable State

def analyze_logs_imperative(lines):
    error_messages = []
    level_counts = {}

    for line in lines:
        parts = line.strip().split(" ", 3)
        if len(parts) < 4:
            continue
        level = parts[2]
        message = parts[3]

        if level in level_counts:
            level_counts[level] += 1
        else:
            level_counts[level] = 1

        if level == "ERROR":
            error_messages.append(message)

    return {
        "total": sum(level_counts.values()),
        "level_counts": level_counts,
        "errors": error_messages,
    }

Two mutable data structures, nested logic, all in one function.

✅ Functional: Pure Functions + Composition

from dataclasses import dataclass
from collections import Counter

@dataclass(frozen=True)
class LogEntry:
    timestamp: str
    level: str
    message: str

def parse_log_line(line: str) -> LogEntry | None:
    parts = line.strip().split(" ", 3)
    if len(parts) < 4:
        return None
    return LogEntry(
        timestamp=f"{parts[0]} {parts[1]}",
        level=parts[2],
        message=parts[3],
    )

def is_error(entry: LogEntry) -> bool:
    return entry.level == "ERROR"

def count_by_level(entries):
    return dict(Counter(e.level for e in entries))

def analyze_logs_functional(lines):
    entries = [e for line in lines
               if (e := parse_log_line(line)) is not None]
    errors = list(filter(is_error, entries))
    error_messages = list(map(lambda e: e.message, errors))

    return {
        "total": len(entries),
        "level_counts": count_by_level(entries),
        "errors": error_messages,
    }

→ Full code: src/log_analyzer.py

🧠 Quick check

Which property is *not* characteristic of a pure function?

Summary

Concept Core Idea Python Tool
Pure Functions Same input → same output Explicit parameters, no global state
Immutability Don't change, transform frozen dataclass, tuple, replace()
Higher-Order Functions Functions as values map, filter, reduce, decorators
Closures Functions with memory Nested functions, factories
Composition Plug small parts together compose(), pipe(), pipelines

Each of these concepts makes code immediately more testable, readable, and robust. But one question remains: What happens when something goes wrong? How do you handle errors functionally? The answers lie in Algebraic Data Types — the topic of the next article.


All code examples from this article can be found in the src/ directory. They are runnable and include tests.

Sources

📄 As PDF