跳到内容

第 1 卷 / 第 8 章 / 第 8 课

8.8 实验:Lambda 与 Stream ​

前置知识:Lambda、方法引用、Stream API

预计时间:35 分钟

运行环境:Java 17

完成标志:独立完成练习,并能用固定输入验证结果

先写验收结果,再接流水线 ​

每完成一步就打印或断言结果,确认顺序、空输入和重复数据都符合预期。让流水线短一点很容易,让它交出正确名单更有用。

1. 建立实验文件 ​

先为实验准备独立目录:

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

新建 LambdaLab.java,先放入骨架:

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:

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

2. 热身一:字符串流水线 ​

目标是从单词列表中筛出长度大于 3 的项,转成大写,排序并收集:

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);

预期输出:

text
[ELEPHANT, GIRAFFE, TIGER]

大写转换显式使用 Locale.ROOT,避免机器的默认区域设置改变测试结果,例如土耳其语中的 i。检查顺序时不要只看元素是否都在。sorted() 是需求的一部分,测试也应检查整个列表。

3. 热身二:匿名内部类改成 Lambda ​

先看原始写法:

java
Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("任务执行中");
    }
};

函数方法没有参数也没有返回值,可以改成:

java
Runnable task = () -> System.out.println("任务执行中");
task.run();

这段代码不能在保持原行为的同时直接写成 System.out::println,因为 println() 有无参重载,却会输出空行,不包含“任务执行中”。若先把有含义的动作提取成方法,才可引用:

java
static void announceTask() {
    System.out.println("任务执行中");
}

Runnable task = LambdaLab::announceTask;

方法引用应保持原行为,不能只追求出现 ::。

4. 热身三:选择合适的方法引用 ​

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

逐个说出它们的目标类型。若删除左边的类型,方法引用通常就失去了足够的上下文。

再试一个失败输入:

java
try {
    parser.apply("十二");
} catch (NumberFormatException exception) {
    System.out.println("无法解析年龄:十二");
}

Lambda 和方法引用不会改变异常契约。解析失败仍要在合适边界处理。

5. 挑战一:生成用户报告 ​

输入固定如下:

java
List<Participant> participants = List.of(
        new Participant("张三", 25, "基础组"),
        new Participant("李四", 17, "基础组"),
        new Participant("王五", 30, "算法组"),
        new Participant("赵六", 22, "基础组"));

先完成两个彼此清楚的查询:

java
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);

预期输出:

text
[赵六, 张三]
平均年龄:23.5

两次遍历并不等于错误。对内存中的小列表,两条独立流水线往往比强行塞进一次复杂收集更易读。只有测量表明遍历成本重要,或来源确实只能消费一次时,才考虑复合收集。

此进阶片段还需 import java.util.stream.Collectors;。Java 12 起可用 Collectors.teeing 同时得到两种结果,但它属于进阶工具:

java
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章展开:

java
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"));
}

预期输出:

text
5
HELLO
[消息] hello

如果后续必须把所有结果当作同一种类型继续处理,就应统一返回类型,例如全部改成 Transformer<String, String>,而不是频繁强制转换 Object。

7. 完整可运行版本 ​

现在用下面的完整版本覆盖同一个 LambdaLab.java。它把前面的关键练习和断言收在一起:

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) { }
}

重新编译并运行:

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

预期输出:

text
任务执行中
5
HELLO
[消息] hello
全部检查通过

8. 追加边界用例 ​

完成主线后,再逐项验证:

  1. 把参与者列表设为空,验证默认平均年龄是否符合业务要求;
  2. 加入重复名称,确认 distinct() 是否保留首次出现顺序,并判断这是不是业务需要;
  3. 用允许 null 的 java.util.Arrays.asList(...) 构造字符串列表(List.of 会先拒绝 null),决定在来源边界拒绝、过滤还是转换,不能让 NPE 偶然决定策略;
  4. 尝试给 toList() 的结果 add 元素,观察不可修改契约;
  5. 保存一条 Stream 并执行两个终端操作,确认第二次消费会失败;
  6. 不修改代码语义的前提下,把可读的 Lambda 替换为方法引用,再判断是否真的更清楚。

离开实验室前 ​

确认完整程序的输出与断言都通过,再逐条说明过滤、排序、转换为什么按当前顺序排列。空平均值、异构转换和 toList() 的不可修改结果都要有明确契约;map、filter 与 peek 中不要悄悄修改外部业务状态。

保留完整列表断言和失败用例。以后调整过滤或排序顺序时,它们能指出行为是否已经改变。

保存实验记录后清理:

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

→ 下一步:字符串基础

Built with VitePress | Software Systems Atlas