Week 3 - Data Structures & Testing 

Collections

Generics

Enums

Stream APIs

Unit Testing with JUnit

Debugging in Java

Practice

Assignment

Back end Track

Introduction

In this chapter, you’ll learn how Java’s Stream API helps you process collections with clean, chainable operations.

If you remember JavaScript array methods such as .filter().map(), and .reduce(), the idea will feel familiar. Java streams let you describe a sequence of data transformations, then run that sequence when you ask for a final result.

We’ll start by comparing imperative and declarative data processing. Then you’ll learn how to create streams, use intermediate operations such as filter()map(), and sorted(), and finish a pipeline with terminal operations such as toList()collect()forEach(), and count().

Data Processing with Streams

The Java Stream API was introduced in Java 8. It lets you process collections in a declarative style rather than only using the traditional imperative style.

Imperative Data Processing

In imperative code, you describe how to perform an operation step by step: create this variable, loop over that list, add this value to the result.

In the code below, we have a list of grocery item names. We want to transform each String into a GroceryItem object with a quantity value. The imperative approach does the mapping step by step:

// Richer object type that we want to map grocery strings to
record GroceryItem(String name, int quantity) {}

void main() {
  // Preparing the array we want to map from
  List<String> groceries = Arrays.asList("Milk", "Eggs", "Banana");

  // Mapping imperatively/step by step
  // Step1: Initialize empty array
  List<GroceryItem> mappedGroceries = new ArrayList<>();
  // Step2: Loop over the original array
  for (String item : groceries) {
    // Step3: Instantiate a GroceryItem object with the grocery string
    GroceryItem groceryItem = new GroceryItem(item, 0);
    // Step4: Add the grocery object to mapped list.
    mappedGroceries.add(groceryItem);
  }

  IO.println(mappedGroceries);
}

Declarative Data Processing

In declarative code, you describe what result you want rather than every step needed to produce it. The Stream API enables this style.

Let’s compare the two styles with pseudocode:

=========================================================================
Imperative style
=========================================================================

initialize empty result list

for each person in people do:
    if person.age >= 18:
        if person.country == "Netherlands":
		        name = "{person.firstName} {person.lastName}"
            uppercaseName = convertToUpperCase(name)
            add uppercaseName to result list

return result list

=========================================================================
Declarative style
=========================================================================

return people
		filter people so that each person.age >= 18
		filter people so that each person.country == "Netherlands"
		map each person to "{person.firstName} {person.lastName}" (Type changes to String)
		map the full-name to convertToUpperCase(full-name)
		collect to list

In the declarative style, each line describes part of the result. The final line, collect to list, is different: it asks Java to actually run the pipeline and produce a concrete result.

That final execution step is called a terminal operation. Without a terminal operation, a stream pipeline describes work, but it does not produce a final value.

💬 Can you think of the advantage of having a terminal operation in declarative data processing?

Working with Streams

Now that you understand the difference between imperative and declarative data processing, you can start using Java’s Stream API.

Creating Streams

Here are two ways to create streams in Java.

void main() {
  List<String> groceries = Arrays.asList("Milk", "Eggs", "Bread");

  // Using the built-in method of List interface
  Stream<String> groceriesStream = groceries.stream();

  // Using the static, default method of() in Stream interface which takes multiple values
  Stream<String> groceriesStream2 = Stream.of("Milk", "Eggs", "Bread");
}

The .stream() method is the most common approach when you already have a collection.

<aside> ⚠️

Be careful when storing a Stream in a variable. A stream can be consumed by a terminal operation only once. If you need to process the same data again, create a new stream from the original collection.

To stay safe, keep streams created with Stream.of inside a small local scope and finish the pipeline immediately.

</aside>

<aside> 💡

If you hover over the .stream() method in IntelliJ, you can see the return type is ReferencePipeline . This is a Java type for a stream that hasn’t been terminally operated on (yet).

Think of a stream as a pipeline of operations that elements pass through: source -> filter -> map -> collect.

</aside>

Intermediate Operations

An intermediate operation returns another stream. It adds one step to the pipeline, but it does not process the elements yet.

The Stream interface in the java.util.stream package provides many intermediate operations. In this chapter, we’ll focus on filter()map(), and sorted().

filter()

The filter() method keeps only the elements that match a condition.

Its argument is a Predicate<T>, a functional interface that receives a value of type T and returns a boolean. The T comes from the stream’s element type. In the example below, the stream contains String values, so the predicate receives a String.

void main() {
    List<String> names = List.of("Anna", "Bob", "Charlie", "Eve");

    Stream<String> shortNames = names.stream()
            .filter(name -> name.length() <= 3);

    IO.println(shortNames.toList());
}

<aside> ⌨️

Hands on: Try printing shortNames directly, without the terminal operation toList(). What do you see?

</aside>

<aside> 💡

Predicate<T> is a functional interface. In practice, a functional interface is an interface with exactly one abstract method. That single method is the target for the lambda expression or method reference you provide.

In the case of Predicate<T>, the target method is:

// Predicate.java | java.util.function.Predicate
// Find it in IntelliJ IDEA with Search Everywhere: Double Shift
package java.util.function;

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

That’s exactly what the lambda we passed to .filter() above implements: it receives a String and returns a boolean.

Java provides more functional interfaces in the java.util.function package. To use them, you write a lambda that matches the signature of the interface’s single abstract method.

</aside>

<aside> 💡

In IntelliJ, you can also Ctrl+click (Cmd+click on Mac) Predicate to jump directly to its source code.

</aside>

map()

The map() method transforms each element into another value.

Its argument is a Function<T, R>, a functional interface that receives a value of type T and returns a value of type R. This means map() can also change the element type of the stream.

Earlier, we mapped grocery names to GroceryItem objects imperatively. Here is the same idea with map():

import java.util.List;
import java.util.stream.Stream;

record GroceryItem(String name, int quantity) {}

void main() {
    List<String> groceries = List.of("Milk", "Eggs", "Banana");

    Stream<GroceryItem> mappedGroceries = groceries.stream()
            .map(item -> new GroceryItem(item, 0));

    IO.println(mappedGroceries.toList());
}

Much cleaner!

sorted()

The sorted() method sorts elements in their natural order: alphabetically for strings and ascending for numbers. You can pass a Comparator when you need custom sorting.

void main() {
  List<String> names = Arrays.asList("Charlie", "Anna", "Eve", "Bob", "David");
  List<String> sortedNames = names.stream()
          .sorted()
          .toList(); // Terminal operation

  IO.println(sortedNames);
}

Terminal Operations

terminal operation starts the actual processing of the stream and produces a final result or side effect. After a terminal operation runs, that stream is consumed and cannot be reused.

You have already seen toList(), which collects stream elements into a List. Three other terminal operations you will use often are collect()forEach(), and count().

collect()

The collect() method gathers the stream elements into another structure, such as a ListSet, or Map. It takes a Collector, which tells Java how to build the result.

Java provides many ready-made collectors through the Collectors class in the java.util.stream package.

<aside> ⚠️

You will rarely need to implement the Collector interface yourself. If you feel tempted to do that, first check whether a method in Collectors already solves the problem.

</aside>

Here is Collectors.toList():

// Object we want to map grocery strings to
record GroceryItem(String name, int quantity) {}

void main() {
  List<GroceryItem> groceries = new ArrayList<>();
  
  groceries.add(new GroceryItem("Milk", 0));
  groceries.add(new GroceryItem("Eggs", 0));
  groceries.add(new GroceryItem("Banana", 10));

  List<GroceryItem> mappedGroceries = groceries.stream()
          .filter(grocery -> grocery.quantity > 0)
          .collect(Collectors.toList());

  IO.println(mappedGroceries);
}

And Collectors.toSet() to collect into a Set :

void main() {
  List<String> names = Arrays.asList("Charlie", "Charlie", "Charlie", null);
  Set<String> sortedNames = names.stream()
          .filter(name -> name != null)
          .collect(Collectors.toSet());

  IO.println(sortedNames);
}

forEach()

The forEach() method runs an action for each element. Its argument is a Consumer<T>, a functional interface that receives a value and returns nothing.

Use forEach() when the goal is a side effect, such as printing values:

void main() {
  List<String> groceries = Arrays.asList("Milk", "Eggs", "Banana");
  groceries.stream()
          .filter(name -> name != null)
          .forEach(element -> IO.println(element));
}
// OUTPUT:
// Milk
// Eggs
// Banana

count()

The count() method returns the number of elements in the stream as a long.

void main() {
  List<String> groceries = Arrays.asList("Milk", "Eggs", "Banana");
  Stream<String> groceriesStream = groceries.stream()
          .filter(name -> name != null);

  IO.println(groceriesStream.count());
}

<aside> ⚠️

Try printing groceriesStream.toList() after groceriesStream.count(). You should get an exception.

That is because a terminal operation consumes the stream. After groceriesStream.count() runs, groceriesStream.toList() tries to reuse the same stream and Java throws an IllegalStateException.

</aside>

Streams vs loops - When to use which

Advantages of working with streams:

Use streams when they make the transformation easier to read.

Use for-loops instead if: