Programming in Java

Chapter 5
Functional Programming, Lambdas, and Streams

Post Graduate Program in Computer Science Engineering
Programming in Java   •   Post Graduate

5. Functional Programming, Lambdas, and Streams

Introduction and Context

Java was initially associated with object-oriented programming, where programs are organized around objects that encapsulate state and behavior. Modern Java, however, also supports a substantial functional style. The introduction of lambda expressions in Java 8, together with functional interfaces, method references, streams, collectors, and `Optional`, changed how Java developers express computation over collections and other data sources.

Functional programming treats computation primarily as the evaluation and composition of functions. It favors immutable data, declarative descriptions, and the reduction of observable side effects. Java is not a purely functional language: objects may be mutable, methods may change state, and exceptions and input/output remain inherently effectful. Its functional facilities should therefore be understood as tools for improving abstraction, expressiveness, and composability rather than as a requirement to eliminate object-oriented design.

This chapter examines how functional features operate, how stream pipelines are constructed, and how their design affects readability, correctness, concurrency, and performance. Particular attention is given to closures, `Optional`, collectors, reduction, parallel streams, and the professional judgment required to decide when a functional solution is preferable to an imperative one.

Learning Objectives

After studying this chapter, the reader should be able to:

1. Explain functional interfaces, lambda expressions, method references, closures, and target typing.

2. Distinguish declarative stream processing from imperative iteration.

3. Construct sequential stream pipelines using intermediate and terminal operations.

4. Select suitable collectors and reductions for aggregation and transformation.

5. Use `Optional` appropriately at API boundaries and in transformation pipelines.

6. Evaluate side effects, ordering, readability, and performance in functional code.

7. Explain the execution model, limitations, and appropriate uses of parallel streams.

8. Apply these concepts to realistic software-engineering and data-processing problems.

From Behavior Objects to Functions

A functional interface is an interface with exactly one abstract method. It may contain default and static methods, but only one abstract operation qualifies it as a functional interface. The annotation `@FunctionalInterface` is optional but valuable because the compiler verifies the intended design.

Common interfaces in `java.util.function` include:

  • `Predicate<T>`: accepts a value and returns `boolean`.
  • `Function<T,R>`: converts a value of type `T` into a value of type `R`.
  • `Consumer<T>`: accepts a value and returns no result.
  • `Supplier<T>`: produces a value without an input.
  • `UnaryOperator<T>`: maps `T` to `T`.
  • `BinaryOperator<T>`: combines two values of the same type.

For example:

```java

Predicate<String> longName = s -> s.length() >= 10;

Function<String, Integer> size = String::length;

Consumer<String> printer = System.out::println;

Supplier<List<String>> factory = ArrayList::new;

```

A lambda expression supplies an implementation of a functional interface. The expression `s -> s.length() >= 10` is not itself an object of an arbitrary function type; its target type is inferred from the assignment to `Predicate<String>`. This is called target typing. A lambda can have an expression body or a block body:

```java

x -> x * x

x -> {

int result = x * x;

return result;

}

```

The parameter types are often inferred, although explicit types may be useful when documentation or overload resolution requires them. A lambda with no parameters is written `() -> expression`, while multiple parameters require parentheses, as in `(a, b) -> a + b`.

A method reference is a compact form of a lambda that merely delegates to an existing method. Examples include `String::trim`, `System.out::println`, `Math::max`, and `ArrayList::new`. Method references often improve readability, but they are not automatically superior. A short lambda may communicate intent more clearly when adaptation or business meaning is involved.

Closures and Variable Capture

A lambda can access variables from its enclosing scope. A local variable captured by a lambda must be final or effectively final:

```java

int minimum = 100;

Predicate<Integer> acceptable = value -> value >= minimum;

```

The lambda forms a closure over `minimum`. Reassigning `minimum` after the lambda is created would be illegal. This restriction prevents ambiguity about the lifetime and visibility of local variables and supports safe capture semantics. Instance fields can be read or changed because they belong to the object rather than being local variables, but such mutation introduces side effects and should be carefully justified.

Functional interfaces can also encode checked exceptions only indirectly. For example, `Function<T,R>` cannot declare a checked exception. Applications sometimes define their own interface, such as `ThrowingFunction<T,R>`, or handle exceptions within the lambda. Both approaches should be designed consistently; silently wrapping every exception in an unchecked exception may obscure failure semantics.

Declarative Transformation with Stream Pipelines

A stream is a sequence of elements supporting aggregate operations. It is not a data structure and does not store elements independently. A stream obtains elements from a source such as a collection, array, generator, file, or map entry set, and processes them through a pipeline.

A pipeline normally contains:

1. A source, such as `orders.stream()`.

2. Zero or more intermediate operations, such as `filter`, `map`, `sorted`, or `distinct`.

3. One terminal operation, such as `collect`, `reduce`, `count`, `forEach`, or `findFirst`.

For example:

```java

List<String> result = names.stream()

.filter(name -> !name.isBlank())

.map(String::trim)

.map(String::toUpperCase)

.distinct()

.sorted()

.toList();

```

This code declares what should happen: discard blank values, normalize the remaining values, remove duplicates, order them, and produce a list. The implementation does not explicitly control the loop, index, or temporary collection.

Streams are lazy. Intermediate operations generally record computation rather than execute it immediately. Processing begins when a terminal operation is invoked. Laziness enables operation fusion, in which several operations may be applied during a single traversal, and short-circuiting, in which processing stops when the result is known. `findFirst`, `findAny`, `anyMatch`, `allMatch`, and `noneMatch` can short-circuit.

Intermediate operations are commonly classified as stateless or stateful. `filter` and `map` are stateless because each element can be processed independently. `sorted` and `distinct` are stateful because they may need to observe multiple elements or maintain internal state. This distinction matters for memory use, ordering, and parallel performance.

A stream cannot normally be reused after a terminal operation:

```java

Stream<String> stream = names.stream();

stream.count();

// stream.findFirst(); // IllegalStateException

```

A new stream must be obtained from the source when another traversal is required.

Mapping, Flattening, and Filtering

`map` performs one-to-one transformation. If each order has a customer, `orders.stream().map(Order::customer)` produces a stream of customers. When one input produces multiple outputs, `flatMap` is appropriate:

```java

List<String> words = sentences.stream()

.flatMap(sentence -> Arrays.stream(sentence.split("\\s+")))

.map(String::toLowerCase)

.toList();

```

Using `map` in this situation would produce a stream of arrays or streams rather than a single flattened sequence. Primitive streams such as `IntStream`, `LongStream`, and `DoubleStream` can reduce boxing overhead in numerical processing. Methods such as `mapToInt`, `sum`, `average`, and `summaryStatistics` are useful for quantitative operations.

Ordering is an observable property. A stream derived from an ordered collection normally preserves encounter order unless an operation or explicit design changes it. `forEach` on a parallel stream does not guarantee encounter order; `forEachOrdered` does, usually at a performance cost.

Collectors, Reduction, and the Algebra of Aggregation

The `collect` operation gathers stream elements into a result container or summary. Common collectors include:

```java

List<Order> orders = stream.toList();

Set<String> codes = stream.collect(Collectors.toSet());

Map<String, List<Order>> byCustomer = orders.stream()

.collect(Collectors.groupingBy(Order::customerId));

```

Other useful collectors include `partitioningBy`, `mapping`, `joining`, `counting`, `summingInt`, `averagingDouble`, `maxBy`, and `collectingAndThen`. A downstream collector allows a multistage aggregation:

```java

Map<String, Long> orderCounts = orders.stream()

.collect(Collectors.groupingBy(

Order::customerId,

Collectors.counting()));

```

`toMap` requires special care when keys are not unique. A merge function must be supplied:

```java

Map<String, Order> latest = orders.stream()

.collect(Collectors.toMap(

Order::id,

Function.identity(),

(first, second) -> second));

```

Without a merge function, duplicate keys cause `IllegalStateException`. The chosen merge rule should reflect domain meaning rather than merely suppressing the exception.

`reduce` combines elements into one result. For example:

```java

int total = values.stream().reduce(0, Integer::sum);

```

The identity must be a true identity for the accumulator: adding zero does not alter a sum. An overload without an identity returns `Optional<T>` because an empty stream has no result. For parallel execution, the accumulator and combiner must be associative, and the identity must be compatible with both. Subtraction, string-dependent state, or order-sensitive operations often violate these requirements.

There is an important distinction between `reduce` and `collect`. Reduction is suitable for immutable, associative combination into a single value. Collection is designed for mutable result containers, such as lists, maps, and summary structures, and provides supplier, accumulator, and combiner behavior. A common error is to mutate a shared external list inside `forEach` instead of using `collect`; the latter communicates the accumulation model and can be correctly adapted for parallel execution.

Optional and the Representation of Absence

`Optional<T>` represents either a present value or an explicit absence. It is especially useful for return types where “no result” is a normal outcome:

```java

Optional<User> user = repository.findById(id);

String displayName = user.map(User::displayName)

.filter(name -> !name.isBlank())

.orElse("Unknown user");

```

Important methods include `of`, `ofNullable`, `empty`, `map`, `flatMap`, `filter`, `orElse`, `orElseGet`, `orElseThrow`, `ifPresent`, and `ifPresentOrElse`. `orElse` evaluates its argument eagerly, whereas `orElseGet` invokes its supplier only when the optional is empty. This difference matters when the fallback is expensive or has side effects.

`map` wraps the result of a transformation, while `flatMap` avoids nested optionals when the transformation already returns an `Optional`. `Optional` should not normally be used as a field, method parameter, or collection element merely to replace every null. It is primarily an API and transformation tool. It also should not be used to conceal programming errors that ought to be reported immediately.

Side Effects, Purity, and Readability

A pure function produces the same result for the same input and does not alter observable state. Pure transformations are easier to test, reason about, cache, and parallelize. Stream pipelines are most reliable when `map`, `filter`, and `reduce` are free of external mutation.

This code is unsafe in a parallel context and poor in sequential style:

```java

List<String> result = new ArrayList<>();

items.parallelStream().forEach(item -> result.add(transform(item)));

```

The list is not thread-safe, and even a synchronized list would not necessarily preserve the desired order. Prefer:

```java

List<String> result = items.parallelStream()

.map(this::transform)

.toList();

```

Side effects are not forbidden. Logging, persistence, messaging, and user-interface updates are inherently effectful. The design goal is to keep such effects at clear boundaries, rather than hiding them in transformations where execution order and frequency may be surprising.

Readability remains a professional criterion. A five-line loop with a clear invariant may be better than a dense chain of ten operations. Naming intermediate results, extracting domain methods, and using meaningful method references can make functional code maintainable. Stream use should express a genuine transformation or aggregation, not demonstrate language features.

Parallel Streams: Execution Model and Judgment

A parallel stream divides work among threads, commonly using the common `ForkJoinPool`. The framework attempts to split the source, process partitions, and combine partial results. Benefits are possible when the dataset is sufficiently large, operations are computationally expensive, the source splits efficiently, and the operation is independent and associative.

Parallelism is not automatically faster. Thread coordination, partitioning, combining, cache effects, boxing, and task scheduling can outweigh computation. Ordered operations, `sorted`, `distinct`, blocking I/O, small collections, and shared mutable state are frequent sources of poor performance or incorrect behavior. The common pool is also shared by unrelated tasks, so using parallel streams in server applications can create resource contention.

A sound evaluation includes measurement with representative data and a suitable benchmark methodology, such as JMH for microbenchmarks. Timing a single execution with `System.nanoTime()` often measures warm-up, allocation, or unrelated scheduling effects rather than steady-state performance. Parallel streams should be selected because the workload justifies them, not because the syntax is shorter.

An Applied Case: Transaction Analysis for a Payment Service

Background

Consider an illustrative payment platform that receives transaction records from several processing nodes. Each record contains an account identifier, status, currency, amount, and timestamp. The reporting service must identify successful transactions, compute totals by currency, and produce a list of suspicious accounts with unusually large transactions.

Situation and Problem

An initial implementation uses nested loops, repeated temporary maps, and a shared mutable list. It has duplicated filtering rules, makes null handling inconsistent, and produces occasional failures when a report is generated concurrently with another report. The team wants a clearer implementation without sacrificing auditability.

Analysis

The problem contains several distinct operations: validation, selection, transformation, grouping, and aggregation. These operations can be represented as a pipeline. However, transaction validation may involve malformed records, and the reporting result must preserve deterministic ordering for audit output. The design therefore separates pure calculations from effectful retrieval and publication.

Approach and Solution

A simplified model might use:

```java

record Transaction(String account, String currency,

BigDecimal amount, Status status) {}

Map<String, BigDecimal> totalsByCurrency = transactions.stream()

.filter(Objects::nonNull)

.filter(t -> t.status() == Status.SUCCESS)

.collect(Collectors.groupingBy(

Transaction::currency,

Collectors.reducing(

BigDecimal.ZERO,

Transaction::amount,

BigDecimal::add)));

```

The grouping collector makes the aggregation rule explicit. A separate pipeline identifies suspicious accounts:

```java

List<String> suspiciousAccounts = transactions.stream()

.filter(t -> t.status() == Status.SUCCESS)

.filter(t -> t.amount().compareTo(THRESHOLD) > 0)

.map(Transaction::account)

.distinct()

.sorted()

.toList();

```

If transaction acquisition returns no data, the service can expose an empty result rather than a null collection. If a single account lookup may fail, `Optional` can represent that absence at the repository boundary. The report-generation method remains side-effect free until the final step that stores or publishes the report.

Outcome

The revised design removes shared mutation, centralizes aggregation rules, and makes ordering explicit. It is easier to unit-test because filtering and aggregation can be tested with small in-memory inputs. A parallel stream might be considered for very large, independent, CPU-intensive datasets, but it would require validating the cost of `BigDecimal` operations, ordering requirements, and concurrent service load.

Learning Points

The case demonstrates that functional style is not simply a replacement for loops. It is a method of decomposing a problem into transformations and reductions, selecting abstractions that express business rules, and controlling side effects. The team still needs domain validation, exception policy, performance measurement, and audit requirements; streams do not remove those responsibilities.

Common Errors, Risks, and Limitations

Several mistakes recur in professional Java code:

  • Treating a stream as a reusable collection.
  • Calling `get()` on an `Optional` without establishing presence.
  • Using `map` where `flatMap` is required.
  • Using `toMap` without considering duplicate keys.
  • Mutating external state inside a pipeline.
  • Assuming `parallelStream()` guarantees improved performance.
  • Relying on encounter order when using unordered or parallel processing.
  • Building pipelines so dense that their business meaning is obscured.
  • Performing blocking I/O inside common-pool parallel streams.
  • Using `reduce` with a non-associative accumulator.

Debugging can also be less direct because execution is lazy and operations may be fused. `peek` can assist temporary diagnosis, but it should not be used as the primary mechanism for business effects. Logging, tracing, and metrics should be designed with attention to possible multiple executions and parallel ordering.

Professional and Ethical Considerations

Functional pipelines often process personal, financial, health, or operational data. Developers must apply access control, data minimization, retention policies, and appropriate logging practices. A pipeline that groups or filters data can unintentionally expose sensitive categories or create unfair classifications. Technical correctness does not establish ethical legitimacy.

Determinism is important in regulated and auditable systems. If output order affects reports, approvals, or user treatment, it must be specified and tested rather than left to incidental collection or parallel behavior. Reproducible calculations also require clear rounding, numeric precision, time-zone handling, and error policies. Code review should therefore examine not only elegance but also data governance, traceability, and failure behavior.

Contemporary Directions and Best Practices

Recent Java releases have continued improving collection and stream ergonomics, including convenient immutable collection results and enhancements to language and library support. Nevertheless, the central design principles remain stable. Reactive libraries and asynchronous data-flow systems extend ideas of declarative transformation to data that arrives over time, but their back-pressure, scheduling, and failure models differ from ordinary collections and streams. Stream knowledge is therefore foundational, not a substitute for understanding reactive systems.

Recommended practice is to:

1. Use functional interfaces to name small, composable behaviors.

2. Keep transformations pure whenever practical.

3. Use `Optional` to communicate ordinary absence at suitable boundaries.

4. Select collectors according to the intended result and duplicate-key policy.

5. Verify associativity and identity before parallel reduction.

6. Preserve ordering only when the requirement justifies its cost.

7. Prefer clear domain methods over cryptic lambda expressions.

8. Benchmark representative workloads before adopting parallel execution.

9. Isolate I/O, mutation, and error handling at explicit boundaries.

10. Test empty inputs, duplicate values, null-related cases, ordering, and failure paths.

Chapter Summary

Java’s functional features provide a complementary style to object-oriented programming. Functional interfaces give types to behavior; lambdas and method references supply concise implementations; closures capture effectively final local variables. Stream pipelines express source-to-result transformations through lazy intermediate operations and terminal operations. `map`, `flatMap`, filtering, short-circuiting, collectors, and reduction support powerful declarative processing.

`Optional` represents ordinary absence but should not be used indiscriminately. Collectors are generally appropriate for mutable result structures, while reduction requires valid identity and associative combination rules. Functional code is most robust when side effects are limited and isolated. Parallel streams can help with suitable large, independent, CPU-bound workloads, but they introduce ordering, contention, splitting, and resource considerations. Readability, auditability, measurement, and domain requirements must guide every design choice.

Key Terms

Functional interface: An interface with exactly one abstract method.

Lambda expression: An expression that provides an implementation of a functional interface.

Method reference: A compact reference to an existing method or constructor.

Target typing: Inferring a lambda’s functional-interface type from its context.

Closure: A function together with captured values from its enclosing scope.

Predicate: A function that evaluates a value and returns a Boolean result.

Function: A function that transforms an input value into an output value.

Consumer: A function that accepts a value and produces no return result.

Supplier: A function that produces a value without an input.

Stream: A pipeline-oriented abstraction for processing a sequence of elements.

Intermediate operation: A lazy stream operation that returns another stream.

Terminal operation: An operation that initiates stream processing and produces a result or effect.

Short-circuiting: Ending stream processing once the required result is known.

Collector: A strategy for accumulating stream elements into a result.

Reduction: Combining stream elements into one value using an accumulator.

Optional: A container representing either one present value or absence.

Side effect: An observable state change or external interaction caused by computation.

Associativity: A property allowing grouped combination without changing the result.

Encounter order: The logical order in which stream elements are presented.

Parallel stream: A stream whose processing may be partitioned across multiple threads.

Review Questions

Short Answer Questions

1. What makes an interface a functional interface?

2. Distinguish `map` from `flatMap`.

3. Why must captured local variables be final or effectively final?

4. What is the difference between `orElse` and `orElseGet`?

5. Why is a stream described as lazy?

Long Answer Questions

1. Explain the structure and execution behavior of a stream pipeline, including intermediate, terminal, stateful, and short-circuiting operations.

2. Compare collectors and reduction, giving situations in which each is appropriate.

3. Discuss side effects in functional Java code and explain how they affect testing, correctness, and parallel execution.

4. Explain the conditions under which a parallel stream may improve performance and the conditions under which it may be harmful.

5. Describe how lambdas, method references, closures, and functional interfaces work together in Java.

Analytical / Application Questions

1. Design a stream-based solution that groups successful transactions by currency and computes their total amounts. Explain how duplicate keys and empty input are handled.

2. A developer uses `parallelStream().forEach(x -> sharedList.add(transform(x)))`. Analyze the defects and propose a correct alternative, including any ordering considerations.

3. A method uses `Optional.get()` after filtering a stream and applies `reduce` with subtraction. Identify the risks and redesign the method using appropriate absence handling and aggregation semantics.

22. Multiple Choice Questions with Answers

Question 1

Which statement best describes a Java functional interface?

A. An interface that contains only default methods

B. An interface with exactly one abstract method

C. An interface that cannot be implemented by a class

D. An interface whose methods must all be static

Correct Answer: B. An interface with exactly one abstract method

Question 2

Why is flatMap preferred over map when each input element produces multiple output elements?

A. It sorts the output automatically

B. It removes all null values

C. It flattens nested results into one stream

D. It makes the stream parallel

Correct Answer: C. It flattens nested results into one stream

Question 3

Which operation begins execution of a lazy stream pipeline?

A. A terminal operation

B. A lambda declaration

C. A stream source declaration

D. A method reference

Correct Answer: A. A terminal operation

Question 4

What is the principal risk in this code: items.parallelStream().forEach(x -> sharedList.add(x))?

A. The stream cannot process objects

B. The list may be mutated unsafely and ordering may be lost

C. forEach always sorts elements

D. Parallel streams cannot use lambdas

Correct Answer: B. The list may be mutated unsafely and ordering may be lost

Question 5

Which condition is required for a reduction intended for parallel execution?

A. The accumulator must be associative

B. The source must always be a Set

C. The identity must be randomly generated

D. The operation must perform I/O

Correct Answer: A. The accumulator must be associative

Question 6

Which statement about orElseGet is correct?

A. It always evaluates its fallback immediately

B. It invokes its supplier only when the Optional is empty

C. It throws when the Optional is present

D. It converts an Optional into a stream

Correct Answer: B. It invokes its supplier only when the Optional is empty

Question 7

What happens when toMap encounters duplicate keys without a merge function?

A. The duplicate entries are silently discarded

B. The first value is always retained

C. An IllegalStateException is normally thrown

D. The map becomes automatically concurrent

Correct Answer: C. An IllegalStateException is normally thrown

Question 8

Which example is a method reference?

A. x -> x * 2

B. () -> new ArrayList<String>()

C. String::trim

D. value -> { return value; }

Correct Answer: C. String::trim

Question 9

Why might a sequential stream outperform a parallel stream for a small collection?

A. Sequential streams always use more memory

B. Parallel streams cannot process collections

C. Parallel coordination and partitioning overhead may exceed the computation

D. Lambdas execute only sequentially

Correct Answer: C. Parallel coordination and partitioning overhead may exceed the computation

Question 10

Which design most clearly isolates side effects?

A. Updating a shared map inside every map operation

B. Writing to a database inside a filter predicate

C. Performing pure transformations and publishing the result at an explicit boundary

D. Using peek to implement all business actions

Correct Answer: C. Performing pure transformations and publishing the result at an explicit boundary

Programming in Java
Chapter 5 of 15