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:
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:
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:
javac --release 17 -encoding UTF-8 -d out LambdaLab.java
java -cp out LambdaLab2. 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.
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:
[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:
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:
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.
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
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:
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:
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:
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:
[Zhao Liu, Zhang San]
Average age: 23.5Two 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:
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:
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:
5
HELLO
[Message] helloIf 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:
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:
javac --release 17 -encoding UTF-8 -Xlint:all -d out LambdaLab.java
java -cp out LambdaLabExpected output:
Task execution in progress
5
HELLO
[Message] hello
All checks passed8. Additional Boundary Cases
After the main exercise passes, verify each boundary case separately:
- Set the participants list to an empty list and verify whether the default average age value aligns with business requirements;
- Add duplicate names, confirm whether
distinct()preserves first-encounter order, and decide whether the application needs that behavior; - Create the string list with
java.util.Arrays.asList(...), which permits null (List.ofrejects it at creation), then decide at the source boundary whether to reject, filter, or transform, without letting NPE accidentally determine the strategy; - Call
addon the result oftoList()and observe the unmodifiable contract; - Save a Stream and perform two terminal operations, confirming that the second consumption will fail;
- 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:
cd
ls -ld -- "$chapter8_lambda_root"
rm -r -- "$chapter8_lambda_root"