8.8 实验:Lambda 与 Stream
前置知识:Lambda、方法引用、Stream API
预计时间:35 分钟
运行环境:Java 17
完成标志:独立完成练习,并能用固定输入验证结果
先写验收结果,再接流水线
每完成一步就打印或断言结果,确认顺序、空输入和重复数据都符合预期。让流水线短一点很容易,让它交出正确名单更有用。
1. 建立实验文件
先为实验准备独立目录:
chapter8_lambda_root=$(mktemp -d)
readonly chapter8_lambda_root
mkdir -p "$chapter8_lambda_root/out"
cd "$chapter8_lambda_root"新建 LambdaLab.java,先放入骨架:
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
public class LambdaLab {
public static void main(String[] args) {
// 练习代码写在这里
}
@FunctionalInterface
interface Transformer<T, R> {
R transform(T input);
}
record Participant(String name, int age, String team) { }
}编译命令固定使用 Java 17:
javac --release 17 -encoding UTF-8 -d out LambdaLab.java
java -cp out LambdaLab2. 热身一:字符串流水线
目标是从单词列表中筛出长度大于 3 的项,转成大写,排序并收集:
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);预期输出:
[ELEPHANT, GIRAFFE, TIGER]大写转换显式使用 Locale.ROOT,避免机器的默认区域设置改变测试结果,例如土耳其语中的 i。检查顺序时不要只看元素是否都在。sorted() 是需求的一部分,测试也应检查整个列表。
3. 热身二:匿名内部类改成 Lambda
先看原始写法:
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("任务执行中");
}
};函数方法没有参数也没有返回值,可以改成:
Runnable task = () -> System.out.println("任务执行中");
task.run();这段代码不能在保持原行为的同时直接写成 System.out::println,因为 println() 有无参重载,却会输出空行,不包含“任务执行中”。若先把有含义的动作提取成方法,才可引用:
static void announceTask() {
System.out.println("任务执行中");
}
Runnable task = LambdaLab::announceTask;方法引用应保持原行为,不能只追求出现 ::。
4. 热身三:选择合适的方法引用
Function<String, Integer> parser = Integer::parseInt;
Function<String, String> clean = String::strip;
Comparator<Participant> byAge = Comparator.comparingInt(Participant::age);逐个说出它们的目标类型。若删除左边的类型,方法引用通常就失去了足够的上下文。
再试一个失败输入:
try {
parser.apply("十二");
} catch (NumberFormatException exception) {
System.out.println("无法解析年龄:十二");
}Lambda 和方法引用不会改变异常契约。解析失败仍要在合适边界处理。
5. 挑战一:生成用户报告
输入固定如下:
List<Participant> participants = List.of(
new Participant("张三", 25, "基础组"),
new Participant("李四", 17, "基础组"),
new Participant("王五", 30, "算法组"),
new Participant("赵六", 22, "基础组"));先完成两个彼此清楚的查询:
List<String> adultNamesInTeam = participants.stream()
.filter(participant -> participant.team().equals("基础组"))
.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, "平均年龄:%.1f%n", averageAge);预期输出:
[赵六, 张三]
平均年龄:23.5两次遍历并不等于错误。对内存中的小列表,两条独立流水线往往比强行塞进一次复杂收集更易读。只有测量表明遍历成本重要,或来源确实只能消费一次时,才考虑复合收集。
此进阶片段还需 import java.util.stream.Collectors;。Java 12 起可用 Collectors.teeing 同时得到两种结果,但它属于进阶工具:
record Report(List<String> names, double averageAge) { }
Report report = participants.stream().collect(Collectors.teeing(
Collectors.filtering(
participant -> participant.team().equals("基础组")
&& participant.age() >= 18,
Collectors.mapping(Participant::name, Collectors.toList())),
Collectors.averagingInt(Participant::age),
Report::new));这段复合收集没有按年龄排序。若业务要求顺序稳定,就要在收集器或结果阶段明确排序;“只遍历一次”不会自动满足所有语义。
6. 挑战二:自定义函数式接口
不同转换的返回类型不同,不能把它们硬塞进 List<Transformer<String, String>>。使用通配符表达“输入都是 String,输出类型各自不同”。Transformer<T, R> 中 T 表示输入类型,R 表示输出类型;? 表示这里暂时不知道的输出类型,详细规则在第12章展开:
Transformer<String, Integer> toLength = String::length;
Transformer<String, String> toUpper = text -> text.toUpperCase(java.util.Locale.ROOT);
Transformer<String, String> withPrefix = text -> "[消息] " + text;
List<Transformer<String, ?>> transformations =
List.of(toLength, toUpper, withPrefix);
for (Transformer<String, ?> transformation : transformations) {
System.out.println(transformation.transform("hello"));
}预期输出:
5
HELLO
[消息] hello如果后续必须把所有结果当作同一种类型继续处理,就应统一返回类型,例如全部改成 Transformer<String, String>,而不是频繁强制转换 Object。
7. 完整可运行版本
现在用下面的完整版本覆盖同一个 LambdaLab.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("张三", 25, "基础组"),
new Participant("李四", 17, "基础组"),
new Participant("王五", 30, "算法组"),
new Participant("赵六", 22, "基础组"));
List<String> names = participants.stream()
.filter(participant -> participant.team().equals("基础组"))
.filter(participant -> participant.age() >= 18)
.sorted(Comparator.comparingInt(Participant::age))
.map(Participant::name)
.toList();
checkEquals(List.of("赵六", "张三"), 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 -> "[消息] " + text;
List<Transformer<String, ?>> transformations =
List.of(toLength, toUpper, withPrefix);
for (Transformer<String, ?> transformation : transformations) {
System.out.println(transformation.transform("hello"));
}
System.out.println("全部检查通过");
}
static void announceTask() {
System.out.println("任务执行中");
}
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) { }
}重新编译并运行:
javac --release 17 -encoding UTF-8 -Xlint:all -d out LambdaLab.java
java -cp out LambdaLab预期输出:
任务执行中
5
HELLO
[消息] hello
全部检查通过8. 追加边界用例
完成主线后,再逐项验证:
- 把参与者列表设为空,验证默认平均年龄是否符合业务要求;
- 加入重复名称,确认
distinct()是否保留首次出现顺序,并判断这是不是业务需要; - 用允许 null 的
java.util.Arrays.asList(...)构造字符串列表(List.of会先拒绝 null),决定在来源边界拒绝、过滤还是转换,不能让 NPE 偶然决定策略; - 尝试给
toList()的结果add元素,观察不可修改契约; - 保存一条 Stream 并执行两个终端操作,确认第二次消费会失败;
- 不修改代码语义的前提下,把可读的 Lambda 替换为方法引用,再判断是否真的更清楚。
离开实验室前
确认完整程序的输出与断言都通过,再逐条说明过滤、排序、转换为什么按当前顺序排列。空平均值、异构转换和 toList() 的不可修改结果都要有明确契约;map、filter 与 peek 中不要悄悄修改外部业务状态。
保留完整列表断言和失败用例。以后调整过滤或排序顺序时,它们能指出行为是否已经改变。
保存实验记录后清理:
cd
ls -ld -- "$chapter8_lambda_root"
rm -r -- "$chapter8_lambda_root"