Java 8 里最让我觉得“改了一个语法,整个代码风格都变了”的,就是 Lambda 表达式。坦白说,我刚开始接触它的时候并没有太多好感——Java 一向以“啰嗦但明确”著称,匿名内部类写起来虽然难看,但功能上也没差太多。真正让我改变看法的,是我用 Stream 操作集合之后:原本十几行的过滤、排序、分组,缩成了三五行可读性还更高的流水线。从那时候起,Lambda 就不再是“语法糖”这么简单了,它实际上是 Java 从“面向对象”走向“函数式思想”的敲门砖。
这篇文章适合三类人:正在学 Java 基础、准备 Java 面试的初学者;工作中天天写集合处理、对传统 for 循环情有独钟的 CRUD 开发者;以及想搞懂函数式接口、方法引用、Stream 底层逻辑的中级工程师。我会从“为什么需要 Lambda”讲起,把语法、函数式接口、排序实战、变量捕获、常见坑全部过一遍,最后再整理一份高频面试题。代码都是可以直接抄走验证的,你不用翻别的资料就能把这一个主题吃透。
1. Lambda 表达式到底解决了什么问题
1.1 从匿名内部类的痛点说起
在 Java 8 之前,如果我想给一个 List 排序,写出来的代码大概是这样的:
List<User> users = getUsers(); Collections.sort(users, new Comparator<User>() { @Override public int compare(User u1, User u2) { return Integer.compare(u1.getAge(), u2.getAge()); } });这段代码最大的问题不是“不能工作”,而是“噪音太多”。真正有意义的只有Integer.compare(u1.getAge(), u2.getAge())这一行,其余全是围绕 Java 类结构不得不写的套话。更麻烦的是,这种匿名内部类在调试、堆栈跟踪、代码阅读时都会造成干扰,你会盯着new Comparator<User>()想半天:“它到底想干什么?”
Lambda 表达式把同样的逻辑缩成这样一行:
users.sort((u1, u2) -> Integer.compare(u1.getAge(), u2.getAge()));注意这不仅是“少写了几行字”。它的本质是把“行为”本身作为一个参数传给了sort方法——你告诉方法“拿什么规则去比”,而不是写一个完整的类。这个思想转变,才是 Lambda 真正的价值所在。
1.2 行为参数化:把方法变成数据
传统的 Java 里,数据可以传来传去,但“行为”很难直接传递。你如果想实现一个“按任意条件筛选用户”的方法,通常得定义一堆接口、写一堆实现类,或者引入策略模式。Java 8 之前这样做是可行的,但样板代码多到让人不想用。
Lambda 出现之后,“行为参数化”变得几乎零成本。举例来说,我要筛选出所有成年人:
public static List<User> filter(List<User> users, Predicate<User> predicate) { List<User> result = new ArrayList<>(); for (User user : users) { if (predicate.test(user)) { result.add(user); } } return result; }调用的时候,我是把一个“判定行为”直接传进去:
List<User> adults = filter(users, u -> u.getAge() >= 18);这里的u -> u.getAge() >= 18就是一个谓词,它本身不是一个类、不是一个对象,而是一段可传递的逻辑。你可以继续传u -> "北京".equals(u.getCity()),方法体完全不用变。这在老版本 Java 里得写多少个匿名内部类才能实现呢?思想上的进步远比语法上的变化重要。
1.3 为什么 Java 8 才引入 Lambda
很多人会问:Python、C# 老早就有 lambda 了,为什么 Java 拖到 2014 年才出?核心原因是 Java 对兼容性的执念,以及对“显式优于隐式”这条设计原则的坚持。C# 的 lambda 依赖语言与底层运行时的同步演进,而 Java 必须保证老的.class文件还能在新 JVM 上跑,老的 JVM 也能读新的字节码(至少要保持一段时间的兼容窗口)。
Java 8 的解决方案很有意思:它没有在字节码层新增任何“lambda 指令”,而是引入了invokedynamic指令配合LambdaMetafactory,在运行时动态生成函数式接口的实现。也就是说,u -> u.getAge() >= 18编译后并不像匿名内部类那样在 class 文件里静态生成一个.class,而是留一个动态调用点,由 JVM 在首次执行时把 Lambda 体“拼接”成目标接口的实现。这带来一个直接结果:多次调用不会重复创建匿名类,性能上比老式的匿名内部类甚至更有优势。这个解题思路,后来也成了各大静态语言引入函数式语法的参考模板。
2. Lambda 语法与函数式接口
2.1 六种语法形态,一次记清楚
Lambda 的完整语法是:(参数列表) -> { 方法体 },箭头左侧是参数,右侧是执行逻辑。实际使用中会有六种简化形态,我从最简单到最复杂列一遍:
// 1. 无参无返回值 Runnable r1 = () -> System.out.println("hello"); // 2. 一个参数,省略括号 Consumer<String> c1 = s -> System.out.println(s); // 3. 两个参数 BinaryOperator<Integer> add = (a, b) -> a + b; // 4. 显式声明参数类型 BinaryOperator<Integer> addExplicit = (Integer a, Integer b) -> a + b; // 5. 块体,带返回值 Function<Integer, Integer> square = (x) -> { int result = x * x; return result; }; // 6. 块体,带日志和流程控制 Consumer<String> logAndPrint = s -> { if (s != null && !s.isEmpty()) { System.out.println("处理消息: " + s); } };关键又不难理解的是:参数类型绝大多数情况下可以省略,因为编译器会根据“目标类型”自动推断。什么叫目标类型?就是你赋值给的那个函数式接口。BinaryOperator<Integer>已经告诉你两个参数都是Integer,所以(a, b) -> a + b里的类型完全不用写。只有类型推断失败的时候,才需要显式补上类型,这种场景我后面会讲。
2.2 函数式接口:Lambda 的“类型身份证”
Lambda 本身没有类型,它必须落在某个函数式接口上才能编译通过。所谓函数式接口,就是“有且仅有一个抽象方法”的接口,业界常简写成 SAM(Single Abstract Method)。
我举个例子:
@FunctionalInterface public interface MyChecker<T> { boolean check(T value); }@FunctionalInterface注解是可选的,但强烈建议保留。它的作用有两个:一是告诉编译器帮我校验,这个接口到底是不是标准函数式接口;二是给读代码的人传递信息——这个接口就是给 Lambda 当靶子用的。如果你在注解修饰的接口里再加一个抽象方法,编译会直接报错:
@FunctionalInterface public interface MyChecker<T> { boolean check(T value); void print(); // 编译错误:不是函数式接口 }有两点容易混淆,我特意说明一下。第一,接口里的default方法和static方法不算抽象方法;第二,如果接口里声明了equals、toString、hashCode这些来自Object的方法,也不计入抽象方法总数。一个接口即使声明了十个 default 方法,只要只有一个抽象方法,它仍然是函数式接口。
为什么只能有一个抽象方法?道理很简单——Lambda 是一段没有名字的代码,编译器必须知道“这段代码要对应到哪个方法”。如果一个接口有两个抽象方法,编译器就不知道该把() -> System.out.println("x")映射给谁了。
2.3 内置函数式接口:四大金刚与它们的亲戚
实际开发中很少有人自己定义函数式接口,因为 Java 8 的java.util.function包已经把绝大多数场景覆盖了。我先记住这六个最常用的,其他都靠派生的很类似。
| 接口 | 入参 | 返回值 | 典型使用场景 |
|---|---|---|---|
Consumer<T> | T | void | 遍历打印、消息通知 |
Supplier<T> | 无 | T | 工厂方法、延迟加载 |
Function<T, R> | T | R | 类型转换、字段提取 |
Predicate<T> | T | boolean | 过滤条件、逻辑断言 |
UnaryOperator<T> | T | T | 同类型加工,如自增 |
BinaryOperator<T> | T, T | T | 两数相加、求最大值合并 |
代码里一眼就能看出用法:
Consumer<String> print = s -> System.out.println(s); Supplier<LocalDate> today = LocalDate::now; Function<String, Integer> strLen = String::length; Predicate<Integer> isAdult = age -> age >= 18; UnaryOperator<Integer> increment = x -> x + 1; BinaryOperator<Integer> sum = (a, b) -> a + b;还有一批带前缀的变体,比如BiConsumer<T, U>(两个入参无返回值)、BiFunction<T, U, R>(两个入参有返回值)、IntFunction<R>(入参是原始类型 int)。这些变体存在的唯一理由是性能:尽量避免基本类型和包装类型之间的自动装箱。如果你要对大量 int 做操作,用IntPredicate会比Predicate<Integer>快不少,这一点在写高性能代码时很重要。
3. 核心实操:用 Lambda 彻底改造集合处理
3.1 排序:从匿名类到 Comparator.comparing
排序是 Lambda 最高频的应用场景之一。刚入门的时候,很多人习惯这样写:
list.sort((u1, u2) -> u1.getAge() - u2.getAge());这样写能跑,但有两个隐患。第一,u1.getAge() - u2.getAge()在极端数值下会溢出;第二,逻辑仍然有点绕。更推荐的做法是使用Comparator.comparing:
// 按年龄自然升序 list.sort(Comparator.comparing(User::getAge)); // 按年龄降序 list.sort(Comparator.comparing(User::getAge).reversed()); // 名称可能为 null 时,把 null 排到最后 list.sort(Comparator.comparing(User::getName, Comparator.nullsLast(String::compareTo))); // 多条件排序:先按年龄,再按姓名 list.sort(Comparator.comparing(User::getAge).thenComparing(User::getName));Comparator.comparing的思路很聪明:你只需要告诉它“取哪个字段”,它就能基于该方法引用生成一个比较器。它本身接收一个Function<T, R>,返回值是Comparator<T>,这个“函数式接口与静态工厂结合”的模式,几乎处处可见。
顺手把热词里的“冒泡排序 java”也聊一下。传统冒泡排序解决的是“怎么排”的算法问题:
for (int i = 0; i < arr.length - 1; i++) { for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } }而 Lambda 解决的是“拿什么比”的策略问题。两者并不冲突:冒泡排序关心过程,Comparator 关心规则。在面试里被问到“用 Lambda 实现排序”,最佳答案通常是把这两点分清楚,然后给出Comparator.comparing(...).thenComparing(...)的完整链式写法,这比手写一堆循环更能体现功底。
3.2 Stream 流水线:过滤、映射、收集一条龙
Lambda 真正的用武之地是配合 Stream API。来看一个最常见的场景:找出所有成年人,按年龄排序,取出前 5 个,只要姓名。
List<String> names = users.stream() .filter(u -> u.getAge() >= 18) .sorted(Comparator.comparing(User::getAge)) .limit(5) .map(User::getName) .collect(Collectors.toList());这四行代码做了四件事:过滤、排序、截断、提取字段。换成传统的 for 循环,至少要写七八行,还要引入两三个临时变量。Stream 的写法读起来像一句自然语言“先过滤,再排序,然后取前五个,最后收集姓名列表”,逻辑链路是完整的。
再来一个分组操作,这个在业务报表里太常用了:
Map<String, List<User>> usersByCity = users.stream() .collect(Collectors.groupingBy(User::getCity));一行代码完成“按城市分组”。如果你想知道每个城市的平均年龄:
Map<String, Double> avgAgeByCity = users.stream() .collect(Collectors.groupingBy(User::getCity, Collectors.averagingInt(User::getAge)));这些都不是 Java 6 时代做不到,而是那时候做起来要写大量样板代码,很多开发者宁可不用。Stream + Lambda 把表达成本压到极低之后,开发者才愿意去用,也才慢慢体会到函数式风格处理数据的好处。有一点要特别记住:Stream 的中间操作(filter、map、sorted)都是惰性的,只有终止操作(collect、forEach、reduce)触发时才开始真正执行。这个特性在调试时影响很大,我后面会专门讲。
3.3 方法引用:Lambda 的快捷写法
方法引用可以被看成是“别人已经写好的 Lambda”。它一共有四种形式,我整理成一张表:
| 形式 | 语法 | 示例 | 等价的 Lambda |
|---|---|---|---|
| 静态方法引用 | 类::静态方法 | Integer::parseInt | s -> Integer.parseInt(s) |
| 实例方法引用(特定对象) | 对象::实例方法 | System.out::println | s -> System.out.println(s) |
| 实例方法引用(类型) | 类::实例方法 | User::getAge | u -> u.getAge() |
| 构造器引用 | 类::new | User::new | () -> new User() |
第三种形式容易让人困惑:User::getAge明明是一个实例方法,为什么直接用了类名?这是因为这里的规则是“把第一个参数当成调用对象,后面的参数作为方法入参”。User::getAge等价于(User u) -> u.getAge(),编译器知道第一个参数是 User,所以直接用类名引用。
什么时候用方法引用,什么时候写 Lambda?我的习惯是:如果 Lambda 体里面只是一次方法调用、没有额外的计算逻辑,就使用方法引用;如果方法体里还要做判断、拼接、异常处理,就写 Lambda。举例来说,u -> u.getName()直接改成User::getName;u -> "用户:" + u.getName()这种就别硬套方法引用了,写成 Lambda 更清楚。
4. 变量捕获、作用域与 this 的坑
4.1 effectively final 到底是什么意思
Lambda 可以访问外部局部变量,但有一个经典限制:这个变量必须是“实际上不可变的”。从 Java 8 开始,规则从“必须声明为 final”放宽成了“没重新赋值就行”,这种变量叫 effectively final。先看能编译的代码:
int base = 100; Function<Integer, Integer> addBase = x -> x + base;base没有 final 修饰,但代码里没有再对它赋值,所以编译器默认它有 final 的语义。只要你在后面写了base = 200;,编译立刻报错。为什么要有这个限制?因为 Lambda 本质上是把一个“值快照”传出去执行,而不是传一个引用。设计者的考虑是:如果允许 Lambda 内部看到外部变量的变化,那这个变量的生命周期、线程安全、逃逸分析都会变得非常复杂;干脆要求你“捕获的时候是什么值,执行的时候就是什么值”,简单又安全。
有一种常见操作是“我想在 Lambda 里改外部变量”,比如统计个数:
int count = 0; list.forEach(s -> count++); // 编译错误正确地绕开方式是使用AtomicInteger或者一个数组容器:
int[] count = {0}; list.forEach(s -> count[0]++);但我必须提醒:这种写法只是绕过了编译器的限制,并没有改变“状态共享”的事实。在多线程环境下,直接用数组容器计数依然存在数据竞争,该用AtomicInteger或加锁还得用。更推荐的做法是尽量不要在流操作里修改外部状态,而是用map、reduce、collect把结果算出来:
int count = list.stream().mapToInt(s -> 1).sum();这也是函数式风格的初衷:操作应该是无副作用的。
4.2 lambda 里的 this 到底指向谁
这是 Lambda 与匿名内部类最大的区别之一,也是面试高频题。在匿名内部类里,this指向匿名类对象本身;但在 Lambda 里,this指向的是外围类的实例。看这段代码就明白了:
public class Demo { private String name = "Outer"; public void test() { Runnable lambda = () -> System.out.println(this.name); Runnable anon = new Runnable() { private String name = "Inner"; @Override public void run() { System.out.println(this.name); } }; lambda.run(); // 打印 Outer anon.run(); // 打印 Inner } }为什么会这样?因为 Lambda 不会创建新的作用域,它不是匿名的“类”,而是外围方法里的一段逻辑。它没有自己的实例,自然this仍然是外围对象。这个特性在实际项目中影响很大:比如你在事件监听、线程执行时用 Lambda 访问外围类的成员变量,一切正常;但同样的代码改成匿名内部类,就要小心this是否被换成了内部类实例,否则容易取到不是你预期的东西。
4.3 作用域遮蔽:Lambda 里不能出现同名局部变量
既然 Lambda 不创建新的作用域,那么 Lambda 参数名就不能与外层局部变量重名。举个例子:
String name = "local"; Function<String, Integer> f = name -> name.length(); // 编译错误这里外层已经有一个局部变量name,Lambda 参数又声明了一个name,编译器就会报“变量 name 已经定义”。解决办法是给参数换个名字:
String name = "local"; Function<String, String> f = input -> input + "-" + name;匿名内部类则没有这个问题,它内部可以再定义同名变量遮蔽外部变量。这个细微差别在阅读别人的代码时偶尔会造成困惑,知道原理后就能理解了:Lambda 就是“外围代码的延续”,不是一个独立的结构体。
5. 常见问题与排查技巧实录
5.1 高频编译错误速查
我把自己过去几年读到的、写过的、还有帮同事排查过的 Lambda 编译错误整理成了下面这张表,基本涵盖了初学者到中级开发最常见的报错场景。
| 报错信息 | 根本原因 | 解决方案 |
|---|---|---|
incompatible types: ... is not a functional interface | 箭头左侧的目标类型根本不是函数式接口,或者接口里有多个抽象方法 | 换成函数式接口;给接口补@FunctionalInterface帮助编译器暴露问题 |
variable used in lambda expression should be final or effectively final | Lambda 捕获的局部变量被重新赋值 | 用新变量接收旧值,或者用容器/原子类,但优先考虑无副作用方案 |
no suitable method found for sort(...) | 类型推断失败,编译器不知道 Lambda 要转换成什么接口 | 显式写出参数类型:(User u1, User u2) -> ... |
method reference ... is ambiguous | 方法引用指向的方法存在重载,目标函数式接口有多个可能匹配 | 改用显式 Lambda,或加一个强转 |
exception X is never thrown in body of corresponding statement | Lambda 抛出的受检异常与目标函数式接口不匹配 | 包一层try-catch,或自定义一个允许抛异常的接口 |
第 5 条值得多说一句。标准的Function、Consumer等接口里的抽象方法都没有声明throws,这意味着你在 Lambda 里处理 IO、数据库操作时,遇到受检异常会编译失败。最常见的是写文件:
// 编译报错:IOException 不被 Function 的方法签名允许 Function<String, String> reader = path -> Files.readString(Path.of(path));通常的做法是在 Lambda 内部捕获并包装成非受检异常:
Function<String, String> reader = path -> { try { return Files.readString(Path.of(path)); } catch (IOException e) { throw new RuntimeException(e); } };这个问题在新手里出现频率极高,建议遇到就记住这个规律:受检异常必须先包一层。
5.2 调试技巧:日志、peek 与方法抽取
Lambda 最让人头疼的就是报错堆栈不直观。Java 会显示类似Demo.lambda$test$0(Demo.java:15)这样的栈帧,方法名自动生成,读起来很不友好。我有几个实战调试技巧:
第一个技巧是别在 Lambda 里面写太多逻辑,尽量抽取成独立方法,再用方法引用代替。比如:
// 不推荐:逻辑全塞在 lambda 里,堆栈难读 users.stream() .filter(u -> u.getAge() >= 18 && "北京".equals(u.getCity()) && u.getName() != null) .forEach(u -> System.out.println(u.getName() + ": " + u.getAge())); // 推荐:抽取成方法,堆栈里能直接看到方法名 users.stream() .filter(this::isTargetUser) .forEach(this::printUser);第二个技巧是用peek查看中间状态。peek的定位是“调试用的中间操作”,它能把流里正在经过的元素打印出来而不打断链路:
List<String> names = users.stream() .filter(u -> u.getAge() > 18) .peek(u -> System.out.println("过滤后: " + u.getName())) .map(User::getName) .peek(name -> System.out.println("映射后: " + name)) .collect(Collectors.toList());由于流是惰性的,把peek加在正确的位置,你能准确看到每个阶段到底有哪些元素流过去了,这比一步步猜快得多。排查完记得删掉这些调试代码。
第三个技巧是善用调试器。IntelliJ IDEA 对 Lambda 的支持已经很成熟,你可以给 Lambda 内部打上断点,在调试面板里直接查看捕获的变量和参数值。遇到“感觉过滤条件不对但说不出哪不对”的情况,直接断点看数据,比反复修改代码试错高效很多。
还有一点关于并行流的提醒:users.parallelStream()不是万金油。小数据集、有状态操作、非线性任务用并行流反而更慢;真正适合的是大数据量、无状态的映射和聚合操作。线上排查过太多次“并行流导致线程安全”的问题,建议在搞不清数据量的情况下默认使用串行流。
5.3 性能与潜在陷阱
Lambda 的运行时性能总体上是好的,JDK 8 之后invokedynamic的缓存机制避免了每次执行都生成一个实现对象。但在使用上仍有一些常见的性能陷阱。
第一,注意装箱问题。Stream<Integer>和IntStream差别很大。如果要做数值求和,用IntStream:
int sum = users.stream().mapToInt(User::getAge).sum();而不是:
int sum = users.stream().map(User::getAge).reduce(0, Integer::sum); // 能跑但多余装箱第二,警惕无限流。Stream.iterate(0, i -> i + 1)不接limit会一直生成元素,最后内存耗光。使用无限流记得一定要搭配limit,且让上游终止操作尽早截断。
第三,递归 Lambda 有一个经典限制:Lambda 不能直接引用自身。因为变量是在赋值表达式完成之后才被绑定的,下面的代码无法编译:
// 编译报错:f 可能还没初始化 Function<Integer, Integer> factorial = x -> x <= 1 ? 1 : x * factorial.apply(x - 1);解决办法是把函数定义改成一个类字段或者一个方法:
public class MathUtil { static Function<Integer, Integer> factorial; static { factorial = x -> x <= 1 ? 1 : x * factorial.apply(x - 1); } }或者干脆不用 Lambda 写递归,用普通方法就行。这个坑面试里偶尔会被问到,知道缘由即可。
第四,不要在 Lambda 中修改外部的共享状态。这不是性能问题,而是正确性问题。当多个线程同时执行count++时,结果不可预期,前面已经提过。函数式的核心规范是“无副作用”,遵守它不但能规避并发问题,还能让代码更容易测试。
6. 面试高频考点与综合实战
6.1 高频面试题盘点
如果是准备 Java 面试,Lambda 这一块常考的点基本就下面几个,我直接给出最省力的回答口径。
第一个问题是:“Java 8 的 Lambda 表达式是什么?为什么引入?” 可以这样回答:它是一段可传递的匿名函数,配合函数式接口使用,核心动机是行为参数化,解决样板代码过多的问题,同时为 Stream API 铺路。
第二个问题是:“什么是函数式接口?@FunctionalInterface 是必需的吗?” 回答要点:只有一个抽象方法的接口是函数式接口,@FunctionalInterface不是必需的,但有助于编译期校验和表达意图;default 方法和 Object 方法不计入抽象方法。
第三个问题是:“Lambda 表达式和匿名内部类的区别?” 至少要答出三点:Lambda 的this指向外围对象,匿名内部类的this指向自身;Lambda 不能定义新的遮蔽变量,匿名内部类可以展藏;Lambda 基于invokedynamic在运行时动态实现,匿名内部类在编译期生成 class 文件。
第四个问题是:“什么是 effectively final?” 回答要点:变量初始化后没有再被赋值,即便没写 final 修饰符,编译器也把它视为 final;Lambda 捕获的是值快照,所以不允许捕获的变量被修改。
第五个问题是:“方法引用有哪些类型?” 答出四种:静态方法引用、特定对象的实例方法引用、特定类型的实例方法引用、构造器引用。
第六个问题是:“Stream 中间操作与终止操作的区别?” 中间操作返回新的 Stream,惰性执行;终止操作产生结果或副作用,触发整个流水线执行。
第七个问题是:“并行流如何使用,有什么坑?” 使用parallelStream()或.parallel();坑是状态共享和线程安全、小数据量性能不升反降、对有序性有依赖时要谨慎。
6.2 传统代码到 Lambda 重构实战
最后做一个完整的重构对比。假设有一串用户数据,需求是:筛出成年人,取他们的姓名,按字典序排序,最后返回列表。传统写法:
List<String> result = new ArrayList<>(); for (User user : users) { if (user.getAge() >= 18) { result.add(user.getName()); } } Collections.sort(result);用 Lambda 和 Stream 重构:
List<String> result = users.stream() .filter(this::isAdult) .map(User::getName) .sorted() .collect(Collectors.toList());其中isAdult是一个方法:
private boolean isAdult(User user) { return user.getAge() >= 18; }有经验的同学应该能看出,这两种写法都能正确完成任务。不同点在于:传统写的“怎么做”很具体,但意图不容易一眼看清;重构后的代码按“数据集 -> 过滤 -> 转换 -> 排序 -> 收集”的顺序叙述,和业务需求描述的语序几乎一致。当然,Lambda 不是越简洁越好。如果一个表达式链超过五六个操作,或者中间有复杂的业务判断,我建议拆成多个独立方法,保持每个方法只干一件事,这样可读性才是最好的。
就拿我自己的项目经验来说,跟团队推荐 Lambda 的时候,最有效的做法不是讲多少理论,而是找一段大家熟悉的 for 循环代码,当众把它改写成 Stream 一行版本,再用 IDE 的断点看一遍数据流。大多数人的反应都是“原来这么简单”,然后自然而然地开始用。反倒是那种一上来就抛十几个 Collector 的人,容易把团队吓退。
学 Lambda 我还有一个建议:给自己定一个“渐进清单”。第一周只允许用Comparator.comparing和list.forEach;第二周加上filter与map;第三周再碰Collectors.groupingBy、partitioningBy这类高级收集器;最后才去啃Optional、reduce和自定义 Collector。回头你会发现,原来遇到困难的地方不是语法,而是“思维方式”没有翻转过来。
我个人在实际操作中还有一个体会:Lambda 是把双刃剑,短是它的优点也是它的风险。一个只有一行实现的方法引用, readability 几乎无敌;可一旦在参数列表里写五个 lambda 嵌套,代码就成了“只有写的人能看懂”的东西。所以我现在写代码有个不成文的规矩——凡是 Lambda 体超过三行,一律抽成方法;凡是流式操作链条超过四段,必须拆成带注释的两步。技术为业务服务,这一点永远不能忘。