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: 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