Skip to content

Metadata Card

  • Prerequisites: Vol 9 Chapter 1 (type system understanding), basic experience with Java Stream API
  • Estimated time: 45 minutes
  • Core difficulty: Advanced
  • Reading mode: High focus
  • Optional skip: The Monad section is advanced; can be skipped on first read-through; understanding Monoid and Functor is sufficient
  • Completion mark: Can write side-effect-free pure functions; can rewrite imperative loops with Stream API; understand the intuitive meaning of Functor and Monad

Your Progress

In the first three rooms of the ruins, you saw three ways to solve conflicts—locks (shared memory), message passing (Actor), and transactions (STM). They all share one thing in common: they assume "data can change," then try to control that change.

But the civilization in the fourth room offers a more radical view—don't change data, and there are no problems at all.

So you push open the fourth door of the ruins—the hall of functional programming. An old scholar sits by the fireplace, unhurriedly reciting the maxim on the wall:

"Computation is evaluation, not the execution of instructions."

This means: a program is not a series of "do this, do that" commands, but a series of expression evaluations. Like a mathematical formula—2 + 3 = 5, and if you change 2 to something else, 5 doesn't change along with it, because you didn't "change" 2 itself.

Your Task

Functional programming (FP) is not a syntactic sugar toolkit. It's a different answer to the question "what is programming?"—imperative programming says "a program is a series of instructions;" functional programming says "a program is a series of expression evaluations." This chapter starts from immutability and pure functions, and progressively moves toward an intuitive understanding of Monoid, Functor, and Monad.

Chapter Layers

  • Required: Immutability, pure functions, side effect isolation, higher-order functions
  • Optional: Monoid, Functor intuition
  • Advanced: Monad intuition (Maybe, List, IO)

Breaking Ground · Tracing the Origin

Your previous Java code had a default assumption—"data can be changed."

java
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
System.out.println(names.size());  // 2

Looks natural, right? You change names—add elements, change names, delete—every step modifies the original list. In Java, this is taken for granted.

But the functional camp asks a question that makes you pause: How do you know other code elsewhere isn't changing this list at the same time?

You've never thought about this—because in single-threaded, single-file practice, it doesn't cause problems. But in a 20-person codebase maintained by many, a List might be passed into dozens of functions. Who changed it? In what order? What's the final state?—Nobody knows. This is the curse of mutable state.

This is the starting point of functional programming—immutability.

You no longer modify data; you create new copies:

java
// Immutable style
List<String> names = List.of("Alice", "Bob");
// names.add("Charlie");  // UnsupportedOperationException
List<String> newNames = Stream.concat(names.stream(), Stream.of("Charlie"))
                              .collect(Collectors.toList());

names hasn't changed, but newNames has one more element. This is a copy, not an in-place modification.

Inefficient? Intuitively, yes. But persistent data structures make immutable operations approachable to O(log n) in both time and space—using shared structure to avoid full copying. Java doesn't have built-in persistent data structures, but Scala's Vector and Clojure's PersistentVector both implement them.


Pure Functions: No Side Effects, Predictable

java
// Impure — depends on and modifies external state
private int counter = 0;
public int nextId() {
    return counter++;
}

Each call to nextId() gives a different result. Tests need to set the counter's value first. Multi-threaded environments also need locking.

java
// Pure function — input determines output
public int nextId(int previous) {
    return previous + 1;
}

Same input always gives the same output. Doesn't modify any external state. Tests need no preconditions.

The benefits of pure functions go beyond testability. Their callers can arbitrarily reorder calls, cache results (memoization):

java
// Pure recursive fib implementation
public int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

This function recomputes subproblems repeatedly. Adding a cache (memoization), after the first call with a given n, it doesn't need to recompute—because it's pure:

java
// Cached fib — leveraging the determinism of pure functions
private Map<Integer, Integer> cache = new HashMap<>();

public int fibMemo(int n) {
    if (n <= 1) return n;
    return cache.computeIfAbsent(n, k -> fibMemo(k - 1) + fibMemo(k - 2));
}

Pure functions + memoization = automatic performance optimization. This is impossible with impure functions—because their results depend on external state, the cache might give you stale answers.


Higher-Order Functions

Functions can be parameters and return values. This might not seem like much to you now, but it was a major design choice in early mainstream languages.

Java couldn't do it before Java 8. After Java 8, it uses functional interfaces:

java
// Function as parameter
public List<Integer> map(List<Integer> list, Function<Integer, Integer> fn) {
    List<Integer> result = new ArrayList<>();
    for (Integer i : list) {
        result.add(fn.apply(i));
    }
    return result;
}

// Usage
List<Integer> doubled = map(List.of(1, 2, 3), x -> x * 2);  // [2, 4, 6]

Python supports it natively:

python
def double(x): return x * 2
result = list(map(double, [1, 2, 3]))

Or more directly with list comprehensions (Python's FP style is more common in practice):

python
result = [x * 2 for x in [1, 2, 3]]

Code written with higher-order functions feels more like "declaring what you want" rather than "how to implement it":

java
// Imperative: you tell the machine every step
List<Integer> result = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
    int x = list.get(i);
    if (x % 2 == 0) {  // Even numbers
        result.add(x * x);
    }
}

// Functional: you declare what you want
List<Integer> result = list.stream()
    .filter(x -> x % 2 == 0)
    .map(x -> x * x)
    .collect(Collectors.toList());

The latter is more readable—filter says "I want to filter," map says "I want to transform."


Practical Depth of Java Stream API—From "How to Do It" to "What You Want"

After changing code from "every step of how to do it" to "what result I want," how much does readability improve? Let's look at a real example.

You have a list of users, and need to: get active users, extract emails, group by domain, sort by count descending.

Writing it in traditional imperative style, you'd create a temporary HashMap, then an ArrayList, then nested loops—cumbersome and error-prone.

Using Stream API:

java
Map<String, Long> domainCounts = users.stream()
    .filter(User::isActive)                  // Filter
    .map(User::getEmail)                     // Extract email
    .filter(email -> email.contains("@"))    // Exclude invalid
    .collect(Collectors.groupingBy(
        email -> email.split("@")[1],        // Group by domain
        Collectors.counting()                // Count
    ))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Long>comparingByValue().reversed())  // Sort
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        Map.Entry::getValue,
        (a, b) -> a,
        LinkedHashMap::new                   // Preserve order
    ));

This code has no intermediate variables, no explicit loops, no manual indices—it's declarative stream processing. You read it not as "this variable is first assigned to... then..." but from top to bottom like reading an article: filter, map, groupBy, sort. Each step tells you what you want, not how to implement it.

But note: The Stream API is not purely functional—it allows side-effecting forEach, allows peek for debugging. It's a functional-style graft onto Java's object-oriented world—like the fourth civilization that accepted functional philosophy but kept traditional construction methods.


Algebraic Intuition: Monoid, Functor, Monad—Three Scary-Sounding But Everyday Patterns

These three concepts aren't about math exams. They're fundamental building blocks of functional programming—patterns, not formulas.

Monoid: You Use It Every Day, Just Haven't Named It

A Monoid is "something that can be combined," satisfying two conditions:

  1. Associativity: (a * b) * c == a * (b * c)
  2. Identity element: There's an ID that "does nothing"
java
// Number addition is a Monoid
// Associativity: (1 + 2) + 3 == 1 + (2 + 3)
// Identity: 0 (any number plus 0 stays the same)

// String concatenation is a Monoid
// Associativity: ("a" + "b") + "c" == "a" + ("b" + "c")
// Identity: "" (any string plus empty string stays the same)

// List concatenation is a Monoid
// Associativity: [1].concat([2]).concat([3]) works fine
// Identity: [] (empty list)

Why is this useful? Monoid's "associativity" means you can split the combination operation into multiple independent parts and run them in parallel—the foundation of divide and conquer.

java
// Suppose you have 10 million data items to combine
// Monoid guarantees: split into 10 chunks -> each chunk combined independently -> combine chunk results = full combination
// Each chunk can be on different threads, different machines—same result.

Functor: Something You Can Map

A Functor is "something you can apply a function to its inner value"—essentially, something that implements map:

java
// List is a Functor
List<Integer> nums = List.of(1, 2, 3);
List<String> strs = nums.stream()
    .map(n -> "num: " + n)  // Applied to each element
    .collect(Collectors.toList());

// Optional is also a Functor
Optional<String> name = Optional.of("Alice");
Optional<Integer> length = name.map(s -> s.length());  // If present, transform

You don't need to know the word "Functor" to use map. But once you do, you'll notice many classes implement mapList, Set, Optional, Stream, CompletableFuture. They're all Functors. map is a very general pattern.

Monad: Something That Can Be FlatMapped

A Monad is a type with a flatMap operation—a common pattern for "taking a value out of a container, transforming it, and putting it back."

Optional is a Monad:

java
Optional<String> name = Optional.of("Alice");

// Without flatMap:
Optional<Integer> length = name.map(s -> s.length());  // Optional<Integer>

// If the transformation itself returns Optional, map creates nesting:
Optional<Optional<Integer>> nested = name.map(s -> Optional.of(s.length()));
// Two layers of Optional — awkward

// flatMap flattens:
Optional<Integer> flat = name.flatMap(s -> Optional.of(s.length()));
// Single layer of Optional

Stream is a Monad (called flatMap in Java):

java
List<List<Integer>> nested = List.of(
    List.of(1, 2), List.of(3, 4)
);

// flatMap flattens the nested structure
List<Integer> flat = nested.stream()
    .flatMap(list -> list.stream())
    .collect(Collectors.toList());  // [1, 2, 3, 4]

Monad intuition: You have a value inside a context (Maybe: may or may not exist; List: multiple values; IO: the side-effecting world). You want to apply transformations to the wrapped value without worrying about nested contexts. flatMap does that for you.

Don't stress about understanding Monad on the first try. Most Java developers use Stream.flatMap and Optional.flatMap in their code for years without knowing the word "Monad." Monad's value is that when you encounter new types (Try, Either, IO), you'll find they all support flatMap, and you already know how to use them.


Common Pitfalls

  1. "Functional = no variables" — Not about having no variables, but about having no mutable state. Local var is completely fine.
  2. "Pure functions have no performance cost" — Immutable data structures require extra copying and GC pressure. On hot paths, mutable + locks may be faster.
  3. "Java isn't suitable for functional programming" — The syntax is more verbose, but the Stream API works well in practice. The real limitation is Java's lack of tail recursion optimization and built-in immutable collections.

Pass Challenges

  • Warm-up: Take one of your for-loops and rewrite it with Stream API. Compare line count and readability.
  • Hands-on: Implement a custom @FunctionalInterface—for example Transformer<T, R>—then call it with a Lambda in your code.
  • Observe: On IntStream.range(0, 1000000), execute sum with both sequential stream and parallelStream(), and compare performance.

Traveler's Notes

Functional programming isn't about eliminating side effects—you can't avoid logging, writing files, or making network requests. It's about pushing side effects to a boundary where they're managed, keeping the core logic pure, predictable, and composable.

Next Stop Preview

You now know how types constrain data, how memory stores data, how concurrency processes data, and how functional programming keeps data unchanged—but how does the code you write actually execute? The answer lies in bytecode. Push open the fifth door: enter JVM bytecode and see what the Java compiler actually does.

Built with VitePress | Software Systems Atlas