ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java 8 Lambda表达式学习笔记:语法、原理与避坑指南

Java 8 Lambda表达式学习笔记:语法、原理与避坑指南 说实话看到DAY16这个数字的时候我自己都愣了一下——原来我已经坚持写了十六天的Java学习日记了。今天要总结的主角是lambda表达式标题里我写成了lamdba后来才发现少打了一个b这种小错误在学Java的路上简直是家常便饭。lambda是Java 8引入的重量级特性也是我从写出一段能跑的程序过渡到写出一段像样的程序时感受最强烈的一个知识点。lambda表达式解决的核心问题非常朴素以前我们要给某个方法传一段行为逻辑必须先把它包在一个类或匿名内部类里代码啰嗦又难读lambda允许我们直接把行为像数据一样传过去写法瞬间清爽很多。这篇日记适合刚学完Java基础、准备入门lambda的初学者也适合那些已经会用lambda但没有真正掌握它背后规则的同学。我会从一个初学者的视角把我踩过的坑、查过的资料和最终理解透的过程全部记录下来保证你看完之后能真正理解而不是死记硬背语法。1. 先搞明白lambda到底解决了什么问题1.1 匿名内部类带来的代码肥胖症在没有lambda的年代想在Java里传递一段行为标准做法是搞一个匿名内部类。我现在回头看这段代码依然能感受到当时的痛苦Runnable task new Runnable() { Override public void run() { System.out.println(执行任务); } };这是一个典型得不能再典型的例子。整段代码里真正有用的逻辑只有System.out.println(执行任务)这一行但为了让它能作为Runnable传给Thread我不得不写一堆样板代码new一个Runnable接口、标注Override、把run()方法的签名完整写出来、最后还得注意括号和分号的位置。写一次还好写得多了满屏都是这种结构性噪音核心业务逻辑反而被淹没在无关紧要的格式里。我第一次写Comparator的时候更崩溃。当时要给一个Apple对象的集合按重量排序需求就是拿两个苹果比一比重量可我的代码长这样Collections.sort(appleList, new ComparatorApple() { Override public int compare(Apple a1, Apple a2) { return a1.getWeight().compareTo(a2.getWeight()); } });这个例子让我印象特别深。因为我盯着这段代码想了半天发现它传达的信息其实就一句话按重量从小到大排。但Java的语法不允许我直接说这句话我必须先构造一个Comparator对象告诉编译器我要比较的是Apple类型再把compare方法实现一遍。这种写法不是不能跑而是读代码的人容易被噪音干扰而且写起来非常反人性。1.2 从关注怎么做到关注做什么lambda出现之后上面的排序可以写成这样appleList.sort((a1, a2) - a1.getWeight().compareTo(a2.getWeight()));就一行。第一次在别人的代码里看到这种写法时我的反应是这是什么黑魔法后来自己动手试了试发现编译器完全认识它跑起来结果也一模一样我才开始认真琢磨这里面的原理。lambda最重要的思想转变是让程序员从关注构造过程转向关注核心逻辑。以前你写一个匿名内部类脑子里要过一遍我该创建一个什么样的对象、实现哪个方法、参数类型是什么而lambda把这个过程缩减成了这个方法需要什么参数我要返回什么结果。打个比方匿名内部类就像是你去餐厅要求厨师把锅、铲、食材、燃气灶全部摆到你面前告诉你你自己组装一个炒菜流程lambda则相当于你直接告诉厨师来一份宫保鸡丁中间的过程交给框架去处理。这种思维转变在我后来写Stream、写事件回调、写线程任务时帮助特别大。因为一旦接受了行为即数据这个设定很多Java API设计出来的用途才能真正看懂。1.3 Java 8为什么选择这条路lambda并不是Java的首创在C#、Scala、JavaScript这些语言里函数式编程早就普及了。Java当年选择用lambda来补上这块短板走的路线是尽量不破坏现有语法——所以它没有发明新的关键字也没有改变接口的定义逻辑而是把lambda定位成函数式接口的便捷实现方式。这个设计决策的影响非常深远老代码不用改新特性还能无缝接入集合框架和各种回调场景。理解了这一点你就会明白为什么Java里总是反复强调函数式接口这个概念。因为lambda本身没有类型它的类型是目标类型赋予的——也就是说lambda能成为什么取决于它被赋值给谁。这个知识点是后面所有使用技巧的基础我花了好几天才真正想通下一节仔细拆开讲。2. 语法与函数式接口看懂lambda的基础规则2.1 五种常见写法一网打尽lambda的基本语法长得像这样(参数列表) - { 方法体 }箭头左边的括号里写参数右边的花括号里写逻辑。根据参数个数和方法体的复杂程度它有几种常见变体我总结了五个// 1. 无参数 () - System.out.println(没有任何参数) // 2. 一个参数可以省略括号 name - System.out.println(你好 name) // 3. 多个参数括号不能省 (a, b) - a b // 4. 方法体只有一行可以省略花括号和return (a, b) - a b ? a : b // 5. 方法体有多行必须写花括号 (a, b) - { int sum a b; System.out.println(两数之和 sum); return sum; }这里最需要注意的是第4种和第5种的区别。当方法体只有一个表达式时可以省掉花括号而且这个表达式的值会自动作为返回值一旦你写了花括号就必须显式写return如果方法需要返回值的话。我一开始老是在这里犯错多行lambda忘写return或者单行lambda画蛇添足写了return导致编译报错。还有一个细节也值得记住单个参数时括号可省但多个参数、零个参数必须写括号。别小看这个规则我在网上看到很多人教一个参数可以省略括号结果自己写零参数的lambda也把括号省了直接编译失败。零参数必须写成() - ...这是很多新手遇到的第一只拦路虎。2.2 函数式接口lambda的对接标准前面说过lambda本身没有类型它需要挂靠在一个函数式接口上才能工作。那么什么叫函数式接口只有一个抽象方法的接口就是函数式接口。比如Runnable、Comparator、Callable都是函数式接口。FunctionalInterface public interface MyComparator { int compare(int a, int b); }写接口的时候可以在上面加FunctionalInterface注解这不是必须的但强烈建议加上。它的作用不是让接口变成函数式而是让编译器帮你把关——如果你不小心在这个接口里写了第二个抽象方法编译器会直接报错提前发现问题。我自己的习惯是所有打算配合lambda使用的自定义接口一律加上这个注解。这里要特别注意抽象方法和默认方法的区别。Java 8允许接口里有带方法体的默认方法default method这些不算抽象方法所以一个函数式接口是可以同时拥有默认方法的不影响它作为lambda目标类型的资格。真正要数清楚的只有那些没有方法体的抽象方法的个数必须是恰好一个。2.3 目标类型lambda其实是看菜下饭稍微了解过lambda的人应该都听过目标类型这个词。它的意思是编译器在判断一个lambda表达式的合法性时不是看lambda自己长什么样而是看它被赋值给什么类型的变量或者被传入什么类型的方法参数。举个例子// 同一个lambda赋给不同的函数式接口含义完全不同 IntBinaryOperator op1 (a, b) - a b; // 两个int相加 ComparatorDouble op2 (a, b) - a.compareTo(b); // 两个Double比较上面两个lambda的写法几乎一样但因为目标类型不同它们被解释成了不同的实现。编译器做的事本质上是一种形状匹配把lambda的参数个数、参数类型、返回值和目标接口的抽象方法做一一对照全部对得上就通过。所以lambda能工作的前提是它的形状和目标接口的抽象方法签名一致。这个设计也让Java的lambda没有沦为一种独立的对象类型。你没有办法单独声明一个变量说这个变量是一个lambda。它必须和某个函数式接口绑定。这一点和JavaScript完全不一样也是很多从JS转过来学Java的人最开始不习惯的地方。想通之后其实挺自然的——Java用目标类型巧妙地规避了为函数开辟新类型系统的巨大改动。3. 内置函数式接口java.util.function这个弹药库3.1 四个最常用的接口学了lambda之后你不必每次都用自定义接口——JDK在java.util.function包里准备了一大堆现成的函数式接口最常用的就是四个Predicate、Consumer、Function、Supplier。我把它们整理成了一张表接口抽象方法作用典型场景Predicateboolean test(T t)返回真假用于条件判断筛选、过滤Consumervoid accept(T t)接收参数无返回值遍历、打印FunctionT, RR apply(T t)输入T返回R类型转换、字段提取SupplierT get()无参数只产出结果延迟生成、工厂这四个接口的分工特别清晰几乎覆盖了日常开发80%以上的需求。写lambda的时候你根本不需要关心接口内部叫什么方法名只需要知道它是判断、是消费、还是转换。拿最典型的业务场景来说。假设我从数据库捞了一份User列表现在想筛选出年龄大于18岁的用户再取出他们的名字最后逐个打印ListUser users userService.findAll(); // Predicate筛选 users.stream() .filter(user - user.getAge() 18) // Function从User提取name .map(User::getName) // Consumer打印 .forEach(name - System.out.println(name));这一条链路人一眼就能看懂过滤、提取、打印。如果回到匿名内部类的写法同样的逻辑至少要码出六七个类名和方法签名读起来反而更费劲。3.2 专用接口IntPredicate、LongSupplier这些细节java.util.function里除了泛型接口还有一批针对原始类型的专用接口比如 IntPredicate、IntFunction、LongSupplier、DoubleConsumer。为什么要有这些因为Java的泛型不支持原始类型。如果你写PredicateInteger底层自动装箱会把int变成Integer对象涉及大量对象的创建销毁性能上吃亏专用接口则直接用原始类型省掉装箱开销。我自己在写对性能敏感的代码时比如要循环几百万次做判断会优先选择IntPredicate而不是Predicate 。平时写业务代码倒是不需要这么较真但面试的时候有人问起来你至少要能说出为了避免自动装箱的性能损耗这个原因。3.3 组合玩法and、or、andThen、compose函数式接口最妙的一点是支持组合。Predicate有and()、or()、negate()这些默认方法Function有andThen()和compose()可以像流水线一样把多个lambda拼在一起。比如筛选成年男性PredicateUser isAdult user - user.getAge() 18; PredicateUser isMale user - 男.equals(user.getGender()); ListUser result users.stream() .filter(isAdult.and(isMale)) .collect(Collectors.toList());Function的组合就更直观了。我想把用户的名字转成大写再拼接一个前缀后缀传统写法得嵌套好几层方法调用用andThen则从左到右排开FunctionString, String toUpper String::toUpperCase; FunctionString, String addTag s - 【 s 】; String result toUpper.andThen(addTag).apply(zhangsan); // 结果【ZHANGSAN】这两个默认方法的意义不在于省那几行代码而在于让数据的处理步骤变得声明式、可复用。你可以在配置里定义好一整套处理链运行时再决定要不要组合这比写一堆if-else嵌套要优雅得多。4. 变量捕获lambda里的只读约束4.1 effectively final是怎么回事lambda有一个让所有新手都栽过跟头的规则在lambda内部使用的外部局部变量必须是被final修饰的或者实际上一旦赋值后就不再改变effectively final。什么叫实际上不再改变就是代码里虽然没写final但变量在初始化之后从来没被重新赋值过编译器就默认为它是effectively final。举个例子int basePrice 100; // 之后没再改过OK int discount 50; discount 60; // 重新赋值了不行 SupplierInteger supplier () - basePrice - discount; // 编译报错我一开始特别不理解这条规则觉得Java简直是故意给人添堵。直到后来查资料想通了理由lambda捕获的是变量的值或者说变量的副本而不是变量本身。这个设计是为了规避多线程场景下的数据竞争——如果一个lambda在执行期间外部的变量还在被别的线程改写那lambda读到的值就是不稳定的、无法预测的。强制只读等于从根本上杜绝了这种不一致。其实匿名内部类也有类似的限制内部类使用外部局部变量时那个变量必须显式声明为finalJava 8之前只是lambda把这个限制扩展成了effectively final并且在编译期自动帮你认定了。4.2 想修改外部变量怎么破知道规则之后下一个自然的问题就是我确实需要在lambda里改个计数怎么办最常见的替代方案是用数组或容器作为受害容器因为数组元素本身不算是局部变量的重新赋值int[] count { 0 }; Runnable task () - count[0];这个方法能用但不太推荐拿它写复杂逻辑因为本质上是钻了个漏洞而且多线程下还得自己加锁。更推荐的做法是用java.util.concurrent.atomic包里的原子类AtomicInteger count new AtomicInteger(0); Runnable task () - count.incrementAndGet();原子类的所有操作都是线程安全的在并发场景下比数组方案靠谱得多。你可能会问lambda捕获count这个引用变量为什么允许修改它指向的对象因为这里改变的不是count本身引用地址没变而是它指向的AtomicInteger对象内部的值这并不违反effectively final规则。4.3 静态变量和成员变量没有这个限制一个很容易忽略的知识点是lambda对静态变量和成员变量没有赋值限制。原因很简单局部变量是存储在栈上的每个线程一份捕获副本容易产生歧义而静态/成员变量存储在堆上是共享内存lambda和其他代码访问的是同一个东西所以允许直接修改。这也从侧面说明了为什么局部变量要特别限制——线程之间的数据隔离反而更依赖局部变量。我实际测试过这个区别编译和运行确实没问题public class TestLambda { private int count 0; // 成员变量lambda里随便改 public void run() { Runnable task () - { count; System.out.println(count); }; } }但我要提醒一句写并发代码时不要因为能改就肆无忌惮。成员变量在lambda里被多个线程操作时你依然需要考虑同步问题否则读到的值可能不是预期值。5. 方法引用把lambda再压缩一层5.1 四种形式一次列出lambda已经够简洁了但有些场景里lambda体甚至只是调用一个现成的方法此时还有更简的写法——方法引用。方法引用的形式有四种我用一个小表列清楚形式语法示例等价lambda静态方法引用类名::staticMethodInteger::parseInts - Integer.parseInt(s)实例方法引用指定对象对象::instanceMethodSystem.out::printlns - System.out.println(s)实例方法引用指定类型第一个参数当对象类名::instanceMethodString::toUpperCases - s.toUpperCase()构造方法引用类名::newArrayList::new() - new ArrayList()第四种形式最容易混淆我专门解释一下String::toUpperCase是怎么和s - s.toUpperCase()等价的。原因是在这个方法引用里编译器会把lambda的第一个参数当作调用这个方法的对象把其余参数当作方法的入参。也就是说(a, b) - a.compareTo(b)在多数场景下可以直接写成Integer::compareTo。理解了这个规则你在读源码时遇到陌生的方法引用就不会懵。5.2 什么时候值得用方法引用我的经验是当lambda体就是一行调用某个方法时优先用方法引用可读性最好一旦lambda体里有额外的操作比如拼接字符串、判断再返回就老老实实写lambda硬套方法引用只会让代码变得晦涩。比如这个例子用lambda更清楚users.forEach(user - System.out.println(用户名 user.getName()));而下面这种情况方法引用明显胜出names.stream() .map(String::toUpperCase) .filter(s - s.startsWith(A)) .forEach(System.out::println);还有一个面试中经常被问的点方法引用和lambda在编译期是等价的方法引用只是语法糖本质上会被编译成同样的字节码。所以用方法引用会不会影响性能这种问题答案是不会只是写起来更短而已。6. lambda与Stream真正的实战主场6.1 Stream的基本工作方式要说lambda最经典的落地场景绝对非Stream莫属。Stream的意思是流你可以把集合想象成一条传送带上面的元素挨个流过去每个中间操作就像传送带上的一个处理工位——有的工位做筛选filter有的工位做转换map有的工位做整理sorted最后在终点站统一收集成新的集合collect。拿一个非常常见的业务需求来演示从学生列表里找出成绩大于90分的人按成绩从高到低排序只保留名字并去重后输出。ListStudent students getStudents(); ListString names students.stream() .filter(s - s.getScore() 90) .sorted((s1, s2) - s2.getScore() - s1.getScore()) .map(Student::getName) .distinct() .collect(Collectors.toList());这段代码几乎不需要注释就能读懂这恰恰是lambda结合Stream最大的价值——业务意图一目了然。如果换成for循环加if判断加临时变量代码会膨胀一倍以上而且逻辑藏在语句里得逐行读才能还原出全貌。6.2 中间操作和终止操作的执行逻辑学Stream有个概念必须清楚中间操作是惰性的只有遇到终止操作才会真正执行。filter、map、sorted这些都属于中间操作你写完之后不会真的去遍历集合直到collect、forEach、count这类终止操作出现数据才会开始流动。这个设计对性能挺重要。如果你只需要前三个符合条件的名字可以用limit(3)配合终止操作Stream会短路——一旦凑够三个就不再往下遍历。这种能力用for循环当然也能实现但写起来要手动加break和计数器远不如Stream里一个limit来得干净。不过惰性也有个副作用如果你在中间操作里写了一些有副作用的代码比如更新外部状态这些代码的执行时机和次数并不直观调试时容易一头雾水。我的建议是Stream链条里的中间操作尽量保持纯函数不在里面改外部变量否则出了问题很难排查。6.3 分组和聚合groupingBy与reduce除了filter、map、collect这几个入门三板斧Stream配合lambda还有两个很实用的大招——分组和聚合。想要按年级统计每个年级有多少学生MapGrade, Long countByGrade students.stream() .collect(Collectors.groupingBy(Student::getGrade, Collectors.counting()));一行代码搞定分组统计这在以前的Java里至少要写一个for循环加一个Map手动维护计数逻辑。groupingBy的第一个参数是分组依据第二个参数是每个组内怎么聚合collectors里还提供了 summingInt、averagingInt、maxBy 这些下游聚合器灵活性非常高。reduce则用来做把一堆值归约成一个值的操作比如求总分数int totalScore students.stream() .map(Student::getScore) .reduce(0, Integer::sum);这里的思路是先把每个学生的成绩map出来然后用reduce从初始值0开始逐个累加。如果用传统的for循环代码更啰嗦不说逻辑也没这么直观。我第一次在真实项目里把一整套groupingBy加reduce的逻辑替换掉原来几十行的循环时整个人的心态从Java笨重变成了Java真可以这种体验上的反转算是学lambda过程中最有成就感的一刻。7. 常见报错与踩坑记录这些都是血泪教训7.1 编译错误速查表学lambda这几周我收集了不少报错挑出出现频率最高的几个做成速查表报错信息简化原因解决办法Local variable x defined in an enclosing scope must be final or effectively final在lambda里用了外部局部变量且该变量后来被重新赋值给变量加final或者改用原子类/容器The target type of this expression must be a functional interfacelambda等号左边不是函数式接口比如接在一个普通类上确认目标是只有一个抽象方法的接口incompatible parameter types in lambda expressionlambda参数类型和目标接口的抽象方法参数对不上检查参数个数和类型Cannot infer functional interface type编译器无法推断目标类型常见于链式调用里直接new给表达式显式声明函数式接口类型return outside method单行lambda里误用了return却写成表达式形态统一改成花括号方法体这些报错大多不是lambda的语法有多难而是形状对不上。排查的时候不要只盯着lambda本身先看看目标接口的抽象方法签名是什么。7.2 this关键字lambda和匿名内部类完全不同这是我踩得最重的一个坑。在匿名内部类里this指向的是匿名类对象本身而在lambda里this指向的是外层类的对象。二者截然不同。如果你在lambda里调用外层的成员方法this能正常工作但如果你期望this代表lambda自己那么很遗憾lambda没有自己的this。从设计角度想也合理lambda不是一个新对象它本质上是目标函数式接口的一个实现而这个实现与外层共享this上下文。这个特性在某些回调场景里有好处可以直接访问外层方法但也是面试里常考的区分点我建议你亲手写一段代码验证一下印象会非常深刻。7.3 lambda里能不能用break和continue我在练习for循环配合lambda时曾试着在forEach里面写continue结果编译直接报错。原因是forEach接收的是一个Consumer这个方法本身不是循环break和continue在它里面根本没有意义。正确的分情况处理是如果你想提前终止整个流的遍历用anyMatch、allMatch、findFirst这类短路操作如果你只是想在某个条件下跳过不处理那就在lambda体里写if判断替代continue。记住lambda体是一个独立的函数不是一个循环体循环的控制语句在它里面统统不适用。7.4 给lambda打日志的正确姿势调试lambda时最直观的办法就是让lambda体不只有一行。前面说过方法体多行时需要写花括号但也正因为能写花括号你完全可以在正式逻辑前加一行System.out.printlnstudents.stream() .map(s - { System.out.println(正在处理 s.getName()); return s.getName().toUpperCase(); }) .forEach(System.out::println);如果用的是IntelliJ IDEA还有更方便的招直接在Stream链路的中间操作上调打断点调试器会自动展示当前的元素集合不用改任何代码。我第一次用这个功能的时候真的有被惊艳到——以后再也不用在代码里塞一堆临时的打印语句了。8. 关于lambda性能的几点实话8.1 lambda真的比匿名内部类慢吗网上有一种说法是lambda性能不如传统的匿名内部类这个结论在Java 8早期有一定道理但在现代JDK上基本不成立。lambda的底层实现走的是invokedynamic指令第一次调用时会做解析之后会缓存起来实际性能甚至比匿名内部类还要好一些。更准确地说lambda在编译阶段不会生成一个独立的Class文件匿名内部类会而是在运行到该处时动态生成实现。这套机制的好处是没有额外的类加载开销且JVM可以针对性地做内联优化。所以正常业务代码里lambda和传统写法在性能上的差异可以忽略不计你更应该关注的是哪个写起来清楚、容易维护。8.2 什么时候别硬用lambda学了新东西就容易到处用我也犯过手里拿着锤子看什么都像钉子的毛病。后来复盘自己的代码发现有几类场景其实不适合lambda循环逻辑复杂且含多个分支时Stream链会变得又臭又长普通for循环反而好读。需要中途跳出的场景比如找到第一个满足条件的人虽然可以用findFirst但可读性不一定比传统循环配break直观。团队协作里别人读不懂的lambda别硬上维护成本会上升。我的原则很简单lambda写出来能让代码变得更短更清楚就用如果为了炫技把代码搞得绕来绕去那还不如回到老写法。写代码的第一服务对象永远是读代码的人包括未来的你自己。8.3 保持lambda小巧的实战建议处理逻辑如果超出三行我建议把方法体单独抽成一个有名字的函数然后用方法引用去调用它。这样不但lambda块保持简洁还方便单元测试。比如// 单独定义 private String formatUserName(User user) { String name user.getName(); if (name null || name.isEmpty()) { return 未知用户; } return name.trim().toUpperCase(); } // lambda里一行引用 users.stream() .map(this::formatUserName) .forEach(System.out::println);这种写法的额外好处是较长的处理逻辑获得了名字和上下文可读性直线上升。我见过太多在一个lambda里硬塞七八行逻辑的代码看着就头大——lambda的价值是让意图显而易见不是让你把复杂的逻辑全部压缩成一行。写到这里DAY16的lambda学习基本告一段落。回头看我花了不少时间在搞懂为什么上为什么局部变量必须effectively final为什么lambda没有自己的this为什么它需要目标类型。这些为什么一开始让人觉得麻烦但一旦想通整个lambda的知识体系就串起来了。如果让我给自己今天的学习打分我会给八十分。剩下二十分留给实战——毕竟纸上得来终觉浅语法都懂了是一回事能不能在真实项目里用得恰到好处是另一回事。接下来我打算刻意练习两周每次写回调、写排序、写集合处理都下意识想想能不能用lambda加Stream替代遇到读不懂的报错就回来翻这篇日记。学编程最怕的就是觉得自己会了只有踩过的坑足够多写出来的代码才有底气。最后再分享一个小技巧如果你也和我一样刚学lambda去IDE里把代码折叠和格式化玩明白多观察lambda对应的匿名内部类等价版本会让你对这些看不见的对象有更深的体感。祝你写出的每一行lambda都让读你代码的人觉得舒服。
返回列表