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.
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)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
- Before the call to record, log is empty, so len(log) is 0.
- Python evaluates record(1) first, which appends to log, then evaluates len(log), which is now 1, so a = 2.
- If we replaced len(log) by its earlier value 0, a would be 1: a different result.
- 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.
| Property | Pure function | Impure function |
|---|---|---|
| Same arguments, same result | Always | Not guaranteed |
| Can be cached (memoized) | Safely | Only with care |
| Can run in parallel with other calls | Yes, no shared state | Only if effects are isolated |
| Unit testing | Compare output to expected value | Needs setup, mocks or clean-up |
| Order of calls matters | Only through data dependencies | Often, 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.
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__)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 behavior 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.
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)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
- The loop keeps only alphabetic words: that is a filter with predicate str.isalpha.
- It then keeps the longest one seen so far: that is a reduction, which max with key=len expresses directly.
- The initial value "" handles an empty list; max needs default="" for the same behavior.
- One subtle difference: on ties the loop keeps the first longest word, and max also returns the first maximal element, so the behavior 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.
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__])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.
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 ")))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 percent tax, rounds to whole cents and returns the total. Build it from small pure functions.
Show solution
- Define keep = lambda xs: [x for x in xs if x >= 100].
- Define tax = lambda xs: [round(x * 1.13) for x in xs].
- Use sum as the final reduction.
- 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.
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);
}
}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 behavior.
- 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.
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.
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}`.
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].
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)`.
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.
Write a function `adder(n)` that returns a function adding n to its argument.
Show answer
`def adder(n): return lambda x: x + n`.
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.
Using partial, create `to_hex` from `int` that parses hexadecimal strings.
Show answer
`to_hex = partial(int, base=16)`; `to_hex("ff")` returns 255.
What is compose(len, str.split)("a b c")?
Show answer
str.split gives ["a", "b", "c"], and len gives 3.
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.
Create a free account to save your progress, take quizzes and earn a certificate.