<< Home

Chapter 13: Functional Features and Lambdas

Modern Java supports a clean, expressive functional style that complements its object-oriented core. Lambdas, method references, and the standard java.util.function interfaces let you pass behavior as data, compose small operations into clear pipelines, and write concise code that focuses on what should happen rather than how it is performed. These features arrived to make everyday tasks simpler and safer, while preserving Java’s clarity and type safety.

In this chapter you will learn how to declare and use lambdas, how functional interfaces define the contracts they implement, and how method references provide a compact alternative when an existing method already matches the required shape. You will also see how variable capture works, why parameters and captured locals are effectively final in lambda bodies, and how this model avoids many subtle bugs that occur with shared mutable state.

Functional techniques shine when combined with streams, since operations like map, filter, and reduce accept functions to describe transformations and decisions. We will introduce the most useful function types from java.util.function, and show how they integrate seamlessly with both collections and stream pipelines.

💡 Lambda expressions were introduced in Java 8 and marked the language’s biggest leap since generics. They are now a core part of everyday Java programming.

Lambda expressions and functional interfaces

A lambda expression is an anonymous block of code that can be passed around and executed later. It represents a function without requiring a named method or enclosing class. Lambdas simplify situations where you would otherwise write a short implementation of an interface containing only one abstract method.

Such interfaces are known as functional interfaces. The Java standard library defines many of them in the java.util.function package, including Predicate<T>, Function<T,R>, Supplier<T>, and Consumer<T>. You can also define your own using the @FunctionalInterface annotation.

// A simple functional interface
@FunctionalInterface
interface Greeting {
  void sayHello(String name);
}

// Using a lambda to implement it
Greeting g = (name) -> System.out.println("Hello, " + name);
g.sayHello("Alice");
⚠️ The type of a lambda is inferred from the context in which it appears. It must match a functional interface type, otherwise the compiler cannot determine what the lambda represents.

Lambdas can have zero, one, or multiple parameters. If there is exactly one parameter and its type can be inferred, you may omit parentheses. The body can be a single expression or a block of statements enclosed in braces.

// Single-expression lambda
Runnable r = () -> System.out.println("Running...");

// Multi-statement lambda
Comparator<String> cmp = (a, b) -> {
  int result = a.length() - b.length();
  return (result != 0) ? result : a.compareTo(b);
};
💡 You can use the @FunctionalInterface annotation to make your intent clear and to ensure the interface has exactly one abstract method. The compiler will issue an error if additional abstract methods are added later.

Method references

A method reference provides a compact way to refer to an existing method instead of writing a full lambda expression that simply calls it. They improve readability when a lambda’s body does nothing more than delegate to a method already defined elsewhere.

⚠️ A method reference does not execute the method immediately. It creates a function-like object that can be called later, usually through a higher-order function such as map() or forEach().

Method references use the :: operator to separate the type or object from the method name. There are four main forms:

// Static method reference
Function<String, Integer> parser = Integer::parseInt;
int value = parser.apply("42");

// Instance method reference (specific object)
String prefix = "Hello, ";
Function<String, String> greeter = prefix::concat;
System.out.println(greeter.apply("world"));

// Instance method reference (arbitrary object)
List<String> names = List.of("alice", "bob", "carol");
names.replaceAll(String::toUpperCase);

// Constructor reference
Supplier<StringBuilder> builder = StringBuilder::new;
StringBuilder sb = builder.get();

In each example, the compiler infers the target functional interface from the context. The method referenced must match the interface’s abstract method signature by argument types and return type.

💡 Prefer method references when they make your intent clearer. For example, list.forEach(System.out::println) is shorter and easier to read than list.forEach(x -> System.out.println(x)).

Built-in functional types

Java’s java.util.function package defines a comprehensive set of standard functional interfaces. These cover the most common patterns of input and output combinations, allowing you to express intent clearly without creating custom interfaces for every case. Each interface represents a single abstract method and is annotated with @FunctionalInterface.

The most frequently used functional types include:

// Predicate example
Predicate<String> isEmpty = s -> s.isEmpty();
System.out.println(isEmpty.test(""));    // true

// Function example
Function<String, Integer> length = s -> s.length();
System.out.println(length.apply("Java")); // 4

// Consumer example
Consumer<String> printer = s -> System.out.println("Value: " + s);
printer.accept("Lambda");

// Supplier example
Supplier<Double> random = Math::random;
System.out.println(random.get());

// BinaryOperator example
BinaryOperator<Integer> sum = (a, b) -> a + b;
System.out.println(sum.apply(3, 4)); // 7
💡 For primitive types, Java provides specialized variants such as IntPredicate, DoubleFunction<R>, and LongSupplier to avoid the overhead of boxing and unboxing.

These interfaces are the foundation of many higher-level APIs, especially the Stream API, which uses them extensively to express filtering, mapping, reduction, and collection operations.

⚠️ Always choose the most specific functional interface available. For example, prefer IntFunction<R> instead of Function<Integer,R> when dealing with primitive int values.

Stream API overview

The Stream API, introduced in Java 8, enables functional-style operations on collections and other data sources. A stream represents a sequence of elements that can be processed declaratively through a pipeline of transformations and actions. This approach promotes cleaner, more expressive code that focuses on what should happen, not how it should be implemented.

Stream pipelines consist of three main parts:

  1. Source — where the data comes from, such as a collection, array, or generator
  2. Intermediate operations — transformations that produce a new stream, such as filter(), map(), or sorted()
  3. Terminal operation — an operation that produces a result or side effect, such as forEach(), collect(), or reduce()
List<String> names = List.of("Alice", "Bob", "Charlie", "David");

names.stream()                      // Source
     .filter(n -> n.length() > 3)   // Intermediate operation
     .map(String::toUpperCase)      // Intermediate operation
     .sorted()                      // Intermediate operation
     .forEach(System.out::println); // Terminal operation
⚠️ A stream can only be consumed once. After a terminal operation, it becomes closed and cannot be reused. To process the same data again, create a new stream from the original source.

Streams can be sequential or parallel. Sequential streams process elements one after another, while parallel streams divide the workload across multiple threads, offering potential performance gains on large datasets.

// Parallel stream example
long count = names.parallelStream()
                  .filter(n -> n.startsWith("A"))
                  .count();
System.out.println("Names starting with A: " + count);

Intermediate operations are lazy, meaning they do not execute until a terminal operation is invoked. This allows the stream to optimize processing, such as combining operations or stopping early when possible.

💡 Streams do not store data themselves. They operate on existing collections or generators, process the elements through a pipeline, and then discard them once complete.

Map-filter-reduce pipelines

One of the most powerful uses of the Stream API is composing map–filter–reduce pipelines. These combine three core functional concepts to transform, select, and aggregate data in a fluent, readable style. Each step plays a distinct role in processing elements through the stream.

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

int sumOfSquares = numbers.stream()
  .filter(n -> n % 2 == 0)        // keep even numbers
  .map(n -> n * n)                // square each one
  .reduce(0, (a, b) -> a + b);    // sum them all

System.out.println(sumOfSquares); // 56
💡 The combination of map(), filter(), and reduce() embodies the essence of functional programming in Java. It encourages clear, concise, and side-effect-free code.

The reduce() operation accumulates results using an identity value (in this case 0) and a binary operator that combines elements step by step. For numeric data, this often replaces traditional loops for summing, multiplying, or aggregating values.

// Using method reference in reduce
int product = numbers.stream()
  .reduce(1, Math::multiplyExact);
System.out.println(product); // 720

Chaining map() and filter() operations allows you to express complex data transformations in a compact way, avoiding intermediate variables or mutable state. Because streams are lazy, elements are processed only as needed to produce the final result.

⚠️ Avoid performing stateful operations or side effects inside stream pipelines. Doing so can cause unpredictable behavior, especially when using parallel streams.