Home Functional Programming
Post
Cancel
Functional Programming | SEG

Functional Programming

This post builds on Imperative Programming and Declarative Programming. You need basic Java methods, classes, lists, and loops; we introduce the functional syntax as we use it. The complete example runs on Java 17 or later.

What Is Functional Programming?

Functional programming is a way of organizing computation by applying and composing functions that transform input values into results. It emphasizes pure functions, immutable data, and passing functions as values. Together, these ideas help us understand a calculation by its inputs and outputs, with fewer dependencies on what happened earlier in the program.

Imagine a task planner that calculates the time needed for unfinished tasks. If the calculation also changes the tasks, reads a global setting, and writes a database row, understanding its result means understanding all those interactions. A functional design separates the calculation from those interactions: give it the tasks and the estimation rule, get back a total, then decide what to do with that result.

By the end, you should be able to recognize these ideas in Java, build a small calculation from them, and decide where they improve your code. This is a programming style you can use within an ordinary Java application; adopting it does not require changing every class or removing every loop.

How It Relates to Familiar Paradigms

An imperative implementation tells the computer how to update state step by step. A functional implementation emphasizes relationships between values: select the relevant inputs, transform them, and combine the results. This often produces a declarative expression of the calculation.

These perspectives overlap. A method containing a local loop can still be pure, and a declarative-looking lambda can still modify shared state. Functional programming is also compatible with Object-Oriented Programming: objects can represent domain values while functions transform those values.

Pure Functions Make Dependencies Visible

A pure function has two important properties: the same input values produce the same result, and evaluating it produces no observable side effects. A side effect is a change or interaction beyond returning the result, such as modifying an argument, updating shared state, printing, or writing a file.

Here is a small method for adding five minutes of planning buffer:

1
2
3
static int addBuffer(int minutes) {
    return minutes + 5;
}

addBuffer(20) produces 25. Calling it again does not change the next call, and the caller’s value remains unchanged. You can test it without a database, a clock, or any setup beyond the input.

This gives us referential transparency: we can replace an expression with its result without changing the program’s observable behavior. Replacing addBuffer(20) with 25 preserves the calculation. If that method also printed a message, making the replacement would remove the message, so the call would no longer be referentially transparent.

Contrast this with a method that reads a mutable static bufferMinutes field. Even if the method only returns a number, its result now depends on something outside its parameters. Changing that field can make the same call return a different result. Pass the buffer as an argument when it is a configurable part of the calculation.

Reading the current time or generating a random number has the same dependency problem: repeated calls can differ. Read those values at the application boundary and pass them into calculations that need them. Purity makes the dependency visible; it does not make time or randomness unnecessary.

Immutable Data Keeps Earlier Values Trustworthy

Immutable data cannot change after it is created. Instead of changing an existing value, a transformation produces a result representing the new state. This supports purity because one function cannot quietly alter the input another function is using.

We can represent a task with a Java record:

1
2
3
4
5
6
record Task(String title, int estimatedMinutes, boolean done) {}

Task original = new Task("Write tests", 20, false);
Task completed = new Task(
    original.title(), original.estimatedMinutes(), true
);

The second task represents a completed state; original.done() is still false. A record supplies a constructor and accessors such as title() and done(), along with value-oriented equals and hashCode. Its component fields are final. See the Java record documentation.

The components here are a String and primitives, so this particular record has no mutable component state. A record containing an ArrayList would require more care: a final field prevents reassignment of the reference, but the referenced list can still change.

Likewise, List.of(...) prevents adding, removing, or replacing elements, but does not freeze mutable objects stored inside the list. The List documentation describes this distinction. Immutability must cover the data you depend on, rather than just the outer reference.

Functions Can Be Values Too

So far, we have called named methods. Functional programming also treats functions as values that can be stored, passed as arguments, and returned. This is commonly called first-class functions. Java supports this style through objects implementing functional interfaces.

A functional interface has one abstract method. A lambda expression supplies its implementation. For a calculation that accepts an int and returns an int, Java provides IntUnaryOperator:

1
2
3
4
import java.util.function.IntUnaryOperator;

IntUnaryOperator buffered = minutes -> minutes + 5;
int estimate = buffered.applyAsInt(20); // 25

The expression before -> names the input; the expression after it calculates the result. applyAsInt invokes the operation. Java infers that minutes is an int from the target interface. These conventions come from the java.util.function interfaces.

A higher-order function accepts a function, returns a function, or both. Here, the calculation accepts a rule instead of hardcoding it:

1
2
3
static int estimate(int minutes, IntUnaryOperator rule) {
    return rule.applyAsInt(minutes);
}

estimate(20, minutes -> minutes + 5) returns 25, while estimate(20, minutes -> minutes) returns 20. The caller chooses the policy; the calculation knows only how to apply it. This resembles passing a strategy object, with less ceremony for a small behavior.

Composition Connects Small Transformations

Function composition feeds one function’s output into another’s input. We can compose buffered minutes with a conversion to seconds:

1
2
3
4
5
IntUnaryOperator buffered = minutes -> minutes + 5;
IntUnaryOperator toSeconds = minutes -> minutes * 60;
IntUnaryOperator bufferedSeconds = buffered.andThen(toSeconds);

int seconds = bufferedSeconds.applyAsInt(20); // (20 + 5) * 60 = 1500

andThen returns a new operator; applying it runs the original operation before the supplied one. The order matters: converting first and then adding five would add five seconds. The IntUnaryOperator documentation defines that order.

This example needs no collection or stream. The central idea is assembling a calculation from small transformations with compatible inputs and outputs. Pure transformations compose predictably because each stage depends on the value it receives.

The Same Calculation in Two Styles

Our planner will total the estimated minutes of unfinished tasks, adding five minutes to each remaining task. These snippets assume the Task record introduced above.

An imperative version maintains a running total:

1
2
3
4
5
6
7
8
9
static int remainingMinutesImperative(List<Task> tasks) {
    int total = 0;
    for (Task task : tasks) {
        if (!task.done()) {
            total += task.estimatedMinutes() + 5;
        }
    }
    return total;
}

This method changes the local total, but does not change the tasks or anything outside the call. With our immutable input values, it is still a pure calculation. Local mutation and externally observable mutation have different consequences.

A functional version can express the calculation as filter, map, and reduce:

OperationMeaning in the planner
FilterKeep tasks whose done value is false
MapTurn each task into its buffered estimate
ReduceCombine those estimates into a single total

Java streams provide these operations. filter selects elements, mapToInt produces integer values, and sum is a reduction. A stream describes processing rather than storing another collection. Intermediate operations are lazy; a terminal operation such as sum triggers the calculation. A consumed stream cannot be used again. See the stream package documentation.

We will also use a method reference: Task::estimatedMinutes means the same operation as task -> task.estimatedMinutes(). It passes existing behavior instead of writing another lambda.

A Complete Java Example

Save the following as FunctionalPlanning.java. We keep the data small, non-null, and nonnegative, with estimates and totals that fit in an int. Input validation and numeric range handling belong in a larger application; they would distract from the transformations here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.List;
import java.util.function.IntUnaryOperator;

public class FunctionalPlanning {
    record Task(String title, int estimatedMinutes, boolean done) {}

    static int addBuffer(int minutes) {
        return minutes + 5;
    }

    // Pure when the supplied rule is pure and the input values are stable.
    static int remainingMinutes(List<Task> tasks, IntUnaryOperator rule) {
        return tasks.stream()
            .filter(task -> !task.done())
            .mapToInt(Task::estimatedMinutes)
            .map(rule)
            .sum();
    }

    public static void main(String[] args) {
        List<Task> tasks = List.of(
            new Task("Write tests", 20, false),
            new Task("Update docs", 10, false),
            new Task("Review change", 15, true)
        );

        IntUnaryOperator buffered = FunctionalPlanning::addBuffer;
        int planned = remainingMinutes(tasks, buffered);
        int unbuffered = remainingMinutes(tasks, minutes -> minutes);

        // Output is an explicit effect, outside the calculation.
        System.out.println("Buffered minutes: " + planned);
        System.out.println("Unbuffered minutes: " + unbuffered);
    }
}

Run it with:

1
2
javac --release 17 FunctionalPlanning.java
java FunctionalPlanning

The output is:

1
2
Buffered minutes: 40
Unbuffered minutes: 30

Walk Through the Values

The filter keeps Write tests and Update docs because their done values are false. It excludes the completed review, so its 15 minutes never enter the estimate. mapToInt extracts 20 and 10. With the buffered rule, map transforms those values to 25 and 15; sum combines them into 40. An empty list produces 0, because there are no estimates to add.

flowchart LR
    A["Three task values"] -->|"filter unfinished"| B["Two task values"]
    B -->|"extract minutes"| C["20 and 10"]
    C -->|"apply buffer rule"| D["25 and 15"]
    D -->|"sum"| E["40 minutes"]

The diagram shows logical transformations; the pipeline need not create a list at each stage. On the second call, the identity lambda returns each estimate unchanged, giving 20 + 10 = 30. Each call creates a fresh stream from the same unchanged tasks.

The relationship between the concepts is visible here: stable task values protect the inputs, pure rules make each transformation predictable, a higher-order method accepts the estimation policy, and composition assembles the overall calculation. The imperative version and the buffered functional version return the same value. Their main difference is how they express traversal and accumulation.

Keep Effects at Clear Boundaries

Real programs must read requests, save changes, and send responses. A useful arrangement is a functional core containing calculations, surrounded by code that performs those effects.

For the planner, an application service might load tasks from a repository, call remainingMinutes, and save or display the returned number. The calculation receives values and returns a value. The service controls when the effects happen. You can test the calculation directly and test repository interactions separately.

A lambda does not guarantee purity. A rule that increments a shared counter, reads a mutable configuration object, or prints a message introduces dependencies or effects into the calculation. The method’s contract and implementation still matter.

Avoid hiding database writes or updates to external lists inside stream transformations. The stream documentation discourages side effects in behavioral parameters; execution can optimize away some operations. A sequential stream is the default, and requesting parallel execution does not guarantee better performance. Choose concurrency through measurement and a clear understanding of the work. See the stream guidance on side effects and parallelism.

When This Style Helps

Functional techniques work well for pricing calculations, validation rules, report transformations, and other logic that naturally converts inputs into results. Explicit parameters make dependencies easier to see, and small pure functions are easy to exercise with representative inputs. Immutable values also reduce coordination problems when several parts of a program share data.

There are costs. Creating new values may allocate more memory, and an elaborate chain of tiny functions can be harder to understand than a direct method. Java collections are not automatically persistent data structures that efficiently share unchanged parts between versions. Measure before replacing large mutable structures with repeated copies.

A loop may express a stateful algorithm or early exit more clearly. Replacing iteration with deep recursion can exhaust the call stack and produce a StackOverflowError. Recursion is useful in functional programming, but it is not a requirement for writing functional Java.

Start with one calculation: identify its inputs, return a result, and move observable effects to its caller. Introduce lambdas and pipelines where they make the relationships easier to read.

What to Practice Next

Functional programming gives you a way to organize logic around values and transformations. Purity makes results depend on explicit inputs, immutability keeps those inputs stable, and higher-order functions let you compose and reuse behavior. Java lets you combine these tools with classes and ordinary control flow.

Try adding a second estimation rule to the planner without changing remainingMinutes. Check an empty list, all completed tasks, and a mixture of completed and unfinished tasks. Then explain which parts of your program are pure and where the effects occur. That explanation matters more than how many streams or lambdas you use.

This post is licensed under CC BY 4.0 by the author.