你好,我是朱涛。
在前面两节课里,我们学了不少Kotlin的语法,其中有些语法是和Java类似的,比如数字类型、字符串;也有些语法是Kotlin所独有的,比如数据类、密封类。另外,我们还知道Kotlin和Java完全兼容,它们可以同时出现在一个代码工程当中,并且可以互相调用。
但是,这样就会引出一个问题:Java是如何识别Kotlin的独有语法的呢?比如,Java如何能够认识Kotlin里的“数据类”?
这就要从整个Kotlin的实现机制说起了。
所以,今天这节课,我会从Kotlin的编译流程出发,来带你探索这门语言的底层原理。在这个过程中,你会真正地理解,Kotlin是如何在实现灵活、简洁的语法的同时,还做到了兼容Java语言的。并且你在日后的学习和工作中,也可以根据今天所学的内容,来快速理解Kotlin的其他新特性。
在介绍Kotlin的原理细节之前,我们先从宏观上看看它是如何运行在电脑上的,这其实就涉及到它的编译流程。
那么首先,你需要知道一件事情:你写出的Kotlin代码,电脑是无法直接理解的。即使是最简单的println("Hello world.")
,你将这行代码告诉电脑,它也是无法直接运行的。这是因为,Kotlin的语法是基于人类语言设计的,电脑没有人的思维,它只能理解二进制的0和1,不能理解println到底是什么东西。
因此,Kotlin的代码在运行之前,要先经过编译(Compile)。举个例子,假如我们现在有一个简单的Hello World程序:
println("Hello world.")
经过编译以后,它会变成类似这样的东西:
LDC "Hello world."
INVOKESTATIC kotlin/io/ConsoleKt.println (Ljava/lang/Object;)V
上面两行代码,其实是Java的字节码。对,你没看错,Kotlin代码经过编译后,最终会变成Java字节码。这给人的感觉就像是:我说了一句中文,编译器将其翻译成了英文。而Kotlin和Java能够兼容的原因也在于此,Java和Kotlin本质上是在用同一种语言进行沟通。
英语被看作人类世界的通用语言,那么Kotlin和Java用的是什么语言呢?没错,它们用的就是Java字节码。Java字节码并不是为人类设计的语言,它是专门给JVM执行的。
JVM,也被称作Java虚拟机,它拿到字节码后就可以解析出字节码的含义,并且在电脑里输出打印“Hello World.
”。所以,你可以先把Java虚拟机理解为一种执行环境。回想我们在第一节课开头所安装的JDK,就是为了安装Java的编译器和Java的运行环境。
不过现在,你可能会有点晕头转向,还是没有搞清楚Kotlin的这个编译流程具体是怎么回事儿,也不清楚Kotlin和Java之间到底是什么关系。别着急,我们一起来看看下面这张图:
这张图的内容其实非常直观,让我们从上到下,将整个过程再梳理一遍。
首先,我们写的Kotlin代码,编译器会以一定的规则将其翻译成Java字节码。这种字节码是专门为JVM而设计的,它的语法思想和汇编代码有点接近。
接着,JVM拿到字节码以后,会根据特定的语法来解析其中的内容,理解其中的含义,并且让字节码运行起来。
那么,JVM到底是如何让字节码运行起来的呢?其实,JVM是建立在操作系统之上的一层抽象运行环境。举个简单的例子,Windows系统当中的程序是无法直接在Mac上面运行的。但是,我们写的Java程序却能同时在Windows、Mac、Linux系统上运行,这就是因为JVM在其中起了作用。
JVM定义了一套字节码规范,只要是符合这种规范的,都可以在JVM当中运行。至于JVM是如何跟不同的操作系统打交道的,我们不管。
还有一个更形象的例子,JVM就像是一个精通多国语言的翻译,我们只需要让JVM理解要做的事情,不管去哪个国家都不用关心,翻译会帮我们搞定剩下的事情。
最后,是计算机硬件。常见的计算机硬件包括台式机和笔记本电脑,这就是我们所熟知的东西了。
在了解了Kotlin的编译流程之后,其实我们很容易就能想到办法了。
第一种思路,直接研究Kotlin编译后的字节码。如果我们能学会Java字节码的语法规则,那么就可以从字节码的层面去分析Kotlin的实现细节了。不过,这种方法明显吃力不讨好,即使我们学会了Java字节码的语法规则,对于一些稍微复杂一点的代码,我们分析起来也会十分吃力。
因此,我们可以尝试另一种思路:将Kotlin转换成字节码后,再将字节码反编译成等价的Java代码。最终,我们去分析等价的Java代码,通过这样的方式来理解Kotlin的实现细节。虽然这种方式不及字节码那样深入底层,但它的好处是足够直观,也方便我们去分析更复杂的代码逻辑。
这个过程看起来会有点绕,让我们用一个流程图来表示:
我们将其分为两个部分来看。先看红色虚线框外面的图,这是一个典型的Kotlin编译流程,Kotlin代码变成了字节码。另一个部分,是红色虚线框内部的图,我们用反编译器将Java字节码翻译成Java代码。经过这样一个流程后,我们就能得到和Kotlin等价的Java代码。
而这样,我们也可以得出这样一个结论,Kotlin的“println
”和Java的“System.out.println
”是等价的。
println("Hello world.") /*
编译
↓ */
LDC "Hello world."
INVOKESTATIC kotlin/io/ConsoleKt.println (Ljava/lang/Object;)V /*
反编译
↓ */
String var0 = "Hello world.";
System.out.println(var0);
好了,思想和流程我们都清楚了,具体我们应该要怎么做呢?有以下几个步骤。
第一步,打开我们要研究的Kotlin代码。
第二步,依次点击菜单栏:Tools -> Kotlin -> Show Kotlin Bytecode。
这时候,我们在右边的窗口中就可以看见Kotlin对应的字节码了。但这并不是我们想要的,所以要继续操作,将字节码转换成Java代码。
第三步,点击画面右边的“Decompile”按钮。
最后,我们就能看见反编译出来的Java文件“Test_decompiled.java”。显而易见,main函数中的代码和我们前面所展示的是一致的:
OK,在知道如何研究Kotlin原理后,让我们来看一些实际的例子吧!
不知道你还记不记得,之前我在第1讲中给你留过一个思考题:
虽然Kotlin在语法层面摒弃了“原始类型”,但有的时候为了性能考虑,我们确实需要用“原始类型”。这时候我们应该怎么办?
那么现在,我们已经知道了Kotlin与Java直接存在某种对应关系,所以要弄清楚这个问题,我们只需要知道“Kotlin的Long”与“Java long/Long”是否存在某种联系就可以了。
注意:Java当中的long是原始类型,而Long是对象类型(包装类型)。
说做就做,我们以Kotlin的Long类型为例。
// kotlin 代码
// 用 val 定义可为空、不可为空的Long,并且赋值
val a: Long = 1L
val b: Long? = 2L
// 用 var 定义可为空、不可为空的Long,并且赋值
var c: Long = 3L
var d: Long? = 4L
// 用 var 定义可为空的Long,先赋值,然后改为null
var e: Long? = 5L
e = null
// 用 val 定义可为空的Long,直接赋值null
val f: Long? = null
// 用 var 定义可为空的Long,先赋值null,然后赋值数字
var g: Long? = null
g = 6L
这段代码的思路,其实就是将Kotlin的Long类型可能的使用情况都列举出来,然后去研究代码对应的Java反编译代码,如下所示:
// 反编译后的 Java 代码
long a = 1L;
long b = 2L;
long c = 3L;
long d = 4L;
Long e = 5L;
e = (Long)null;
Long f = (Long)null;
Long g = (Long)null;
g = 6L;
可以看到,最终a、b、c、d被Kotlin转换成了Java的原始类型long;而e、f、g被转换成了Java里的包装类型Long。这里我们就来逐步分析一下:
我们可以用以下两个规律,来总结下Kotlin对基础类型的转换规则:
好,接着我们再来看看另外一个例子。
我在上节课,带你了解了Kotlin面向对象编程中的“接口”这个概念,其中我给你留了一个问题,就是:
接口的“成员属性”,是Kotlin独有的。请问它的局限性在哪?
那么在这里,我们就通过这个问题,来分析下Kotlin接口语法的实现原理,从而找出它的局限性。下面给出的,是一段接口代码示例:
// Kotlin 代码
interface Behavior {
// 接口内可以有成员属性
val canWalk: Boolean
// 接口方法的默认实现
fun walk() {
if (canWalk) {
println(canWalk)
}
}
}
private fun testInterface() {
val man = Man()
man.walk()
}
那么,要解答这个问题,我们也要弄清楚Kotlin的这两个特性,转换成对应的Java代码是什么样的。
// 等价的 Java 代码
public interface Behavior {
// 接口属性变成了方法
boolean getCanWalk();
// 方法默认实现消失了
void walk();
// 多了一个静态内部类
public static final class DefaultImpls {
public static void walk(Behavior $this) {
if ($this.getCanWalk()) {
boolean var1 = $this.getCanWalk();
System.out.println(var1);
}
}
}
}
从上面的Java代码中我们能看出来,Kotlin接口的“默认属性”canWalk,本质上并不是一个真正的属性,当它转换成Java以后,就变成了一个普通的接口方法getCanWalk()。
另外,Kotlin接口的“方法默认实现”,它本质上也没有直接提供实现的代码。对应的,它只是在接口当中定义了一个静态内部类“DefaultImpls”,然后将默认实现的代码放到了静态内部类当中去了。
我们能看到,Kotlin的新特性,最终被转换成了一种Java能认识的语法。
我们再具体来看看接口使用的细节:
// Kotlin 代码
class Man: Behavior {
override val canWalk: Boolean = true
}
以上代码中,我们定义了一个Man类,它实现了Behavior接口,与此同时它也重写了canWalk属性。另外,由于Behavior接口的walk()方法已经有了默认实现,所以Man可以不必实现walk()方法。
那么,Man类反编译成Java后,会变成什么样子呢?
// 等价的 Java 代码
public final class Man implements Behavior {
private final boolean canWalk = true;
public boolean getCanWalk() {
// 关键点 ①
return this.canWalk;
}
public void walk() {
// 关键点 ②
Behavior.DefaultImpls.walk(this);
}
}
可以看到,Man类里的getCanWalk()实现了接口当中的方法,从注释①那里我们注意到,getCanWalk()返回的还是它内部私有的canWalk属性,这就跟Kotlin当中的逻辑“override val canWalk: Boolean = true”对应上了。
另外,对于Man类当中的walk()方法,它将执行流程交给了“Behavior.DefaultImpls.walk()”,并将this作为参数传了进去。这里的逻辑,就可以跟Kotlin接口当中的默认方法逻辑对应上来了。
看完这一堆的代码之后,你的脑子可能会有点乱,我们用一张图来总结一下前面的内容吧:
以上图中一共有5个箭头,它们揭示了Kotlin接口新特性的实现原理,让我们一个个来分析:
到这里,我们的答案就呼之欲出了。Kotlin接口当中的属性,在它被真正实现之前,本质上并不是一个真正的属性。因此,Kotlin接口当中的属性,它既不能真正存储任何状态,也不能被赋予初始值,因为它本质上还是一个接口方法。
到这里,你应该就明白了:你写的Kotlin代码,最终都会被Kotlin编译器进行一次统一的翻译,把它们变成Java能理解的格式。Kotlin的编译器,在这个过程当中就像是一个藏在幕后的翻译官。
可以说,Kotlin的每一个语法,最终都会被翻译成对应的Java字节码。但如果你不去反编译,你甚至感觉不到它在幕后做的那些事情。而正是因为Kotlin编译器在背后做的这些翻译工作,才可以让我们写出的Kotlin代码更加简洁、更加安全。
我们举一些更具体的例子:
最后,我们还需要思考一个问题:Kotlin编译器一直在幕后帮忙做着翻译的好事,那它有没有可能“好心办坏事”?这个悬念留着,我们在第8讲再探讨。
在上节课当中,我们曾提到过,为Person类增加isAdult属性,我们要通过自定义getter来实现,比如说:
class Person(val name: String, var age: Int) {
val isAdult
get() = age >= 18
}
而下面这种写法则是错误的:
class Person(val name: String, var age: Int) {
val isAdult = age >= 18
}
请运用今天学到的知识来分析这个问题背后的原因。欢迎你在留言区分享你的答案和思路,我们下节课再见。
评论