CPEN 221A · 2025 lab archive

Lab 8: Streams and Lambdas

2025 archive

Jump to a lab section

Overview: What Are Java Streams?

Java Streams are a powerful feature introduced in Java 8 that allows for functional-style operations on collections of data. A Stream is a sequence of elements that supports aggregate operations, such as filtering, mapping, and reducing, in a clean and declarative manner.


Learning Outcomes


Key Characteristics of Streams

  1. Functional: Streams encourage functional programming. You can process data without modifying the original collection, and operations are defined in terms of transformations on data.
  2. Lazy Evaluation: Streams are lazily evaluated, meaning operations are not executed until a terminal operation (like collect() or reduce()) is invoked. This improves performance by avoiding unnecessary calculations.
  3. Parallelizable: Streams can be processed in parallel with minimal effort by simply calling parallelStream() instead of stream(). This makes it easier to take advantage of multi-core processors.
  4. Non-Mutating: Stream operations do not modify the source collection but instead return new collections or values.

The Stream Pipeline

Streams operate in a pipeline of operations. This pipeline consists of:

  1. Source: The data to be processed (like a List, Set, or an array).
  2. Intermediate Operations: Operations that transform or filter the data (like filter(), map(), sorted()). These operations are lazy and return a new stream.
  3. Terminal Operation: The final operation that triggers the execution of the pipeline and produces a result (like collect(), reduce(), or forEach()).

Here’s a simple example of a Stream pipeline:

List<String> words = Arrays.asList("apple", "banana", "cherry", "date", "elderberry");

List<String> result = words.stream()
    .filter(word -> word.length() > 5)
    .sorted()
    .collect(Collectors.toList());

System.out.println(result);  // Outputs: [banana, cherry, elderberry]

In this example:


Types of Stream Operations

filter() Filters elements based on a predicate.
map() Transforms elements by applying a function.
sorted() Sorts the stream.
distinct() Removes duplicates.
Example of `map()` and `filter()`:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squaredNumbers = numbers.stream()
    .map(n -> n * n)  // Square each number
    .filter(n -> n > 10)  // Keep only numbers greater than 10
    .collect(Collectors.toList());  // Collect the results into a list

System.out.println(squaredNumbers);  // Outputs: [16, 25]
collect() Gathers elements of the stream into a collection like a List, Set, or Map.
forEach() Performs an action for each element of the stream.
reduce() Reduces the elements of a stream into a single value (e.g., summing, finding max/min).
count() Counts the number of elements in the stream.
findFirst() Finds the first element of the stream.
**Example of `forEach()`:**
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream()
    .forEach(name -> System.out.println("Hello, " + name));

Using Streams for Parallel Processing

One of the major advantages of Streams is the ability to easily process data in parallel, leveraging modern multi-core processors. By simply replacing stream() with parallelStream(), the Stream is processed concurrently.

Example of parallel processing

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.parallelStream()
    .reduce(0, Integer::sum);

System.out.println(sum);

In this case, the sum is calculated in parallel, potentially speeding up execution for larger datasets.


Introduction to Optional

In functional programming with Streams, it’s common to encounter cases where a value might be absent, such as the result of filtering. Instead of returning null and risking null pointer exceptions, Java provides Optional<T>, a container that may or may not hold a value.

Common Optional Methods:

isPresent() Checks if a value is present.
ifPresent(Consumer) Executes a block of code if a value is present.
orElse(T other) Returns the value if present, or a default value if not.
map() Transforms the value inside the Optional.

Example using Optional:

Optional<String> maybeName = Optional.of("Alice");
maybeName.ifPresent(name -> System.out.println("Hello, " + name));

Optional<String> emptyName = Optional.empty();
System.out.println(emptyName.orElse("Guest"));

Combining Streams and Optional

Streams and Optional often work hand-in-hand when filtering or reducing data. For example, you might want to find the first element that matches a condition, and if no match is found, return an Optional.empty().

Example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
Optional<String> firstMatch = names.stream()
    .filter(name -> name.startsWith("C"))
    .findFirst();

firstMatch.ifPresent(name -> System.out.println("First match: " + name));

Optional is not all good

We wanted to introduce you to Java’s Optional type because it exists and you might encounter it in code. But it is not all good. Here is a deeper discussion on the Optional type:

Some Comments on Java’s Optional Type


Stream Example with a Complex Object: TikTok Data

Let’s say we have a TikTokVideo class that contains fields like videoId, uploader, likes, shares, and comments. Here’s how we could use Streams to perform operations on a list of TikTokVideo objects.

Example: Sorting TikTok videos by total engagement (likes + shares + comments):

List<TikTokVideo> rankedByEngagement = videos.stream()
    .sorted(Comparator.comparingInt(video -> video.getLikes() + video.getShares() + video.getComments()).reversed())
    .collect(Collectors.toList());

rankedByEngagement.forEach(video -> {
    int totalEngagement = video.getLikes() + video.getShares() + video.getComments();
    System.out.println("Video ID: " + video.getVideoId() + ", Total Engagement: " + totalEngagement);
});

In this example, we:

Example: Finding Recent Videos

 List<TikTokVideo> recentVideos = videos.stream()
            .filter(video -> video.getUploadTime()
            .isAfter(LocalDateTime.now()
            .minusDays(7)))
            .collect(Collectors.toList());
recentVideos.forEach(video ->
            System.out.println("Recent video ID: " + video.getVideoId()
                               + ", Uploaded: " + video.getUploadTime()));

Example: Calculating Total Engagement Across All TikTok Videos

Let’s say we want to calculate the total engagement (sum of likes, shares, and comments) across all TikTok videos. The reduce() operation can help us aggregate this total in a functional and clean way.

Here’s an example:

int totalEngagement = videos.stream()
    .map(video -> video.getLikes() + video.getShares() + video.getComments())
    .reduce(0, Integer::sum);

System.out.println("Total Engagement across all videos: " + totalEngagement);

Example: Finding the Most Active Uploader

  Optional<Map.Entry<String, Long>> mostActiveUploader = videos.stream()
      .collect(Collectors.groupingBy(TikTokVideo::getUploader, Collectors.counting()))
      .entrySet().stream()
      .max(Map.Entry.comparingByValue());

Introduction to Lambdas

Lambda expressions allow you to express instances of functional interfaces (interfaces with a single abstract method) in a more concise way. They provide a shorthand for creating anonymous classes and can make your code much cleaner and easier to read, especially when working with functional programming constructs like Streams.

Why Use Lambdas?

Lambdas are particularly useful when you need to pass behavior (like a block of code) as a parameter to a method. Instead of writing verbose inner classes, lambdas allow you to focus on what should be done, without all the boilerplate code around how to structure it.

Consider this common scenario: You want to filter or transform a collection of data. Traditionally, you might create an anonymous class that implements an interface like Comparator or Runnable. With lambdas, you can achieve the same result with much less code.


Syntax of a Lambda Expression

A lambda expression has three parts:

(parameters) -> expression

If there’s only one parameter, you can omit parentheses.

If there’s only one statement in the body, you can omit curly braces.

Examples

x -> x * x
(a, b) -> {
    int result = a + b;
    return result;
}

Lambdas in Practice

Using Lambdas with a Functional Interface

A functional interface is an interface that has exactly one abstract method. Java provides several commonly used functional interfaces, including:

Examples

Filtering a list of integers to find numbers greater than 10

Predicate<Integer> isGreaterThan10 = x -> x > 10;

Doubling a number and returning the result

Function<Integer, Integer> doubleNumber = x -> x * 2;

Printing out each element in a list

Consumer<String> printString = s -> System.out.println(s);

Using Lambdas with Streams

Lambdas are especially powerful when used with Streams. Streams provide a way to process data declaratively, and lambdas allow you to specify what should be done with the data.

Example: Using Lambdas with filter() and map()

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> filteredAndSquared = numbers.stream()
    .filter(x -> x % 2 == 0)  // Keep only even numbers
    .map(x -> x * x)  // Square each remaining number
    .collect(Collectors.toList());

System.out.println(filteredAndSquared);  // Outputs: [4, 16, 36]

Lambdas with Custom Types

Lambdas can also be applied to more complex data types, like objects. For example, imagine a Person class with fields like name and age. You can use a lambda to extract or manipulate the fields of Person instances.

Example: Filtering and Transforming a List of Objects

List<Person> people = Arrays.asList(
    new Person("Alice", 30),
    new Person("Bob", 20),
    new Person("Charlie", 40)
);

// Filter people older than 25 and extract their names
List<String> names = people.stream()
    .filter(person -> person.getAge() > 25)  // Filter by age
    .map(Person::getName)  // Extract the name
    .collect(Collectors.toList());

System.out.println(names);  // Outputs: [Alice, Charlie]

When to Use Lambdas

More Examples with Lambdas

Filtering a list of numbers

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> evenNumbers = numbers.stream()
    .filter(n -> n % 2 == 0)  // Lambda to filter even numbers
    .collect(Collectors.toList());

System.out.println(evenNumbers);  // Outputs: [2, 4]

Transforming data

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> upperCaseNames = names.stream()
    .map(name -> name.toUpperCase())  // Lambda to convert each name to uppercase
    .collect(Collectors.toList());

System.out.println(upperCaseNames);  // Outputs: [ALICE, BOB, CHARLIE]

Summary

Streams enable clean, functional, and efficient processing of collections, reducing the need for verbose loops and conditionals. They make it easier to perform complex operations like filtering, transforming, and reducing data, all while keeping the code concise and readable.

Optional complements Streams by providing a way to handle absent values safely, avoiding null and potential runtime errors.

Lambdas allow you to write more concise code by eliminating boilerplate associated with anonymous inner classes. With fewer lines of code, there’s less chance of introducing errors, and it’s easier to understand what the code does. Lambdas let you focus on what needs to be done, without the clutter of how the code is structured. Lambdas enable functional programming patterns in Java, allowing you to treat functions as first-class citizens. You can pass behaviour (i.e., a lambda) to a method, store it in variables, or return it from other methods.

By understanding and mastering these tools, you can write more expressive, maintainable, and efficient Java code.


Grading

You will answer questions on PrairieLearn.