Lambda表达式的使用、简写与原理深入理解

Lambda表达式的使用、简写与原理深入理解

jdk8之所以引入Lambda表达式,是受到函数式编程的启发,将其以一种特殊的方式引入至java的面向对象编程中。 好处是在很多场景下可以大大简化编程,然而其引入的代价则是 java编译器 和 jvm虚拟机都需要做额外的工作以适应这种新的"函数式"编程语法。

在java编程中,有些一次性临时使用的对象方法,我们可以使用匿名内部类的方式来进行简化。Lambda表达式的一个核心想法就是,借用函数式编程思想和语法进行更彻底的简化,即只需要在代码中说明要做什么即可,编译器可以自动进行类、对象方法中的匹配,不需要再写额外的 new overwride 等等冗余的代码。(函数式编程:别给我扯那些那些类模板、对象属性方法啥的,直接告诉我要做什么,好吗?)

Lambda表达式初体验

在上面的例子中,直观的能体会Lambda表达式的作用。简化了什么内容呢?

new 关键词、匿名内部类实现的接口名称、方法签名 、@Override 多余的{} ; 等。

这种写法实在是非常清爽。这能够感受到Lambda表达式的简化,其实这也是Lambda表达式最终极的简化形态。

二 原理深入

那么其中的原理是什么呢?

为什么仅仅只需要声明"做什么事情"即可让编译器无歧义的理解代码呢?

这其中编译之后的class文件是如何的呢?

在jvm层面是否违背了面向对象调用方法运行呢?jvm运行的时候又是如何的呢?

从匿名内部类开始理解

我们可以从匿名内部类开始理解。在面向对象编程过程中我们往往是将我们要做的事情封装在一个方法中,使用方法之前可能要通过对象来进行调用,而要产生对象则必须通过类模板来进行创建,其中经历了两次包装。而匿名内部类则让我们免于写类模板,减少了一次复杂的包装。先看看匿名内部类的原理:

java 复制代码
package org.example.test;


public interface MyInterface {
    public abstract void run(String str);
}
typescript 复制代码
package org.example.test;

public class AnonymousTest {
    public static void main(String[] args) {
        test(new MyInterface() {
            @Override
            public void run(String str) {
                System.out.println(str);
            }
        });
    }

    public static void test(MyInterface oneObject){
        oneObject.run("hello");
    }
}

定义MyInterface,同时AnonymousTest类中的test方法接收一个MyInterface的实现对象,该实现对象采用匿名内部类的写法。

编译AnonymousTest,输出如下,其中发现多了一个AnonymousTest$1.class文件。

反编译查看AnonymousTestAnonymousTest$1.class

发现了一些端倪。猜测如下:

  1. 编译器帮我们自动生成了一个类,且自动实现了MyInterface接口重写了run方法,方法体的内容就是我们在匿名内部类中重写的方法。
  2. AnonymousTestmain方法中test方法调用时,传递了一个对象,这个对象理应是AnonymousTest$1对象,不过这个名字好像不对(猜测是反编译工具的一些处理不够完善,不过这个"new 1()"倒是可以猜测到一些端倪。)

想进一步验证,上字节码!!!

AnonymousTestmain方法和test方法字节码解读:

main方法的第0~4行中发现,的确是新建了一个AnonymousTest$1对象并执行了初始化,第7行中将刚刚创建出来的AnonymousTest$1对象的引用传递给test方法,调用结束后直接返回。

test方法:第0行将main方法传递进来的AnonymousTest$1对象的引用入操作数栈,第1行加载"hello"字符串,第3行以多态的形式调用MyInterface接口中的run方法,也就是动态调用,程序执行的过程中将会调用AnonymousTest$1对象的run方法。

再看AnonymousTest$1对象的run方法,将方法入参(String str)放入操作数栈,执行打印控制台方法。

完美!!! 到此分析结束(字节码yyds)。

总结:

  1. 匿名内部类的确是编译器帮我们自动生成了一个匿名类(这个匿名只是相对于程序员而言的,相对于编译器和jvm是有名字的,该类为xxx$x的格式。其中xxx为匿名内部类所在类的类名,x表示是这个类中的第几个类,从1开始(很显然不能从0开始,因为0一般表示"本""自己"的意思,这个序号不能分配给其他类也是避免引起混淆,也可以类比java方法中的局部变量表中的索引0代表本对象的引用))。
  2. 编译器会将原始匿名内部类的写法转换为标准的传递对象引用的写法。然后交由jvm执行。

很显然,匿名内部类只是一个语法层面的优化,为了避免程序员多写一个类,创造了一种新的语法,然后将创建类的过程交给了编译器。对于jvm来说,其实并没有发生任何的变化。最终的结果就是程序员少写了一点代码,编译器做了一些额外的工作,总工作量其实并没有减少。(不过只要程序员写得少了,那就有这样做的必要,毕竟编译器做额外的重复工作并无所谓。)

Lambda表达式的原理深入理解

先看使用Lambda表达式简化上述匿名内部类该怎么写,再来谈原理。

typescript 复制代码
package org.example.test;

public class LambdaTest {
    public static void main(String[] args) {
        test( (String str) -> {
            System.out.println(str);
        });
    }

    public static void test(MyInterface oneObject){
        oneObject.run("hello");
    }
}

运行,一样的输出打印结果。谈原理之前,我们先来思考一下,假如使用人类的语言对java编译器对话,简化匿名内部类的写法应该如何说呢?

程序员 :我想在test方法里面直接写我要做的事情可以吗?我的想法是这样的,既然你需要一个接口的实现对象,但是不确定的东西只是方法的执行细节,那我把方法细节写好不就行了,你应该知道这个方法是MyInterface 这个接口的某个实现类的run方法吧,因为你的接口只有这个方法。其他的步骤你都懂得,帮我弄一个匿名对象然后重写这个方法交给jvm执行即可,这是我们之间的默契是不是。

Java编译器:理论上是可行的。但是实现起来起来似乎有点麻烦。

程序员:你就像刚刚匿名内部类那样一样,帮我简化不就好了。你只需要做到两点:

  1. 一定有一个对象,这个对象的所属类实现这个接口,且重写了run方法。
  2. 对象中重写的run方法,一定调用了Lambda表达式中的方法(可能完全相同,也许可能还有一些额外的不重要的代码)。

试试看,我相信你。

Java编译器:好我试试看。

看一下编译之后的classs文件。

看一下反编译LambdaTest.class文件之后的代码。

程序员:居然没有自动生成匿名类。你在搞什么?jvm还是看不懂啊。

Java编译器:我想了一种方案,不打算和匿名内部类使用同一种方法,也可以实现你的需求,你看看。

那就看下字节码。

bash 复制代码
main方法的字节码:
0 invokedynamic #2 <run, BootstrapMethods #0>
5 invokestatic #3 <org/example/test/LambdaTest.test>
8 return

test方法的字节码:
0 aload_0
1 ldc #4 <hello>
3 invokeinterface #5 <org/example/test/MyInterface.run> count 2
8 return


还多了一个lambda$main$0的方法
0 getstatic #6 <java/lang/System.out>
3 aload_0
4 invokevirtual #7 <java/io/PrintStream.println>
7 return

真正的反编译后的代码应该是这样的:

typescript 复制代码
public class LambdaTest {
    public static void main(String[] args) {
        test(str -> {
            System.out.println(str);
        });
    }

    public static void test(MyInterface oneObject) {
        oneObject.run("hello");
    }
    
    public static void lambda$main$0(String str) {
        System.out.println(str);
    }
}

Java编译器 :我打算用一种新的方法。正如你所见:test方法没有发生任何变化。但是多出来了一个lambda$main$0方法(这个lambda$main$0的方法体和你在lambda表达式中写的方法体是一样的),同时最关键的main方法中,以前的new对象的字节码变成了invokedynamic #2 <run, BootstrapMethods #0>这行。这是整个新方法中最关键的一步。

程序员:这个是干什么的。你以前和jvm似乎没有这样的约定啊?

Java编译器 :是的。这是我跟jvm之间的新的约定。我想了想,每次我编译你的代码的时候就会多出来一个class文件,这可能有些多余。如果在程序运行的时候,jvm看到这个新约定之后才自动创建一个class模板,然后根据这个class模板去生成对象,传入test方法中的参数中,可能更加的好一些。

程序员 :也就是说你把这个工作交给了jvm是吗?你这个invokedynamic #2 <run, BootstrapMethods #0>我完全不理解是什么,jvm具体做了什么呢?我要如何知道呢?

另外,lambda$main$0和这之间的关联是什么呢?

Java编译器 :我教你一个方法。如果你想知道jvm在运行过程中创建的这些class模板对象,运行的之后加上这个参数就可以了( -Djdk.internal.lambda.dumpProxyClasses=/path),这样jvm就会打印ta创建的class模板对象。 另外,如果你想知道lambda$main$0这个方法的信息,我建议你断点调试,这样你就一下子就明白一切了。

程序员:好的。让我试试。

添加vm选项(-Djdk.internal.lambda.dumpProxyClasses=/path),运行。

的确发现了LambdaTest$$Lambda$1.class这个文件。就是在jvm运行过程中创建的类对象。

查看反编译后的代码和 字节码

LambdaTest$$Lambda$1.classrun方法,居然调用了LambdaTest中在编译环节中生成的lambda$main$0方法。

程序员 :我大致已经猜到了jvm实现的过程了。在遇到invokedynamic #2 <run, BootstrapMethods #0>这行字节码的时候,jvm应该根据某些信息自动创建并加载了LambdaTest$$Lambda$1.class的类模板,同时创建了该类的实例对象,然后将这个实例对象的引用压入操作数栈中。执行结束。

这样接下来在test方法执行的过程中就有了对象的引用,在执行该对象的run方法的时候,就是调用的编译器生成的LambdaTest.lambda$main$0静态方法(而这个方法体就是程序员在lambda表达式中写的方法内容)。整个过程如此的巧妙。编译器和jvm的合作如此的天衣无缝。

接下来在debug环节进行断点验证。

断点处与运行中的方法栈如下:

方法栈: LambdaTest的main方法 →LambdaTest的test方法 → LambdaTest$$Lambda$1.class类的run方法 →LambdaTest的lambda$main$0方法.

完美闭环!!! 整个过程就是这么的简单。

程序员:可是为什么要换这种方法呢? 按照和匿名内部类一样的写法不行吗?

Java编译器:正如我之前讲到的,在运行期间动态的生成和加载类可以更加高效。因为我已经预知这种简化方法可能会大量采用(比如你将来会知道的Stream Api等),如果我每次都提前生成class文件的话,那么可能会有无穷多的class文件,这样会非常的冗余。只要能保证在运行期间jvm可以正常加载类生成对象即可,剩下的事情我跟ta之前商量好了。

程序员: 看起来这其中的过程的确不需要我再去了解了。你说的Stream Api我待会去了解下。

总结:

  1. 与匿名内部类相比,程序员做了更少的工作,真正做到了无需写类文件,也无需显示new对象,只需要说明要做的事情即可(也就是方法中的内容)。
  2. 然而无论语法如何变化和简化,程序员如何写更简化的代码,一定会有额外的工作交给编译器和jvm去处理了(大自然处于动态平衡之中,一切有舍皆有得,有得必有失,程序解决的问题的总体复杂度并不会降低)。在lambda表达式这种语法中,编译器编译时提前将表达式中的方法体转移至一个新的方法中。在jvm运行过程中,生成新的类和对象来调用这个方法,即完成了Lambda表达式中的"事情"的执行。
  3. jvm中的字节码永远没有违背类 对象 以及对象方法的调用来实现基本的java程序运行的过程,永恒不变,依然是纯粹的面向对象。 jvm不愧为 "语言无关的平台",只要你是面向对象,就可以永远信赖jvm(Scala、Kotlin点了一个赞)。

最后需要说明的是,在我们感受学习这种语法的同时,也可以去深入思考当初Lambda表达式设计者的深邃的智慧,将面向函数式编程这种思想引入到面向对象编程体系中,的确需要做大量的功课,才能设计出一种通用、简单且能兼容的编码方法。

三 Lambda表达式的简化

既然Lambda表达式追求简化匿名内部类的书写,是否还可以再上述的基础上更加简写呢? 我们的核心追求是,即只需要保证我们能说清楚要做的事情即可。

程序员 :如下的test方法调用中的Lambda表达式,我想要再简化书写,你看如何?

typescript 复制代码
    public static void main(String[] args) {
        test( (String str) -> {
            System.out.println(str);
        });
    }

Java编译器:说出你的想法。

程序员:我的想法是这样的。只要我的表示方法没有任何的歧义,理论上是不是任何写法都可以,且你都应该知道我想要做什么,这是我们之间的默契,是不是。

Java编译器:直接说出你的简写方案。

  1. 输入参数的简写:

    1. 省略形参类型String:既然test方法中只能接收MyInterface接口的实现对象,其中的run方法的参数类型String在接口中已经声明了,我不想再写了。
    2. 省略"()":既然类型已经不需要了,"()"也没有存在的必要了。
  2. 方法体的简写:

    1. 方法体的"{}"也直接省略,接着";"也可以省略,接着这行代码可以直接移上去了。

简写之后的代码如下:

typescript 复制代码
    public static void main(String[] args) {
        test( str -> System.out.println(str) );
    }

Java编译器:哇,你真的是个天才,你真的做到了能省则省,我竟无言以对。 的确,我能看懂你要表达的意思,我可以正常的告诉jvm我们要做的事情。

程序员:既然如此就让我们把这种简写推广出去吧。

Java编译器:在这之前,你应该思考所有的输入参数的类型,以及所有的方法体,创造一种通用的规则。我们这个程序仅仅只是特例,请完善你的简写规则吧。

程序员:你的考虑很周全,让我再次整理一下具有一般性的规则:

  1. 输入参数的简写:

    1. 任何情况下,都可以省略输入参数的形参类型。

    2. 如果是两个或者以上的参数,不可以省略"()",参数之间以","间隔,例如 (a,b) → xxx的形式。如果写成了a,b → xxx 的形式,很显然,a有可能会被认为是方法形参中第一个参数,而认为后面的是一个整体的Lambda表达式了。

      如果只有一个参数,可以写成a → xxx的形式。

      如果没有参数,不能省略"()"了,可以写成() → xxx的形式。

  2. 方法体的简写:

    1. 如果包括两行代码或以上,不能省略"{}"和";",不然必定会引起歧义。
    2. 如果只有一行代码,可以同时省略"{}"和";",并且这行代码还可以上移一行,保持整洁。
    3. 如果一行代码也没有,不能省略"{}"。

Java编译器:的确是考虑的很周全。天衣无缝。不过其中我发现在没有参数或者没有代码的时候,你要求必须加上"()"和"{}",实际上,如果只要做到没有歧义的情况下,你完成可以不做这个要求,写成如下的形式(如果接口要求是无参的话),也完全没有任何的歧义呢。

typescript 复制代码
    public static void main(String[] args) {
        test(  ->  );
    }

程序员 :我仔细的想了想。我们必须保持形如 a → b的这代码形式,如果内容为空的,应该有占位符的,不然我总觉得哪里怪怪的,这应该很符合java这种静态编程语言的口味吧。

Java编译器:你的提议是不错的。先让大家熟悉Lambda表达式这种写法,也不能简化到啥也没有,不然到时候其他众程序员一脸懵逼。 不过也许以后,当大家都熟悉了之后,我们有可能终于迎来这种写法也说不定。

程序员:你说的也是一种可能。 不过我们暂时就这么愉快的决定吧。

总结,注意下面根本的规则:

  1. Lambda表达式的简写,在不至于引起歧义的条件下,可以能省则省。
  2. Lambda表达式需要始终保持形如a → b的代码形式。

由以上两条总的规则,引申出前述各种细节变化的规则。这就是Lambda表达式的简化中所包含的所有内容了。

待下一篇:函数式接口 与 jdk提供的默认函数式接口的用法

参考资源

Lambda表达式原理讲解

www.bilibili.com/video/BV1Kw...

www.bilibili.com/video/BV1k6...

反编译工具:jadx-gui

字节码查看工具:jclasslib bytecode viewer

相关推荐
DevOpsDojo14 分钟前
HTML语言的数据结构
开发语言·后端·golang
时韵瑶1 小时前
Scala语言的云计算
开发语言·后端·golang
Jerry Lau1 小时前
大模型-本地化部署调用--基于ollama+openWebUI+springBoot
java·spring boot·后端·llama
幼儿园老大*1 小时前
【系统架构】如何设计一个秒杀系统?
java·经验分享·后端·微服务·系统架构
fmdpenny1 小时前
Django的安装
后端·python·django
计算机-秋大田2 小时前
基于SSM的家庭记账本小程序设计与实现(LW+源码+讲解)
java·前端·后端·微信小程序·小程序·课程设计
Code侠客行2 小时前
Scala语言的循环实现
开发语言·后端·golang
Cikiss2 小时前
「全网最细 + 实战源码案例」设计模式——简单工厂模式
java·后端·设计模式·简单工厂模式
小诺大人2 小时前
【超详细】ELK实现日志采集(日志文件、springboot服务项目)进行实时日志采集上报
spring boot·后端·elk·logstash
Pandaconda3 小时前
【Golang 面试题】每日 3 题(三十九)
开发语言·经验分享·笔记·后端·面试·golang·go