2021年5月13日
JDK版本 1.8、Idea版本 2021.1、Kotlin版本 1.4.10、使用maven构建项目
实践出真知
概念
kotlin接口中的方法支持默认值,与Java8中提供的default关键字作用一致
例如我们定义一个Human
接口:
interface Human {
fun name() = "Human"
}
其中name()
方法指定了默认值,连default
都不用写,就可以让接口方法具有默认返回值。
起因
刚开始学kotlin的时候,我粗略的看了下,就没放在心上,直到实际上手,才发现用Java的class实现kotlin接口的时候,默认方法还是要显式实现的!
写个MaleHuman
实现该接口,发现IDEA报错 😱
看来,name()
仍是一个抽象方法,用kotlin代码实现的默认方法对Java编译器“不可见”,自然需要在Java代码中实现这个抽象方法啦!
赶忙用IDEA自动生成实现方法:
public class MaleHuman implements Human{
@NotNull
@Override
public String name() {
return Human.super.name();
}
}
OK,IDEA不报错了,跑个main方法先,然而。。。编译还是过不去 😮
探究
这时候想到,学习一门语言不仅要掌握语法结构,还要了解代码在经过编译之后的执行逻辑。
用jd-gui试着将字节码反编译成java代码看下
很明显,Kotlin编译器首先会为接口类生成一个DefaultImpls
静态内部类,这一点在编译输出目录中也能看到
然后将我们代码中写的方法体作为这个内部类的同名静态方法的方法体,不过还要传一个实现类的对象进去。
看到这里,再结合其他大佬的文章[1][2],可以得出反编译工具给出的“伪Java代码”少掉的部分,就是name()
方法被调用时,从抽象方法指向其内部类静态方法的逻辑
进一步推测:从Java代码中调用kotlin接口抽象方法时,编译器(javac)“看到的”只是我们调用了一个abstract
方法,这个方法并没有被default
修饰,也没有任何方法体,编译器无法获取更多的细节,干脆报错,中断编译过程。
当然还是有很多似懂非懂的地方,比如为何在kotlin实现类里调用接口默认方法时可以通过编译?JVM是如何处理kotlin接口的默认方法调用的?以及Kotlin为什么要采取这种方式生成字节码?
简单处理
这里就不再深挖了,既然知道kotlin编译器会为接口的默认方法生成一个内部类,那我们拿来用就可以了
public class MaleHuman implements Human{
@NotNull
@Override
public String name() {
return Human.DefaultImpls.name(this);
}
}
直接调用内部类DefaultImpls
的name()
方法,传入当前对象,然后编译运行,测试代码如下:
public static void main(String[] args) {
MaleHuman male = new MaleHuman();
System.out.println(male.name());
}
成功输出“Human”字符串。
通用方法
不过仔细想想,每一个Java实现类都要写这种模板代码,肯定不能满足我们对于代码整洁易维护的要求,还是查查有没有更标准的方式吧
这时看到官方标准库中提供了@JvmDefault
注解,正是我们需要的,马上加到name()
方法上试下,结果IDEA报错
意思是这个注解必须在编译时带上-jvm-target 1.8这个参数,对应到maven工程就是需要在kotlin插件中添加jvmTarget参数
<configuration>
<jvmTarget>1.8</jvmTarget>
</configuration>
加上后再编译,结果还是报错。。
和刚才的一样,都是少编译选项,不同的是这次少了-Xjvm-default,参考Stack Overflow上的答案[3],还是在maven的kotlin插件中添加args参数,附上完整版
<configuration>
<jvmTarget>1.8</jvmTarget>
<args>
<arg>-Xjvm-default=enable</arg>
</args>
</configuration>
(这里先设置成enable,下面再谈原因)
加上后编译不报错了,测试代码也能够正常输出“Human”字符串
看下编译后的代码
静态内部类不见了,抽象方法不见了,留给我们的只是单单一个的name()
方法,Java实现类也不需要显式实现kotlin接口的默认方法了!并且IDEA自动生成的代码也是支持的!
public class MaleHuman implements Human{
@NotNull
@Override
public String name() {
return Human.super.name();
}
}
后续
问题就此解决,kotlin接口中的默认方法终于也能像Java接口中的default
方法一样被调用了,但是探寻的脚步还不能停,俗话说饮水思源,我们上kotlin官网看看@JvmDefault
这个注解的文档[4]
此时发现官方文档中给这一页写了一个大大的Deprecated,让我们不要再用这个注解了,仔细阅读后觉得Kotlin开发团队这样做是有原因的,之前@JvmDefault
这个注解加上后,根据传递给编译器的参数不同,有两种实现方式:
编译器参数 | 作用 |
---|---|
-Xjvm-default=enable | 注解方法会编译成default 方法,内部类中的方法会被移除
|
-Xjvm-default=compatibility | 注解方法会编译成default 方法,内部类中的方法仍会保留
|
以上是1.2.40版本时官方给出的解决方案,基于方法注解来处理。
那么问题来了,如果一个接口中有多个默认方法呢?难道每个方法上都要加上这个注解才可以让Java代码正常调用吗?
于是官方在1.4版本中调整了编译器在处理接口默认方法时的作用范围,并给出了全新的编译器参数
编译器参数 | 作用 |
---|---|
-Xjvm-default=all | 所有接口的默认方法都会生成default 方法且不会生成内部类 |
-Xjvm-default=all-compatibility | 所有接口的默认方法都会生成default 方法但仍会生成内部类 |
-Xjvm-default=compatibility | 与all-compatibility作用一致,主要是为了兼容1.4版本之前的参数 |
还有一个新注解@JvmDefaultWithoutCompatibility
,是用在类上的,用来告诉编译器当前类或者接口中的默认方法无需生成DefaultImpls
内部类,应该是和上面的参数配合使用的。
新东西总要实践一下,定义一个接口Something
,有三个默认方法,一个抽象方法
interface Something {
fun a() = "a"
fun b() = true
fun c() = 3
fun d()
}
经过测试,新方式不再需要修改代码,直接在打包编译时指定参数即可!
- 当传递给编译器-Xjvm-default=all参数时
三个方法都有了默认实现,并且没有生成内部类
- 当传递给编译器-Xjvm-default=all-compatibility参数时
三个默认方法既出现在接口中,也出现在内部类里,而且内部类中的方法还加上了@Deprecated
注解
总结
- 避免在同一个工程中使用Java与Kotlin这两种语言编写代码。
尽管官方宣称Kotlin提供了与Java的“一流”的互操作性,但是毕竟是两种语言,从文件、语法、结构到插件、编译器,甚至代码运行时的行为都有差异,在开发过程中,一定会不可避免地出现各种各样的问题,所以水平有限的话还是让两者(物理上)离得越远越好;
- 如果实在避免不了,那就在模块设计上避免。
尽量只暴露方法或者API调用,而不是去继承、实现对方的类或者接口,这样可以将问题的发生场景最大限度地控制在可空性判断上。
参考文章
[1] 【Kotlin填坑-08】object 用法以及 Kotlin 的反编译
[3] kotlin - @JvmDefault and how add compiler option - Stack Overflow