你好,我是朱涛。

在前面两节课里,我们学了不少Kotlin的语法,其中有些语法是和Java类似的,比如数字类型、字符串;也有些语法是Kotlin所独有的,比如数据类、密封类。另外,我们还知道Kotlin和Java完全兼容,它们可以同时出现在一个代码工程当中,并且可以互相调用。

但是,这样就会引出一个问题:Java是如何识别Kotlin的独有语法的呢?比如,Java如何能够认识Kotlin里的“数据类”?

这就要从整个Kotlin的实现机制说起了。

所以,今天这节课,我会从Kotlin的编译流程出发,来带你探索这门语言的底层原理。在这个过程中,你会真正地理解,Kotlin是如何在实现灵活、简洁的语法的同时,还做到了兼容Java语言的。并且你在日后的学习和工作中,也可以根据今天所学的内容,来快速理解Kotlin的其他新特性。

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的编译流程之后,其实我们很容易就能想到办法了。

第一种思路,直接研究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原理后,让我们来看一些实际的例子吧!

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
}

请运用今天学到的知识来分析这个问题背后的原因。欢迎你在留言区分享你的答案和思路,我们下节课再见。

评论