1. 命令式 vs 函数式风格
先从一个简单的问题开始:为什么需要“函数式风格”?它相比我们习惯的基于循环的写法有什么优势?在 Java 中,“函数式风格”到底指什么?
命令式风格
命令式风格是你告诉计算机如何一步一步地完成任务。比如,需要从字符串列表得到其长度列表,只保留奇数长度并按降序排序,你可能会这样写:
List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
这里我们显式创建了中间列表、手动添加元素、排序——一切都按步骤来。
函数式风格
函数式风格是描述你想要什么结果,而不是如何实现。在 Java 中,这通过 Stream API 实现:
List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
这看起来就像在搭建一个数据处理“流水线”:先把单词变成长度(map),再过滤出奇数(filter),然后排序(sorted)。全部在一条链路中完成,没有显式的中间集合和循环。
对比:迭代式 vs 函数式风格
在传统的命令式写法中,我们写一个循环,在循环体中逐步告诉计算机该做什么:遍历每个元素、检查条件、添加到新列表或打印到控制台。代码能跑,但行数更多,任务越复杂,阅读和维护就越困难。
函数式风格让你描述目标而不是过程。我们不写冗长的循环,而是搭建操作链:过滤、转换、收集结果。这样更短、更直观,并且因为减少了对可变集合的“手工操作”,也更不容易出错。
当然也有另一面。对新手来说,这样的链式调用可能显得晦涩:多个 lambda 连在一起有时不如简单循环好读。所以函数式在简洁和表达力上占优,但也需要一点习惯和经验积累。
2. Stream API 的基础操作
Stream API 不是“新型循环”,而是一整套以函数式风格处理集合的工具箱。我们来看看核心操作。
如何得到一个 Stream?
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
中间操作
- map — 转换流中的元素
- filter — 按条件过滤元素
- flatMap — 将每个元素变成一个流并“展开”
- sorted — 排序
- distinct — 去重
- limit / skip — 限制/跳过元素
终端操作
- forEach — 对每个元素执行操作
- collect — 将结果收集为集合
- reduce — 将流归约为一个值(例如求和)
- count — 统计元素数量
- anyMatch、allMatch、noneMatch — 条件检查
示例:处理链
List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [BORIS, VIKA, DASHA]
“流水线”的可视化示意:
[Anna, Boris, Vika, Gleb, Dasha]
| filter (length>3)
[Boris, Vika, Dasha]
| map (toUpperCase)
[BORIS, VIKA, DASHA]
| sorted
[BORIS, VIKA, DASHA]
| toList
每个操作都不会修改原始集合——而是产生一个新的流。
3. 流式执行与惰性求值
中间操作与终端操作
Stream 有两类操作。第一类是中间操作,比如 map、filter 或 sorted。它们返回一个新流,看似承诺要做什么,但实际上尚未执行。第二类是终端操作,比如 forEach、collect 或 reduce。这些才会真正触发整个处理流程。关键点是,只要你没调用终端操作,流就保持“惰性”——计算不会开始。
示例:
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("调用 forEach 之前");
stream.forEach(System.out::println);
输出:
调用 forEach 之前
map: a
A
map: bb
BB
map: ccc
CCC
可以看到,在开始 forEach 之前,map 并不会执行。
为什么这很棒?
这种方式可以毫无负担地构建任意长的转换链。只有当确实需要时,流才会开始工作。由此我们可以轻松处理超大数据量,甚至是无限序列。而且惰性还帮助节省内存并更高效地执行计算。
4. 实战:任务“字符串 → 长度 → 只保留奇数 → 按降序”
我们一步步解决这个任务:“从字符串列表得到长度列表,只保留奇数长度,并按降序排序。”
命令式解法
List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> lengths = new ArrayList<>();
for (String word : words) {
int len = word.length();
if (len % 2 != 0) {
lengths.add(len);
}
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]
使用 Stream API 的函数式解法
List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> result = words.stream()
.map(String::length) // 将字符串转换为其长度
.filter(len -> len % 2 != 0) // 只保留奇数长度
.sorted(Comparator.reverseOrder()) // 按降序排序
.toList(); // 收集为 List(Java 16+)
System.out.println(result); // [7, 5, 3]
说明:
- map(String::length) — 针对每个字符串获取其长度。
- filter(len -> len % 2 != 0) — 只保留奇数长度。
- sorted(Comparator.reverseOrder()) — 按降序排序。
- toList() — 将流收集为新列表。
类比
就像在工厂搭建传送带:每个阶段对零件进行新的处理,直到最后才把所有东西装进盒子。
5. 更多示例:map、filter、forEach、collect
示例 1:过滤并打印
List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");
names.stream()
.filter(name -> name.contains("a"))
.forEach(System.out::println);
// 将输出:Anna, Dasha
示例 2:转换并收集到 Set
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]
示例 3:获取所有字符串长度之和
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("总长度: " + totalLength);
示例 4:使用 Predicate 和 Function
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "你好," + name + "!";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// 你好,Boris!
// 你好,Dasha!
6. 使用 Stream API 的风格要点
避免可变状态
Stream API 倡导“纯函数”——没有副作用。这意味着不要在 lambda 中修改外部变量。
不推荐:
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(result::add); // side-effect!
更好:
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.toList();
组合操作
可以把 map、filter、sorted 等方法组合成很长的链。关键是别滥用:如果链长到超出一屏,可能需要拆分成若干部分。
惰性求值
在到达终端操作之前,Stream API 不会做任何事。这有助于节省资源并构建高效的数据处理流水线。
不修改原始集合
Stream 不会修改原集合!所有变换都会返回新的流/集合。
7. 何时使用 Stream API
Stream API 很适合在以下场景:
- 需要快速处理集合(过滤、转换、排序)。
- 希望代码简洁、可读。
- 不想手动创建中间集合。
- 希望轻松增加并行性(通过 parallelStream())。
命令式风格有时更合适,例如:
- 需要复杂逻辑,有多个嵌套循环和条件。
- 在关键路径上追求极致性能(Stream API 有时稍慢)。
- 需要处理可变状态(例如“就地”更新元素)。
8. 使用 Stream API 的常见错误
错误 1:用 forEach 来收集集合。 很多新手用 forEach 向新集合添加元素。这不是函数式风格!请改用 collect 或 toList()。
错误 2:过早优化。 不要一上来就用 parallelStream() —— 只有在数据确实很大且任务是 CPU 密集型时,才需要并行。
错误 3:将 Stream API 与可变集合混用。 Stream API 倾向于不变数据。不要在 lambda 中修改集合元素。
错误 4:结果被“丢失”。 忘了调用终端操作——什么也不会发生。
错误 5:lambda 过于复杂。 如果一个 lambda 超过一两行,请把它提取到具名方法中。
GO TO FULL VERSION