CMPUT-274 · Lesson 2 of 12 · 31 min

Functional programming I: pure functions and higher-order functions

Functional programming builds programs out of functions in the mathematical sense: rules that map each input to exactly one output and do nothing else. This lesson introduces the two ideas that everything else in the paradigm rests on. The first is purity: a pure function's result depends only on its arguments, and calling it has no effect on the rest of the world. The second is that functions are values: they can be stored in variables, passed as arguments, returned as results and built at run time. Functions that take or return other functions are called higher-order functions, and they let you express whole families of loops as single, reusable operations.

Pure functions and side effects

A function is pure when two conditions hold. Its result is determined entirely by its arguments, so the same arguments always give the same result. And it has no side effects: it does not modify global variables, mutate its arguments, print, read files, depend on the clock or random numbers, or otherwise interact with anything outside itself. The function \(f(x) = x^2 + 1\) is pure. A function that appends to a list passed in, or that returns a different value each time because it reads the current time, is not.

One pure function and three impure ones
import random
counter = 0

def area(w, h):              # pure: result depends only on arguments
    return w * h

def area_logged(w, h):       # impure: changes global state
    global counter
    counter += 1
    return w * h

def add_item(cart, item):    # impure: mutates its argument
    cart.append(item)
    return cart

def roll():                  # impure: result is not determined by arguments
    return random.randint(1, 6)

print(area(3, 4), area(3, 4))
print(area_logged(3, 4), area_logged(3, 4), "counter =", counter)
c = ["pen"]; add_item(c, "ink"); print("cart after call:", c)
Output
12 12
12 12 counter = 2
cart after call: ['pen', 'ink']

Side effects are not bad in themselves; a program with no effects at all could never show its result. The functional discipline is to push effects to the edges of a program. A core of pure functions does the computation, and a thin outer layer reads input, calls the core, and writes output. The core is then easy to test, because each function can be checked by comparing outputs for given inputs, with no setup or clean-up of external state.

Referential transparency

An expression is referentially transparent if it can be replaced by its value without changing what the program does. If `area(3, 4)` is pure, then anywhere it appears you may write `12` instead, and nothing changes. This property lets you reason about code the way you simplify algebra: substitute equals for equals. It also lets compilers and libraries optimize safely, by caching results, reordering calls or evaluating them in parallel.

Worked problem

Testing referential transparency

Is the expression `len(log)` referentially transparent in this code? `log = []`; `def record(x): log.append(x); return x`; `a = record(1) + len(log)`.

Show solution
  1. Before the call to record, log is empty, so len(log) is 0.
  2. Python evaluates record(1) first, which appends to log, then evaluates len(log), which is now 1, so a = 2.
  3. If we replaced len(log) by its earlier value 0, a would be 1: a different result.
  4. The value of len(log) depends on when it is evaluated, because record has a side effect on log.

Answer: No. Because record mutates log, len(log) has different values at different times, so it cannot be replaced by a single value. Side effects destroy referential transparency.

Why purity makes code easier to reason about
PropertyPure functionImpure function
Same arguments, same resultAlwaysNot guaranteed
Can be cached (memoized)SafelyOnly with care
Can run in parallel with other callsYes, no shared stateOnly if effects are isolated
Unit testingCompare output to expected valueNeeds setup, mocks or clean-up
Order of calls mattersOnly through data dependenciesOften, because of shared state

Functions as first-class values

A language treats functions as first-class values when functions can be used anywhere other values can: bound to names, stored in data structures, passed to and returned from other functions. Python, JavaScript, Rust and modern Java all do this. In Python, `def` creates a function object and binds it to a name; `lambda` creates an anonymous function from a single expression. Both produce values of the same kind.

Functions are values: stored, looked up and compared like any other object
def double(x):
    return 2 * x

square = lambda x: x * x
ops = {"double": double, "square": square, "negate": lambda x: -x}

for name, f in ops.items():          # functions stored in a dictionary
    print(f"{name:>6}(7) = {f(7)}")

g = double                           # another name for the same function object
print(g is double, type(g).__name__)
Output
double(7) = 14
square(7) = 49
negate(7) = -7
True function

Storing functions in a dictionary is a common way to replace a long `if`/`elif` chain that dispatches on a command name. Adding a new operation means adding one entry instead of editing a branching statement. This idea, choosing behaviour by looking up a function, reappears in lesson 6 as the strategy and command patterns.

Higher-order functions: map, filter and reduce

A higher-order function takes a function as an argument or returns one. Three classic higher-order functions capture the most common loop shapes. `map(f, xs)` applies `f` to every element. `filter(p, xs)` keeps the elements for which the predicate `p` is true. `reduce(f, xs, init)`, in Python's `functools`, combines the elements into a single value by repeatedly applying a two-argument function. Once you recognize these shapes, many loops collapse into one line that says what is being computed.

Three rows of boxes. In the map row each input box is transformed into one output box. In the filter row some input boxes are crossed out and only matching ones pass through. In the reduce row the boxes are folded left to right into a single accumulated value.
map transforms each element, filter keeps some, reduce folds all into one value
filter, map and reduce on sensor readings
from functools import reduce
from operator import add, mul

readings = [12.5, -1.0, 14.2, 13.8, -1.0, 15.1]       # -1.0 marks a failed sensor read

valid = list(filter(lambda r: r >= 0, readings))
in_f = list(map(lambda c: round(c * 9 / 5 + 32, 1), valid))
total = reduce(add, valid, 0.0)
product = reduce(mul, [1, 2, 3, 4, 5], 1)

print("valid:", valid)
print("fahrenheit:", in_f)
print("mean:", round(total / len(valid), 2))
print("5! =", product)
Output
valid: [12.5, 14.2, 13.8, 15.1]
fahrenheit: [54.5, 57.6, 56.8, 59.2]
mean: 13.9
5! = 120

In Python, list comprehensions often express map and filter more readably than lambda: `[c * 9 / 5 + 32 for c in readings if c >= 0]` does both at once. Built-ins such as `sum`, `max`, `min`, `any` and `all` are specialized reductions. Use `reduce` when no specialized function exists, for example to merge a list of dictionaries or to compose a list of functions. Prefer whatever reads most clearly; the paradigm is about avoiding hidden state, not about a particular syntax.

Worked problem

From loop to higher-order functions

Rewrite this loop without mutation: `longest = ""` then `for w in words: if w.isalpha() and len(w) > len(longest): longest = w`.

Show solution
  1. The loop keeps only alphabetic words: that is a filter with predicate str.isalpha.
  2. It then keeps the longest one seen so far: that is a reduction, which max with key=len expresses directly.
  3. The initial value "" handles an empty list; max needs default="" for the same behaviour.
  4. One subtle difference: on ties the loop keeps the first longest word, and max also returns the first maximal element, so the behaviour matches.

Answer: `longest = max(filter(str.isalpha, words), key=len, default="")`. It states the result directly: the longest alphabetic word, or the empty string.

Returning functions: closures

A function defined inside another function can refer to the outer function's variables. When the inner function is returned, it keeps access to those variables even after the outer function has finished. The combination of a function and the variables it captures is called a closure. Closures let you build specialized functions from general ones at run time.

A function factory: each returned function remembers its own factor and offset
def make_converter(factor, offset=0.0):
    def convert(x):                  # captures factor and offset
        return x * factor + offset
    return convert

c_to_f = make_converter(9 / 5, 32)
km_to_mi = make_converter(0.621371)

print(c_to_f(100), c_to_f(-40))
print(round(km_to_mi(42.195), 2))
print("captured:", [cell.cell_contents for cell in c_to_f.__closure__])
Output
212.0 -40.0
26.22
captured: [1.8, 32]

Each call to `make_converter` creates a new closure with its own captured values, which you can see in the `__closure__` attribute. Closures are the functional counterpart of objects: an object bundles data with methods, and a closure bundles data with one function. In fact, a closure can be used to make a tiny object with private state, and an object with a single method can stand in for a closure. Lesson 6 compares the two when it discusses the strategy pattern.

One trap is worth knowing. Python closures capture variables, not values. If you create several lambdas in a loop that all refer to the loop variable, they all see its final value when they are called later. The usual fix is to bind the current value as a default argument, `lambda x, k=k: x + k`, or to use a factory function as above.

Partial application and composition

Partial application fixes some arguments of a function and returns a function of the rest. Python's `functools.partial` does this without writing a closure by hand. Function composition builds a new function that applies one function and then another: \((g \circ f)(x) = g(f(x))\). Together they let you assemble processing pipelines from small, tested pieces.

partial fixes an argument; compose chains functions right to left
from functools import partial, reduce

def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
cube = partial(power, exp=3)
print(square(9), cube(4))

def compose(*fs):
    """compose(f, g, h)(x) == f(g(h(x)))"""
    return reduce(lambda f, g: lambda x: f(g(x)), fs, lambda x: x)

clean = compose(str.title, str.strip, lambda s: s.replace("_", " "))
print(repr(clean("  data_science_basics  ")))
Output
81 64
'Data Science Basics'

Worked problem

Building a pipeline from small functions

You need a function that takes a list of prices in cents, drops those under 100, applies 13 per cent tax, rounds to whole cents and returns the total. Build it from small pure functions.

Show solution
  1. Define keep = lambda xs: [x for x in xs if x >= 100].
  2. Define tax = lambda xs: [round(x * 1.13) for x in xs].
  3. Use sum as the final reduction.
  4. Compose: checkout = compose(sum, tax, keep). For [50, 200, 1000], keep gives [200, 1000], tax gives [226, 1130], and sum gives 1356.

Answer: checkout([50, 200, 1000]) = 1356 cents. Each stage is pure and can be tested on its own; changing the tax rate means changing one small function.

Functional style in Java and the limits of purity in Python

Functional ideas are not unique to Python. Java's streams, added in Java 8, provide map, filter and reduce over collections with lambdas. The program below computes the same sensor statistics as the Python example. The stream pipeline is a description of the computation; nothing runs until the terminal operation `average` asks for a result, a form of laziness you will study in lesson 3.

The same filter and map pipeline written with Java streams (compiled and run with Java 21)
import java.util.List;

public class Readings {
    public static void main(String[] args) {
        List<Double> readings = List.of(12.5, -1.0, 14.2, 13.8, -1.0, 15.1);
        double mean = readings.stream()
                .filter(r -> r >= 0)
                .mapToDouble(Double::doubleValue)
                .average()
                .orElse(Double.NaN);
        List<Double> fahrenheit = readings.stream()
                .filter(r -> r >= 0)
                .map(c -> Math.round((c * 9 / 5 + 32) * 10) / 10.0)
                .toList();
        System.out.printf("mean = %.2f%n", mean);
        System.out.println("fahrenheit = " + fahrenheit);
    }
}
Output
mean = 13.90
fahrenheit = [54.5, 57.6, 56.8, 59.2]

Python does not enforce purity. Nothing stops a function from mutating its arguments, and lists and dictionaries are mutable by default. Functional programming in Python is therefore a discipline you choose: write functions that return new values, avoid global state, and keep effects at the boundaries. Languages such as Haskell enforce purity in the type system, marking effectful code explicitly. Lesson 7 discusses how types can carry this kind of information.

Common mistakes

  • MistakeCalling a function pure because it has no print statement.

    InsteadA function is pure only if its result depends solely on its arguments and it has no side effects of any kind, including mutating arguments or globals.

  • MistakeUsing map with a lambda that has side effects, such as appending to a list.

    InsteadHigher-order functions are meant for transformations; side effects inside them make results depend on evaluation order and are easily lost when map is lazy.

  • MistakeExpecting lambdas created in a loop to remember each loop value.

    InsteadPython closures capture variables, not values; bind the current value with a default argument or a factory function.

  • MistakeReplacing every loop with reduce.

    InsteadUse the clearest form: comprehensions, sum, max, any and all are often more readable than reduce with a lambda.

  • MistakeThinking functional code must avoid all effects.

    InsteadPrograms need effects to be useful; the functional approach confines them to a thin outer layer around a pure core.

Summary and key terms

  • A pure function returns the same result for the same arguments and has no side effects.
  • Referential transparency lets you replace a pure expression by its value, which supports reasoning, caching and parallelism.
  • First-class functions can be named, stored, passed and returned like any other value.
  • map, filter and reduce capture the common loop shapes of transforming, selecting and combining.
  • Closures bundle a function with captured variables and let you create specialized functions at run time.
  • Partial application and composition assemble pipelines from small, testable functions.
  • Python allows but does not enforce functional style; keep effects at the edges of a pure core.
pure function
A function whose result depends only on its arguments and which has no side effects.
side effect
Any observable change outside a function's returned value, such as mutating data, printing or reading external state.
referential transparency
The property that an expression can be replaced by its value without changing program behaviour.
first-class function
A function that can be used as a value: assigned, stored, passed and returned.
higher-order function
A function that takes another function as an argument or returns a function.
map
A higher-order function that applies a function to every element of a collection.
filter
A higher-order function that keeps the elements satisfying a predicate.
reduce (fold)
A higher-order function that combines a collection into one value using a two-argument function and an initial value.
closure
A function together with the variables from its defining scope that it captures.
partial application
Fixing some arguments of a function to produce a function of the remaining arguments.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Is `def f(xs): return sorted(xs)` pure? Is `def g(xs): xs.sort(); return xs` pure?

    Show answer

    f is pure: sorted returns a new list. g is impure: it mutates its argument in place.

  2. Write a pure function that returns a copy of a dictionary with one key updated.

    Show answer

    `def updated(d, k, v): return {**d, k: v}`.

  3. Use filter and map to get the lengths of words longer than three letters in `["a", "tree", "is", "green"]`.

    Show answer

    `list(map(len, filter(lambda w: len(w) > 3, words)))` gives [4, 5].

  4. Use reduce to compute the maximum of a non-empty list without max.

    Show answer

    `reduce(lambda a, b: a if a >= b else b, xs)`.

  5. What does `[lambda: i for i in range(3)]` produce when each function is called, and why?

    Show answer

    Each returns 2, because all three closures share the variable i, whose final value is 2.

  6. Write a function `adder(n)` that returns a function adding n to its argument.

    Show answer

    `def adder(n): return lambda x: x + n`.

  7. Explain why a pure function can safely be memoized.

    Show answer

    Its result depends only on its arguments, so a stored result for given arguments is always correct and skipping the call changes nothing else.

  8. Using partial, create `to_hex` from `int` that parses hexadecimal strings.

    Show answer

    `to_hex = partial(int, base=16)`; `to_hex("ff")` returns 255.

  9. What is compose(len, str.split)("a b c")?

    Show answer

    str.split gives ["a", "b", "c"], and len gives 3.

  10. Why does the Java stream pipeline not run until average is called?

    Show answer

    Intermediate stream operations are lazy; they build a description of the computation, and only a terminal operation triggers evaluation.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz