Functional Programming Fundamentals¶
Overview¶
Functional programming is a paradigm where: - Pure functions: Output depends only on input (no side effects) - Immutability: Data never changes, create new versions instead - First-class functions: Functions are values (can be passed around) - Composition: Build complex operations from simple ones
Understanding functional principles reveals a different way to structure ML code.
Pure Functions vs Side Effects¶
What is a Pure Function?¶
# PURE: Output depends only on input
def add(a, b):
"""Pure function - no side effects."""
return a + b
# IMPURE: Has side effects
result_cache = {}
def add_with_cache(a, b):
"""Impure - modifies global state."""
key = (a, b)
if key not in result_cache:
result_cache[key] = a + b # Side effect!
return result_cache[key]
# IMPURE: Depends on external state
global_offset = 10
def add_with_offset(a, b):
"""Impure - depends on global state."""
return a + b + global_offset # Depends on global!
Benefits of Purity¶
# Pure function benefits
def pure_square(x):
"""Pure - always same output for same input."""
return x * x
# Testing is trivial
assert pure_square(3) == 9
assert pure_square(0) == 0
assert pure_square(-2) == 4
# Can be cached easily
cache = {}
def cached_square(x):
if x not in cache:
cache[x] = pure_square(x)
return cache[x]
# Can be parallelized
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(pure_square, range(1000000)))
Referential Transparency¶
The Key Principle¶
A function is referentially transparent if you can replace it with its result without changing program behavior:
# Referentially transparent
def get_price(item):
"""Pure function - can replace with result."""
prices = {'apple': 1.0, 'banana': 0.5}
return prices[item]
# These two are equivalent:
cost1 = get_price('apple') + get_price('apple')
cost2 = 1.0 + 1.0 # Same result, can replace function call!
# NOT referentially transparent
import random
def get_discount():
"""Impure - different result each time."""
return random.random()
# These are NOT equivalent:
discount1 = get_discount() + get_discount() # Two different random values
discount2 = get_discount() # Just one random value
# Can't replace function with its result!
Immutability¶
Working Without Mutation¶
# IMPERATIVE: Mutate in place
def add_item_imperative(items, new_item):
"""Modifies list in place (impure)."""
items.append(new_item) # Mutation!
return items
original = [1, 2, 3]
result = add_item_imperative(original, 4)
print(original) # [1, 2, 3, 4] - CHANGED!
# FUNCTIONAL: Create new collection
def add_item_functional(items, new_item):
"""Returns new list, doesn't modify original (pure)."""
return items + [new_item] # Create new list!
original = [1, 2, 3]
result = add_item_functional(original, 4)
print(original) # [1, 2, 3] - unchanged!
print(result) # [1, 2, 3, 4]
Immutable Collections¶
# Tuples are immutable
data = (1, 2, 3)
# data[0] = 10 # TypeError! Can't modify
# frozenset is immutable
unique = frozenset([1, 2, 3, 2, 1])
# unique.add(4) # AttributeError! Can't modify
# Creating new "versions" instead of mutating
original = (1, 2, 3)
new_version = original + (4,) # Creates new tuple
print(original) # (1, 2, 3) - unchanged
print(new_version) # (1, 2, 3, 4) - new data
Immutable Data with namedtuple¶
from collections import namedtuple
# Define immutable record
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(1, 2)
# p1.x = 3 # AttributeError! Immutable
# Create new version with updated field
p2 = p1._replace(x=3) # Creates new Point
print(p1) # Point(x=1, y=2) - original unchanged
print(p2) # Point(x=3, y=2) - new version
First-Class Functions¶
Functions as Values¶
# Functions are first-class - can be assigned to variables
square = lambda x: x ** 2
cube = lambda x: x ** 3
# Can store in collections
operations = [square, cube]
# Can call from collections
for op in operations:
print(op(3)) # Calls 9, then 27
# Can return from functions
def make_multiplier(n):
"""Return a function."""
return lambda x: x * n
times_two = make_multiplier(2)
times_three = make_multiplier(3)
print(times_two(5)) # 10
print(times_three(5)) # 15
# Can pass as arguments
def apply_operation(x, y, op):
"""Take function as argument."""
return op(x, y)
print(apply_operation(3, 4, lambda a, b: a + b)) # 7
print(apply_operation(3, 4, lambda a, b: a * b)) # 12
Lambda Functions¶
Lightweight Function Definition¶
# Lambda for simple functions
square = lambda x: x ** 2
# Multiple arguments
add = lambda x, y: x + y
# No arguments
get_value = lambda: 42
# Can be more complex
compute = lambda x, y: x * y + x / y
# Useful in map/filter/reduce
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x ** 2, numbers))
print(squared) # [1, 4, 9, 16, 25]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
total = reduce(lambda acc, x: acc + x, numbers)
print(total) # 15
Pure Function Patterns¶
Pattern 1: Transformation Functions¶
# Pure data transformation
def transform_user(user, updates):
"""Transform user dict without mutation."""
return {**user, **updates} # Merge dicts
original_user = {'name': 'Alice', 'age': 30}
updated_user = transform_user(original_user, {'age': 31})
print(original_user) # {'name': 'Alice', 'age': 30} - unchanged
print(updated_user) # {'name': 'Alice', 'age': 31} - new dict
Pattern 2: Accumulation¶
# Pure accumulation pattern
def sum_numbers(numbers):
"""Pure function that accumulates."""
total = 0
for n in numbers:
total += n # Local mutation is okay
return total
print(sum_numbers([1, 2, 3, 4, 5])) # 15
# Using reduce (more functional)
from functools import reduce
total = reduce(lambda acc, n: acc + n, [1, 2, 3, 4, 5], 0)
print(total) # 15
Pattern 3: Conditional Pure Functions¶
# Pure function with conditionals
def classify_number(n):
"""Pure classification function."""
if n < 0:
return "negative"
elif n == 0:
return "zero"
else:
return "positive"
# No side effects, always same output for same input
assert classify_number(-5) == "negative"
assert classify_number(0) == "zero"
assert classify_number(5) == "positive"
Real-World: Pure Functions in ML¶
Pure Data Processing¶
import numpy as np
def normalize_features(X):
"""Pure normalization - no side effects."""
mean = np.mean(X, axis=0)
std = np.std(X, axis=0)
return (X - mean) / std
# Can be called multiple times, same result
data = np.random.randn(100, 10)
normalized1 = normalize_features(data)
normalized2 = normalize_features(data)
np.testing.assert_array_equal(normalized1, normalized2)
Pure Loss Computation¶
import torch
def mse_loss(predictions, targets):
"""Pure loss function - no side effects."""
return torch.mean((predictions - targets) ** 2)
# Can compute loss multiple times
pred = torch.tensor([1.0, 2.0, 3.0])
targ = torch.tensor([1.1, 2.1, 2.9])
loss1 = mse_loss(pred, targ)
loss2 = mse_loss(pred, targ)
assert torch.allclose(loss1, loss2) # Same result!
Summary: Functional vs Imperative¶
| Aspect | Functional | Imperative |
|---|---|---|
| Mutations | Avoided | Common |
| Predictability | High (pure) | Variable (side effects) |
| Testing | Easy (no setup) | Complex (setup/teardown) |
| Parallelization | Safe | Requires synchronization |
| Composability | Easy | Harder |
| Reasoning | Substitution principle | Trace execution flow |
Related Topics¶
- 02 Higher Order Functions & Closures - Functions that work with functions
- 03 Function Composition & Piping - Combining functions
- 03 Iterator & Generator Protocol - Lazy functional evaluation
- 02 Decorators - Functional patterns as decorators