Skip to content

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
def add(a, b):
 """Pure function - no side effects."""
 return a + b

# IMPURE
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
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
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
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

-

  • [02 Higher Order Functions & Closures](/05-py3/03-functional-programming/(02-higher-order-functions-closures/) - Functions that work with functions
  • [03 Function Composition & Piping](/05-py3/03-functional-programming/(03-function-composition-piping/) - Combining functions
  • [03 Iterator & Generator Protocol](/05-py3/01-fundamentals/(03-iterator-generator-protocol/) - Lazy functional evaluation
  • 02 Decorators - Functional patterns as decorators