Index

Streams and Functional Programming with Collections

Java Collections

14.1 Introduction to Streams API

The Streams API, introduced in Java 8, revolutionized how developers work with collections by offering a declarative, functional-style approach to data processing. Rather than relying on loops and external iteration, streams enable concise and readable code that focuses on what to do with data rather than how to do it.

What Is a Stream?

A stream is a sequence of elements that supports aggregate operations such as filtering, mapping, and reducing. Streams don’t store data themselves; instead, they operate on data sources like Collection, List, or arrays. Think of a stream as a pipeline through which data flows, undergoing transformations or actions along the way.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream()
     .filter(name -> name.startsWith("A"))
     .forEach(System.out::println);  // Output: Alice

In this example, the stream filters names starting with "A" and prints them. This approach is both readable and expressive, eliminating boilerplate loop constructs.

Key Characteristics of Streams

  1. Declarative and Functional: Streams favor a high-level, declarative style. Operations like filter, map, and collect describe what should happen, not how to loop or manage state.

  2. Lazy Evaluation: Intermediate operations (like filter or map) are lazy—they don't execute until a terminal operation (like forEach, collect, or reduce) is invoked. This enables performance optimizations, such as short-circuiting.

  3. Single Use: Streams can only be traversed once. After a terminal operation, the stream is considered consumed.

  4. Can Be Parallelized: With .parallelStream(), the same operations can be processed in parallel, improving performance for large datasets.

Streams vs. Collections

Feature Collection Stream
Data Structure Stores elements (in memory) Computes elements on demand
Iteration External (e.g., for-loop) Internal (handled by stream pipeline)
Mutability Can be modified (add/remove) Immutable – does not modify source
Reusability Can iterate multiple times Consumed after one use

Why Use Streams?

Streams simplify complex data manipulations, such as filtering or aggregating, into a series of concise steps:

int sum = numbers.stream()
                 .filter(n -> n > 0)
                 .mapToInt(Integer::intValue)
                 .sum();  // Sum of positive integers

This avoids verbose loops and improves clarity, especially in multi-step transformations.

By separating the intent of data processing from the mechanics, the Streams API encourages cleaner, more maintainable code. As we delve deeper into filtering, mapping, reducing, and collecting in later sections, you’ll see just how powerful and elegant stream-based programming can be.

Index

14.2 Filtering, Mapping, Reducing Collections

The Streams API in Java provides a powerful set of core operations that allow us to process collections in a fluent and expressive manner. The three most essential operations are filtering, mapping, and reducing. These form the backbone of functional-style data processing in Java.

Filtering: Selecting Elements

filter() is an intermediate operation that lets you retain elements based on a condition. It takes a predicate (a function that returns true or false) and produces a new stream with only the elements that match.

Example:

List<String> names = Arrays.asList("Alice", "Bob", "Amanda", "Brian");

List<String> filtered = names.stream()
    .filter(name -> name.startsWith("A"))
    .collect(Collectors.toList());

System.out.println(filtered); // Output: [Alice, Amanda]

In this example, only names starting with "A" are selected.

Mapping: Transforming Elements

map() is another intermediate operation that transforms each element of the stream into another form. It takes a function and applies it to each element.

Example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

List<Integer> nameLengths = names.stream()
    .map(String::length)
    .collect(Collectors.toList());

System.out.println(nameLengths); // Output: [5, 3, 7]

Here, the map() function transforms each string into its length.

Reducing: Aggregating Results

reduce() is a terminal operation that combines elements into a single result, such as a sum or concatenation. It takes a binary operator (e.g., addition) and applies it cumulatively.

Example:

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

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

System.out.println(sum); // Output: 15

The stream reduces the list of integers to their total sum.

Chaining: Combining Operations Fluently

One of the most powerful aspects of streams is the ability to chain operations together to build complex data transformations in a clean and readable way.

Example:

List<String> words = Arrays.asList("stream", "filter", "map", "reduce", "collect");

int totalChars = words.stream()
    .filter(word -> word.length() > 4)   // Keep words longer than 4 characters
    .map(String::length)                 // Convert to word length
    .reduce(0, Integer::sum);            // Sum the lengths

System.out.println(totalChars); // Output: 30

This example filters out short words, maps the remaining words to their lengths, and then sums those lengths.

Click to view full runnable Code

import java.util.*;
import java.util.stream.Collectors;

public class StreamOperationsDemo {
    public static void main(String[] args) {
        // FILTER: Select names starting with "A"
        List<String> names = Arrays.asList("Alice", "Bob", "Amanda", "Brian");
        List<String> filtered = names.stream()
            .filter(name -> name.startsWith("A"))
            .collect(Collectors.toList());
        System.out.println("Filtered (starts with A): " + filtered); // [Alice, Amanda]

        // MAP: Transform names to their lengths
        List<String> moreNames = Arrays.asList("Alice", "Bob", "Charlie");
        List<Integer> nameLengths = moreNames.stream()
            .map(String::length)
            .collect(Collectors.toList());
        System.out.println("Name lengths: " + nameLengths); // [5, 3, 7]

        // REDUCE: Sum a list of integers
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        int sum = numbers.stream()
            .reduce(0, Integer::sum);
        System.out.println("Sum of numbers: " + sum); // 15

        // CHAINING: Filter, map, and reduce
        List<String> words = Arrays.asList("stream", "filter", "map", "reduce", "collect");
        int totalChars = words.stream()
            .filter(word -> word.length() > 4)
            .map(String::length)
            .reduce(0, Integer::sum);
        System.out.println("Total characters (length > 4): " + totalChars); // 30
    }
}

Summary

Operation Purpose Type
filter() Selects elements conditionally Intermediate
map() Transforms elements Intermediate
reduce() Combines elements into one value Terminal

Filtering, mapping, and reducing are foundational to stream processing. They allow developers to write clean, concise, and declarative code for complex data transformations. In the next section, we'll expand this foundation using Collectors for grouped results and explore the benefits of parallel streams.

Index

14.3 Collectors and Parallel Streams

The Streams API provides a powerful way to process collections functionally, and collectors play a central role in gathering the results of stream operations. At the same time, parallel streams enable data processing across multiple threads for improved performance—if used wisely.

Collectors: Gathering Results

The Collectors class provides a variety of static methods that can be used to collect stream elements into various formats. These methods are typically passed to the collect() terminal operation.

Common Collectors

  1. Collectors.toList() Gathers stream elements into a List.

    List<String> names = Stream.of("Alice", "Bob", "Charlie")
        .collect(Collectors.toList());
  2. Collectors.toSet() Collects elements into a Set, eliminating duplicates.

    Set<Integer> numbers = Stream.of(1, 2, 2, 3)
        .collect(Collectors.toSet()); // Output: [1, 2, 3]
  3. Collectors.joining() Concatenates strings into a single string, optionally with delimiters.

    String result = Stream.of("Java", "Streams", "API")
        .collect(Collectors.joining(", "));
    System.out.println(result); // Output: Java, Streams, API
  4. Collectors.groupingBy() Groups elements by a classification function, returning a Map.

    List<String> words = Arrays.asList("apple", "banana", "apricot", "blueberry");
    
    Map<Character, List<String>> grouped = words.stream()
        .collect(Collectors.groupingBy(w -> w.charAt(0)));
    
    System.out.println(grouped);
    // Output: {a=[apple, apricot], b=[banana, blueberry]}
  5. Collectors.summarizingInt() Provides summary statistics like count, sum, min, max, and average.

    IntSummaryStatistics stats = Stream.of(3, 5, 7, 2, 9)
        .collect(Collectors.summarizingInt(Integer::intValue));
    
    System.out.println(stats); // count=5, sum=26, min=2, average=5.2, max=9

Parallel Streams

Parallel streams offer a convenient way to leverage multi-core processors by dividing a stream’s elements and processing them concurrently.

To convert a regular stream into a parallel one, simply call parallel():

List<Integer> nums = IntStream.range(1, 10000)
    .boxed()
    .collect(Collectors.toList());

int sum = nums.parallelStream()
    .reduce(0, Integer::sum);
System.out.println("Sum: " + sum);

Benefits

Caveats

Example: Demonstrating Ordering Issue

List<String> letters = Arrays.asList("A", "B", "C", "D", "E");

System.out.println("Using parallel forEach:");
letters.parallelStream().forEach(System.out::print); // Order not guaranteed

System.out.println("\nUsing parallel forEachOrdered:");
letters.parallelStream().forEachOrdered(System.out::print); // Order preserved
Click to view full runnable Code

import java.util.*;
import java.util.function.Function;
import java.util.stream.*;

public class CollectorAndParallelDemo {
    public static void main(String[] args) {
        // toList()
        List<String> names = Stream.of("Alice", "Bob", "Charlie")
            .collect(Collectors.toList());
        System.out.println("List: " + names);

        // toSet()
        Set<Integer> numbers = Stream.of(1, 2, 2, 3)
            .collect(Collectors.toSet());
        System.out.println("Set (no duplicates): " + numbers);

        // joining()
        String joined = Stream.of("Java", "Streams", "API")
            .collect(Collectors.joining(", "));
        System.out.println("Joined string: " + joined);

        // groupingBy()
        List<String> words = Arrays.asList("apple", "banana", "apricot", "blueberry");
        Map<Character, List<String>> grouped = words.stream()
            .collect(Collectors.groupingBy(w -> w.charAt(0)));
        System.out.println("Grouped by first letter: " + grouped);

        // summarizingInt()
        IntSummaryStatistics stats = Stream.of(3, 5, 7, 2, 9)
            .collect(Collectors.summarizingInt(Integer::intValue));
        System.out.println("Summary statistics: " + stats);

        // Parallel stream sum
        List<Integer> bigList = IntStream.range(1, 10_000)
            .boxed()
            .collect(Collectors.toList());
        int sum = bigList.parallelStream()
            .reduce(0, Integer::sum);
        System.out.println("Parallel sum: " + sum);

        // Demonstrating ordering issue
        List<String> letters = Arrays.asList("A", "B", "C", "D", "E");

        System.out.print("Parallel forEach: ");
        letters.parallelStream().forEach(System.out::print); // May print out of order

        System.out.print("\nParallel forEachOrdered: ");
        letters.parallelStream().forEachOrdered(System.out::print); // Preserves order
    }
}

Summary

Collector Use Case
toList(), toSet() Gather into collections
joining() Concatenate string results
groupingBy() Classify elements into map groups
summarizingInt() Collect numeric stats

Parallel streams can greatly enhance performance but must be used with careful attention to side effects, ordering, and shared mutable state.

In the next section, we’ll explore more hands-on examples to bring together the concepts of filtering, mapping, reducing, and collecting—all in a functional style.

Index

14.4 Runnable Examples: Functional-style collection processing

Streams provide a fluent, readable way to process collections with a sequence of operations—filtering, mapping, reducing, and collecting results. Below are runnable examples demonstrating these concepts, including parallel processing and collectors.

Example 1: Filtering, Mapping, and Reducing

This example takes a list of integers, filters out odd numbers, squares the even ones, and sums the results.

import java.util.Arrays;
import java.util.List;

public class StreamExample1 {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

        int sumOfSquares = numbers.stream()
            // Filter: keep only even numbers
            .filter(n -> n % 2 == 0)
            // Map: square each remaining number
            .map(n -> n * n)
            // Reduce: sum all squared values
            .reduce(0, Integer::sum);

        System.out.println("Sum of squares of even numbers: " + sumOfSquares);
    }
}

Explanation:

Example 2: Collecting to a List and Grouping

Now we convert a stream of words to uppercase and group them by their first letter.

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class StreamExample2 {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("apple", "banana", "avocado", "blueberry", "cherry");

        Map<Character, List<String>> grouped = words.stream()
            // Map to uppercase
            .map(String::toUpperCase)
            // Group by first character
            .collect(Collectors.groupingBy(word -> word.charAt(0)));

        System.out.println(grouped);
    }
}

Explanation:

Example 3: Parallel Stream for Performance

Parallel streams can improve performance on large collections. This example calculates the sum of squares using parallel processing.

import java.util.List;
import java.util.stream.IntStream;

public class StreamExample3 {
    public static void main(String[] args) {
        List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
            .boxed()
            .toList();

        long start = System.currentTimeMillis();

        int sumOfSquares = numbers.parallelStream()
            .filter(n -> n % 2 == 0)
            .map(n -> n * n)
            .reduce(0, Integer::sum);

        long end = System.currentTimeMillis();

        System.out.println("Sum of squares (parallel): " + sumOfSquares);
        System.out.println("Time taken (ms): " + (end - start));
    }
}

Explanation:

Summary

These examples highlight the expressive power of streams:

Streams enable concise, readable, and maintainable code, making collection processing easier and often more efficient.

Index