Functional Programming Concepts

0/3 in this phase0/54 across the roadmap

📖 Concept

Functional programming (FP) in Python is not about abandoning OOP — it is about leveraging functions as the primary unit of abstraction to write code that is easier to test, reason about, and compose. Python is a multi-paradigm language, and its FP support is pragmatic rather than pure, giving you the best of both worlds.

Pure functions are the cornerstone of FP. A pure function: (1) always returns the same output for the same input (referential transparency), and (2) produces no side effects (no mutation of external state, no I/O). Pure functions are trivially testable — you only need to assert inputs against outputs, with no mocking or setup.

Immutability means once a value is created, it cannot be changed. Python offers immutable built-ins (tuple, frozenset, str, bytes) and tools like @dataclass(frozen=True) and NamedTuple for custom immutable objects. Immutability eliminates entire classes of bugs caused by shared mutable state, especially in concurrent programs.

First-class functions mean functions are objects — you can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures. This enables higher-order functions like map, filter, sorted, and custom combinators.

Concept Description Python Example
Pure function No side effects, deterministic def add(a, b): return a + b
Higher-order function Takes/returns functions map(str.upper, words)
Currying Transform f(a, b) into f(a)(b) Manual closures or functools.partial
Partial application Fix some arguments, return new function partial(pow, 2) gives 2**n
Composition Combine functions: (f . g)(x) = f(g(x)) compose(f, g)(x)
Closure Inner function captures outer scope def make_adder(n): return lambda x: x + n

Currying transforms a function that takes multiple arguments into a chain of functions each taking a single argument: f(a, b, c) becomes f(a)(b)(c). Partial application fixes some arguments and returns a new function that takes the remaining ones. Python's functools.partial is the standard tool for partial application, while currying is typically done with manual closures or third-party libraries.

Function composition chains functions together so the output of one feeds into the input of the next. While Python lacks a built-in composition operator, you can build one with functools.reduce or a simple helper. Composition is the FP equivalent of Unix pipes — small, focused functions chained to solve complex problems.

💻 Code Example

codeTap to expand ⛶
1# ============================================================
2# Pure functions vs impure functions
3# ============================================================
4from typing import Sequence
5
6# IMPURE: modifies external state, non-deterministic
7total = 0
8
9def impure_add(x: int) -> int:
10 global total
11 total += x # Side effect: mutates global state
12 return total
13
14
15# PURE: same input always gives same output, no side effects
16def pure_add(a: int, b: int) -> int:
17 return a + b
18
19
20# PURE: returns new data instead of mutating
21def add_item(items: tuple, new_item: str) -> tuple:
22 """Return a new tuple with the item appended (immutable)."""
23 return items + (new_item,)
24
25
26cart = ("apple", "bread")
27new_cart = add_item(cart, "milk")
28print(cart) # ("apple", "bread") — original unchanged
29print(new_cart) # ("apple", "bread", "milk")
30
31
32# ============================================================
33# Immutability with frozen dataclasses and NamedTuple
34# ============================================================
35from dataclasses import dataclass, replace
36from typing import NamedTuple
37
38
39@dataclass(frozen=True)
40class Money:
41 amount: int # cents to avoid float issues
42 currency: str
43
44 def add(self, other: "Money") -> "Money":
45 if self.currency != other.currency:
46 raise ValueError(f"Cannot add {self.currency} and {other.currency}")
47 return Money(self.amount + other.amount, self.currency)
48
49 def multiply(self, factor: int) -> "Money":
50 return Money(self.amount * factor, self.currency)
51
52
53price = Money(1999, "USD") # $19.99
54tax = Money(160, "USD") # $1.60
55total_price = price.add(tax)
56print(total_price) # Money(amount=2159, currency='USD')
57
58# price.amount = 0 # FrozenInstanceError!
59updated = replace(price, amount=2499) # Create modified copy
60print(updated) # Money(amount=2499, currency='USD')
61
62
63class Point(NamedTuple):
64 x: float
65 y: float
66
67 def distance_to(self, other: "Point") -> float:
68 return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
69
70
71p1 = Point(3.0, 4.0)
72p2 = Point(0.0, 0.0)
73print(p1.distance_to(p2)) # 5.0
74# p1.x = 10.0 # AttributeError: can't set attribute
75
76
77# ============================================================
78# First-class functions and higher-order functions
79# ============================================================
80from typing import Callable
81
82
83def apply_discount(
84 prices: Sequence[float],
85 strategy: Callable[[float], float],
86) -> list[float]:
87 """Apply a discount strategy to all prices."""
88 return [strategy(p) for p in prices]
89
90
91def flat_discount(amount: float) -> Callable[[float], float]:
92 """Create a flat discount function."""
93 def discount(price: float) -> float:
94 return max(0.0, price - amount)
95 return discount
96
97
98def percentage_discount(pct: float) -> Callable[[float], float]:
99 """Create a percentage discount function."""
100 def discount(price: float) -> float:
101 return price * (1 - pct / 100)
102 return discount
103
104
105prices = [100.0, 250.0, 50.0, 175.0]
106print(apply_discount(prices, flat_discount(20)))
107# [80.0, 230.0, 30.0, 155.0]
108print(apply_discount(prices, percentage_discount(15)))
109# [85.0, 212.5, 42.5, 148.75]
110
111
112# ============================================================
113# Currying and partial application
114# ============================================================
115import functools
116
117
118# Manual currying via closures
119def curry_multiply(a: float):
120 def inner(b: float):
121 return a * b
122 return inner
123
124
125double = curry_multiply(2)
126triple = curry_multiply(3)
127print(double(5)) # 10
128print(triple(5)) # 15
129
130
131# functools.partial — the Pythonic way
132def power(base: int, exponent: int) -> int:
133 return base ** exponent
134
135
136square = functools.partial(power, exponent=2)
137cube = functools.partial(power, exponent=3)
138print(square(5)) # 25
139print(cube(5)) # 125
140
141# Partial with keyword arguments for configuration
142import logging
143
144debug_log = functools.partial(logging.log, logging.DEBUG)
145error_log = functools.partial(logging.log, logging.ERROR)
146
147
148# ============================================================
149# Function composition
150# ============================================================
151def compose(*funcs: Callable) -> Callable:
152 """Compose functions right-to-left: compose(f, g, h)(x) = f(g(h(x)))."""
153 def composed(x):
154 result = x
155 for fn in reversed(funcs):
156 result = fn(result)
157 return result
158 return composed
159
160
161def pipe(*funcs: Callable) -> Callable:
162 """Compose functions left-to-right: pipe(f, g, h)(x) = h(g(f(x)))."""
163 def piped(x):
164 result = x
165 for fn in funcs:
166 result = fn(result)
167 return result
168 return piped
169
170
171# Build a text processing pipeline
172normalize = pipe(
173 str.strip,
174 str.lower,
175 lambda s: s.replace(" ", " "),
176)
177
178print(normalize(" Hello World ")) # "hello world"
179
180
181# Compose validators
182def validate_non_empty(s: str) -> str:
183 if not s:
184 raise ValueError("String must not be empty")
185 return s
186
187
188def validate_max_length(max_len: int) -> Callable[[str], str]:
189 def validator(s: str) -> str:
190 if len(s) > max_len:
191 raise ValueError(f"String exceeds {max_len} chars")
192 return s
193 return validator
194
195
196def validate_alphanumeric(s: str) -> str:
197 if not s.replace(" ", "").isalnum():
198 raise ValueError("String must be alphanumeric")
199 return s
200
201
202validate_username = pipe(
203 validate_non_empty,
204 validate_max_length(32),
205 validate_alphanumeric,
206 str.lower,
207)
208
209print(validate_username("JohnDoe42")) # "johndoe42"
210
211
212# ============================================================
213# Practical: immutable configuration with chaining
214# ============================================================
215@dataclass(frozen=True)
216class QueryBuilder:
217 table: str
218 conditions: tuple = ()
219 order_by: str | None = None
220 limit_val: int | None = None
221
222 def where(self, condition: str) -> "QueryBuilder":
223 return replace(self, conditions=self.conditions + (condition,))
224
225 def order(self, column: str) -> "QueryBuilder":
226 return replace(self, order_by=column)
227
228 def limit(self, n: int) -> "QueryBuilder":
229 return replace(self, limit_val=n)
230
231 def build(self) -> str:
232 sql = f"SELECT * FROM {self.table}"
233 if self.conditions:
234 sql += " WHERE " + " AND ".join(self.conditions)
235 if self.order_by:
236 sql += f" ORDER BY {self.order_by}"
237 if self.limit_val is not None:
238 sql += f" LIMIT {self.limit_val}"
239 return sql
240
241
242query = (
243 QueryBuilder("users")
244 .where("active = 1")
245 .where("age > 18")
246 .order("created_at DESC")
247 .limit(50)
248)
249print(query.build())
250# SELECT * FROM users WHERE active = 1 AND age > 18 ORDER BY created_at DESC LIMIT 50

🏋️ Practice Exercise

Exercises:

  1. Write a pure function transform_records(records: tuple[dict, ...], transformations: tuple[Callable, ...]) -> tuple[dict, ...] that applies a pipeline of transformation functions to each record without mutating any input. Each transformation takes a dict and returns a new dict.

  2. Implement a curry(func) decorator that automatically curries any function of arbitrary arity. curry(lambda a, b, c: a + b + c)(1)(2)(3) should return 6, and curry(lambda a, b, c: a + b + c)(1, 2)(3) should also return 6.

  3. Build a @dataclass(frozen=True) based immutable Config class with nested frozen dataclasses. Implement a Config.merge(other) method that deep-merges two configs, returning a new Config. Test that the original objects remain unchanged.

  4. Create a compose_async(*funcs) function that composes async functions in the same way compose works for sync functions. Test it with three async transformations on a string.

  5. Implement a functional Either type (like Rust's Result) with Success and Failure variants. Add .map(fn), .flat_map(fn), and .get_or_else(default) methods. Use it to build a validation pipeline that collects errors instead of raising exceptions.

  6. Rewrite a class-based strategy pattern (e.g., shipping cost calculator with different strategies) using pure functions and functools.partial. Compare the two approaches for testability and conciseness.

⚠️ Common Mistakes

  • Using mutable default arguments in 'pure' functions. def process(items, cache={}) shares the same dict across all calls. Use None as default and create inside the function: cache = cache if cache is not None else {}.

  • Confusing currying with partial application. Currying transforms f(a, b, c) into f(a)(b)(c) — each call takes exactly one argument. Partial application fixes some arguments and returns a function taking the rest: partial(f, a) returns g(b, c). Python's functools.partial does partial application, not currying.

  • Over-using lambda for complex logic. Lambdas are limited to a single expression and cannot contain statements. Use named functions for anything non-trivial — they are easier to debug (stack traces show the name), test, and document.

  • Assuming Python enforces immutability deeply. frozen=True on a dataclass prevents reassignment of attributes, but if an attribute is a mutable object (like a list), the list itself can still be mutated. Use tuples and frozensets for truly immutable nested data.

  • Creating deeply nested closures that are hard to debug. While closures are powerful, more than two levels of nesting makes code difficult to follow. Extract inner functions into named, testable units.

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for Functional Programming Concepts