Pure Functions
A pure function is a function whose return value depends solely on the arguments passed to it, with no dependence on any hidden or external state, and which produces no observable effect on the world beyond computing and returning that value — no mutation of arguments, no writes to shared state, no I/O, and no dependence on anything that can change between calls such as the system clock or a random number generator. Calling a pure function with the same arguments always produces the same result, a property known as referential transparency, which means a call to a pure function can be replaced anywhere in a program by its result without changing what the program does. Java does not enforce purity at the language level — any method, lambda, or method reference may freely read and write shared mutable state, perform I/O, or behave non-deterministically — so purity in Java is a discipline the developer chooses to follow rather than a guarantee the compiler provides. This entry covers the precise definition of purity and referential transparency, the common ways Java code becomes impure even unintentionally, and the practical payoffs of writing pure functions: simpler testing, safe caching and memoization, and safe parallel execution.
What Makes a Function Pure: Determinism and No Side Effects
// ── Pure functions: output depends only on input, no observable effects ─
static int add(int a, int b) {
return a + b; // deterministic, no side effects
}
static int square(int x) {
return x * x; // same, every time
}
static String greet(String name) {
return "Hello, " + name + "!"; // builds and returns a NEW String
}
// Calling these repeatedly with the same arguments always gives the same result,
// and the call can be replaced by its result anywhere without changing behavior:
int a = square(5); // a == 25
int b = square(5); // b == 25, always, forever
// square(5) could be replaced by the literal 25 everywhere in this program
// without changing what the program does — that's referential transparency.
// ── A function that LOOKS pure but isn't, because of a subtle dependency ─
static int offset = 10;
static int impureLookingButReallyIsnt(int x) {
return x + offset; // depends on a field that lives outside this
// method — NOT pure, even though it takes one
// argument and returns a value
}
// if offset changes, the same call with x=5 returns a different result
// each time — not deterministicCommon Sources of Impurity in Java Code
// ── Source of impurity #1: reading/writing static or instance fields ───
class RunningTotal {
private int total = 0;
// IMPURE: mutates instance state, return value depends on call history
int addAndGetTotal(int amount) {
total += amount;
return total; // same argument, different result each call
}
}
class PureTotal {
// PURE: takes the previous total explicitly, returns a NEW total,
// mutates nothing — the caller decides whether/how to keep track
static int addToTotal(int previousTotal, int amount) {
return previousTotal + amount;
}
}
// ── Source of impurity #2: mutating an argument passed by reference ────
static void clearAndFillImpure(List<String> list, String value) {
list.clear(); // mutates the CALLER's list — visible side effect
list.add(value);
}
static List<String> fillPure(List<String> original, String value) {
List<String> copy = new ArrayList<>();
copy.add(value);
return copy; // returns a NEW list, leaves original untouched
}
// ── Source of impurity #3: I/O ──────────────────────────────────────────
static int impureLoggingAdd(int a, int b) {
int result = a + b;
System.out.println("Computed: " + result); // observable side effect (console)
return result;
}
// A pure version simply omits the println — logging, if needed, is the
// CALLER's responsibility, kept outside the pure computation.
// ── Source of impurity #4: non-deterministic inputs ─────────────────────
static long impureTimestampedId(String name) {
return name.hashCode() + System.currentTimeMillis(); // different every call
}
static long pureId(String name, long timestamp) {
return name.hashCode() + timestamp; // deterministic — timestamp is now an input
}Why Purity Matters: Testability, Memoization, and Safe Parallelism
// ── Testability: a pure function needs no setup or mocks ───────────────
static int discountedPrice(int price, double discountRate) {
return (int) Math.round(price * (1 - discountRate));
}
// Test is just input -> expected output, nothing else to arrange:
assert discountedPrice(100, 0.20) == 80;
// ── Memoization: safe ONLY because the wrapped function is pure ────────
static <T, R> Function<T, R> memoize(Function<T, R> pureFunction) {
Map<T, R> cache = new ConcurrentHashMap<>();
return input -> cache.computeIfAbsent(input, pureFunction);
// Safe because pureFunction(input) is guaranteed to return the same
// result every time — caching it can never serve a stale answer.
}
Function<Integer, Long> slowFibonacci = n -> {
if (n <= 1) return (long) n;
long a = 0, b = 1;
for (int i = 2; i <= n; i++) { long next = a + b; a = b; b = next; }
return b;
};
Function<Integer, Long> fastFibonacci = memoize(slowFibonacci);
fastFibonacci.apply(40); // computed once
fastFibonacci.apply(40); // served instantly from cache — correct because pure
// ── Safe parallelism: no shared mutable state for threads to race on ───
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000).boxed().toList();
// SAFE in parallel: squaring is pure, no shared state touched:
long sumOfSquares = numbers.parallelStream()
.mapToLong(n -> (long) n * n) // pure — depends only on n
.sum();
// UNSAFE in parallel: mutates a shared, non-thread-safe collection —
// a classic case of supplying an impure (stateful, interfering) lambda
// where the Stream API's contract requires a pure one:
List<Integer> sharedResults = new ArrayList<>(); // not thread-safe
numbers.parallelStream().forEach(n -> sharedResults.add(n * n));
// Result: may throw, may silently drop elements, may corrupt internal state —
// behavior depends on thread timing, which is exactly what purity avoids.
// ── Functional core, imperative shell ───────────────────────────────────
record Order(String product, int price, double discount) {}
// Pure core: all real logic, fully testable, no I/O:
static List<String> formatInvoiceLines(List<Order> orders) {
return orders.stream()
.map(o -> o.product() + ": $" + discountedPrice(o.price(), o.discount()))
.toList();
}
// Imperative shell: the only place I/O happens, kept as thin as possible:
static void printInvoice(List<Order> orders) {
formatInvoiceLines(orders).forEach(System.out::println); // side effect isolated here
}