Skip to content

VOL 01 / CH 08 / LESSON 08

8.8 Lab: Lambda and Streams

Prerequisites: Lambda expressions, method references, and the Stream API

Estimated time: 35 minutes

Runtime Environment: Java 17

You are ready to continue when: You can complete the exercises and verify them with fixed input

Write the acceptance criteria first, then connect the pipeline

After each step, print or assert the result to check order, empty inputs, and duplicates. Shortening a pipeline is easy; getting the right names out of it takes a little more attention.

1. Create an experiment file

First, prepare a separate directory for the experiment:

bash
chapter8_lambda_root=$(mktemp -d)
readonly chapter8_lambda_root
mkdir -p "$chapter8_lambda_root/out"
cd "$chapter8_lambda_root"

Create LambdaLab.java with this starting structure:

java
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;

public class LambdaLab {
    public static void main(String[] args) {
        // Practice code goes here
    }

    @FunctionalInterface
    interface Transformer<T, R> {
        R transform(T input);
    }

    record Participant(String name, int age, String team) { }
}

The compile command always uses Java 17:

bash
javac --release 17 -encoding UTF-8 -d out LambdaLab.java
java -cp out LambdaLab

2. Warm-up: String Pipeline

Goal: Filter items from the word list to include only those longer than 3 characters, convert them to uppercase, sort them, and collect the results.

java
List<String> words = List.of("cat", "tiger", "elephant", "giraffe", "dog");

List<String> longWords = words.stream()
        .filter(word -> word.length() > 3)
        .map(word -> word.toUpperCase(java.util.Locale.ROOT))
        .sorted()
        .toList();

System.out.println(longWords);

Expected output:

text
[ELEPHANT, GIRAFFE, TIGER]

Uppercase conversion uses Locale.ROOT so the machine's default locale cannot change the expected result, as Turkish casing can do for i. When checking order, don't just verify that all elements are present. sorted() is part of the requirement, and tests should also verify the entire list.

3. Warm-up Two: Change Anonymous Inner Class to Lambda

Now let's look at the original version:

java
Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("Task execution in progress");
    }
};

A function method with no parameters or return value can be rewritten as:

java
Runnable task = () -> System.out.println("Task execution in progress");
task.run();

This code cannot be changed to System.out::println while preserving its behavior because println() has a parameterless overload that outputs an empty line and does not include "Task executing". Only after extracting the meaningful actions into a method can it be properly referenced.

java
static void announceTask() {
    System.out.println("Task execution in progress");
}

Runnable task = LambdaLab::announceTask;

Method references should retain their original behavior and shouldn't just aim at ::.

4. Warm-Up Three: Choosing the Right Method Reference

java
Function<String, Integer> parser = Integer::parseInt;
Function<String, String> clean = String::strip;
Comparator<Participant> byAge = Comparator.comparingInt(Participant::age);

Say their target types one by one. If you remove the left-side type, the method reference usually loses the context needed for type checking.

Try another invalid input:

java
try {
    parser.apply("Twelve");
} catch (NumberFormatException exception) {
    System.out.println("Cannot parse age: twelve");
}

Lambda and method references won't alter the exception contract. Parsing failures must still be handled at appropriate boundaries.

5. Challenge One: Generate Participant Report

Input is fixed as follows:

java
List<Participant> participants = List.of(
        new Participant("Zhang San", 25, "Basics"),
        new Participant("Li Si", 17, "Basics"),
        new Participant("Wang Wu", 30, "Algorithms"),
        new Participant("Zhao Liu", 22, "Basics"));

First, complete two clearly defined queries:

java
List<String> adultNamesInTeam = participants.stream()
        .filter(participant -> participant.team().equals("Basics"))
        .filter(participant -> participant.age() >= 18)
        .sorted(Comparator.comparingInt(Participant::age))
        .map(Participant::name)
        .toList();

double averageAge = participants.stream()
        .mapToInt(Participant::age)
        .average()
        .orElse(0.0);

System.out.println(adultNamesInTeam);
System.out.printf(java.util.Locale.ROOT, "Average age: %.1f%n", averageAge);

Expected output:

text
[Zhao Liu, Zhang San]
Average age: 23.5

Two passes aren't wrong. For small lists in memory, two separate pipelines are often more readable than forcing everything into a single, complex collection. Only when measurements show traversal cost is significant, or when the source can be consumed only once, should you consider composite collection.

This advanced snippet also needs import java.util.stream.Collectors;. Available since Java 12, Collectors.teeing provides two results, but it's considered an advanced tool:

java
record Report(List<String> names, double averageAge) { }

Report report = participants.stream().collect(Collectors.teeing(
        Collectors.filtering(
                participant -> participant.team().equals("Basics")
                        && participant.age() >= 18,
                Collectors.mapping(Participant::name, Collectors.toList())),
        Collectors.averagingInt(Participant::age),
        Report::new));

The composite collection is not sorted by age. If the application requires stable ordering, define it in the collector or result phase; a single traversal does not automatically satisfy every semantic requirement.

6. Challenge Two: Custom Functional Interface

The return types differ across conversions, so you can't force them all into List<Transformer<String, String>>. Use a wildcard to express "input is always String, output types vary." In Transformer<T, R>, T is the input type and R the output type; ? means the output type is unknown here. Chapter 12 explains these generic types in detail:

java
Transformer<String, Integer> toLength = String::length;
Transformer<String, String> toUpper = text -> text.toUpperCase(java.util.Locale.ROOT);
Transformer<String, String> withPrefix = text -> "[Message] " + text;

List<Transformer<String, ?>> transformations =
        List.of(toLength, toUpper, withPrefix);

for (Transformer<String, ?> transformation : transformations) {
    System.out.println(transformation.transform("hello"));
}

Expected output:

text
5
HELLO
[Message] hello

If you'll need to treat all results as the same type going forward, unify the return type (for example, change everything to Transformer<String, String>) rather than frequently casting to Object.

7. Fully runnable version

Now use the following complete version to replace the contents of LambdaLab.java. It bundles the earlier key exercises and assertions together:

java
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;

public class LambdaLab {
    public static void main(String[] args) {
        List<String> words = List.of("cat", "tiger", "elephant", "giraffe", "dog");
        List<String> longWords = words.stream()
                .filter(word -> word.length() > 3)
                .map(word -> word.toUpperCase(java.util.Locale.ROOT))
                .sorted()
                .toList();
        checkEquals(List.of("ELEPHANT", "GIRAFFE", "TIGER"), longWords);

        Runnable task = LambdaLab::announceTask;
        task.run();

        Function<String, Integer> parser = Integer::parseInt;
        checkEquals(42, parser.apply("42"));

        List<Participant> participants = List.of(
                new Participant("Zhang San", 25, "Basics"),
                new Participant("Li Si", 17, "Basics"),
                new Participant("Wang Wu", 30, "Algorithms"),
                new Participant("Zhao Liu", 22, "Basics"));

        List<String> names = participants.stream()
                .filter(participant -> participant.team().equals("Basics"))
                .filter(participant -> participant.age() >= 18)
                .sorted(Comparator.comparingInt(Participant::age))
                .map(Participant::name)
                .toList();
        checkEquals(List.of("Zhao Liu", "Zhang San"), names);

        double averageAge = participants.stream()
                .mapToInt(Participant::age)
                .average()
                .orElse(0.0);
        checkEquals(23.5, averageAge);

        Transformer<String, Integer> toLength = String::length;
        Transformer<String, String> toUpper = text -> text.toUpperCase(java.util.Locale.ROOT);
        Transformer<String, String> withPrefix = text -> "[Message] " + text;
        List<Transformer<String, ?>> transformations =
                List.of(toLength, toUpper, withPrefix);

        for (Transformer<String, ?> transformation : transformations) {
            System.out.println(transformation.transform("hello"));
        }

        System.out.println("All checks passed");
    }

    static void announceTask() {
        System.out.println("Task execution in progress");
    }

    static void checkEquals(Object expected, Object actual) {
        if (!expected.equals(actual)) {
            throw new AssertionError("expected=" + expected + ", actual=" + actual);
        }
    }

    @FunctionalInterface
    interface Transformer<T, R> {
        R transform(T input);
    }

    record Participant(String name, int age, String team) { }
}

Recompile and run:

bash
javac --release 17 -encoding UTF-8 -Xlint:all -d out LambdaLab.java
java -cp out LambdaLab

Expected output:

text
Task execution in progress
5
HELLO
[Message] hello
All checks passed

8. Additional Boundary Cases

After the main exercise passes, verify each boundary case separately:

  1. Set the participants list to an empty list and verify whether the default average age value aligns with business requirements;
  2. Add duplicate names, confirm whether distinct() preserves first-encounter order, and decide whether the application needs that behavior;
  3. Create the string list with java.util.Arrays.asList(...), which permits null (List.of rejects it at creation), then decide at the source boundary whether to reject, filter, or transform, without letting NPE accidentally determine the strategy;
  4. Call add on the result of toList() and observe the unmodifiable contract;
  5. Save a Stream and perform two terminal operations, confirming that the second consumption will fail;
  6. Without changing the code's semantics, replace readable lambdas with method references, then verify if the result is indeed clearer.

Before leaving the lab

Confirm that the complete program's output and assertions pass, then explicitly describe why filtering, sorting, and transformation are ordered as they are. The empty mean, heterogeneous conversion, and toList()'s unmodifiable result must have clear contracts; map, filter, and peek must not silently modify external business state.

Keep the full-list assertions and failure cases. They will expose behavior changes when you later rearrange filtering or sorting.

Clean up after saving the experiment log:

bash
cd
ls -ld -- "$chapter8_lambda_root"
rm -r -- "$chapter8_lambda_root"

Next step: String basics

Built with VitePress | Software Systems Atlas