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 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");
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);
};
@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.
map() or forEach().
Method references use the :: operator to separate the type or object from the method name. There are four main forms:
ClassName::staticMethod— reference to a static methodinstance::instanceMethod— reference to an instance method of a specific objectClassName::instanceMethod— reference to an instance method of an arbitrary object of a given typeClassName::new— reference to a constructor
// 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.
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<T>— takes a value of typeTand returns a boolean resultFunction<T,R>— takes a value of typeTand returns a result of typeRConsumer<T>— takes a value of typeTand performs an action without returning a resultSupplier<T>— provides (or "supplies") a value of typeTwithout any inputUnaryOperator<T>— a specializedFunction<T,T>that returns the same type as it receivesBinaryOperator<T>— a specializedBiFunction<T,T,T>combining two values of the same typeBiFunction<T,U,R>— takes two inputs and returns a resultBiConsumer<T,U>— takes two inputs and performs an action with no result
// 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
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.
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:
- Source — where the data comes from, such as a collection, array, or generator
- Intermediate operations — transformations that produce a new stream, such as
filter(),map(), orsorted() - Terminal operation — an operation that produces a result or side effect, such as
forEach(),collect(), orreduce()
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
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.
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.
- map() transforms each element using a provided function
- filter() retains only those elements that match a predicate
- reduce() combines all remaining elements into a single result
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
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.