gitbook/朱涛 · Kotlin编程第一课/docs/473529.md
2022-09-03 22:05:03 +08:00

17 KiB
Raw Permalink Blame History

03 | Kotlin原理编译器在幕后干了哪些“好事”

你好,我是朱涛。

在前面两节课里我们学了不少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。这里我们就来逐步分析一下

  • 对于变量a、c来说它们两个的类型是不可为空的所以无论如何都不能为null对于这种情况Kotlin编译器会直接将它们优化成原始类型。
  • 对于变量b、d来说它们两个的类型虽然是可能为空的但是它的值不为null并且编译器对上下文分析后发现这两个变量也没有在别的地方被修改。这种情况Kotlin编译器也会将它们优化成原始类型。
  • 对于变量e、f、g来说不论它们是val还是var只要它们被赋值过null那么Kotlin就无法对它们进行优化了。这背后的原因也很简单Java的原始类型不是对象只有对象才能被赋值为null。

我们可以用以下两个规律来总结下Kotlin对基础类型的转换规则

  • 只要基础类型的变量可能为空那么这个变量就会被转换成Java的包装类型。
  • 反之只要基础类型的变量不可能为空那么这个变量就会被转换成Java的原始类型。

好,接着我们再来看看另外一个例子。

接口语法的局限性

我在上节课带你了解了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接口默认实现的逻辑是被放在DefaultImpls当中的它成了静态内部类当中的一个静态方法DefaultImpls.walk()。
  • 箭头④代表Kotlin接口的实现类必须要重写接口当中的属性同时它仍然还是一个方法。
  • 箭头⑤即使Kotlin里的Man类没有实现walk()方法但是从Java的角度看它仍然存在walk()方法并且walk()方法将它的执行流程转交给了DefaultImpls.walk()并将this传入了进去。这样接口默认方法的逻辑就可以成功执行了。

到这里我们的答案就呼之欲出了。Kotlin接口当中的属性在它被真正实现之前本质上并不是一个真正的属性。因此Kotlin接口当中的属性它既不能真正存储任何状态也不能被赋予初始值因为它本质上还是一个接口方法

小结

到这里你应该就明白了你写的Kotlin代码最终都会被Kotlin编译器进行一次统一的翻译把它们变成Java能理解的格式。Kotlin的编译器在这个过程当中就像是一个藏在幕后的翻译官。

可以说Kotlin的每一个语法最终都会被翻译成对应的Java字节码。但如果你不去反编译你甚至感觉不到它在幕后做的那些事情。而正是因为Kotlin编译器在背后做的这些翻译工作才可以让我们写出的Kotlin代码更加简洁、更加安全。

我们举一些更具体的例子:

  • 类型推导我们写Kotlin代码的时候省略的变量类型最终被编译器补充回来了。
  • 原始类型虽然Kotlin没有原始类型但编译器会根据每一个变量的可空性将它们转换成“原始类型”或者“包装类型”。
  • 字符串模板编译器最终会将它们转换成Java拼接的形式。
  • when表达式编译器最终会将它们转换成类似switch case的语句。
  • 类默认publicKotlin当中被我们省略掉public最终会被编译器补充。
  • 嵌套类默认static我们在Kotlin当中的嵌套类默认会被添加static关键字将其变成静态内部类防止不必要的内存泄漏。
  • 数据类Kotlin当中简单的一行代码“data class Person(val name: String, val age: Int)”编译器帮我们自动生成很多方法getter()、setter()、equals()、hashCode()、toString()、componentN()、copy()。

图片

最后,我们还需要思考一个问题:**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
}

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