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:
- Same input → same output — always, without exception
- 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,
}
- Each function has one job:
parse_log_lineparses,is_errorfilters,count_by_levelaggregates - Everything is testable: Each function can be tested in isolation
- Extensible: A new filter is a new function, not a new
if
→ 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
- Functional Anthology (various authors) – pp. 26, 34, 43
- Functional Programming in Kotlin by Tutorials (raywenderlich) – pp. 115, 137, 158
- Functional Patterns (Tomas Petricek) – p. 61
- Domain Modeling Made Functional (Scott Wlaschin) – pp. 164–165
- Functional Programming in Swift (Chris Eidhof et al.) – p. 28