炉 I · 装炉CH 02进度 02/26

真身:你的 Kotlin 编译成了什么

这是全书最该先读的一章,因为它给后面二十四章提供地基。Kotlin 是一门糖很多的语言data class 一行换六十行、?. 背后有真实的字节码、forEach { } 可能分配对象也可能不分配。糖让你写得快,也让你对代价失去感觉 —— 而所有「说不清」的地方,几乎都能靠一次反编译说清。这一章教你打开那个开关,然后带你看八段最日常的代码的真身。看完你会有点不适:有一半和你以为的不一样。

反编译字节码Java 互操作@Jvm* 注解

先学会开这个开关

Android Studio / IntelliJ 里,打开任意一个 .kt 文件,然后:

Tools → Kotlin → Show Kotlin Bytecode → 点右上角 [Decompile]

左边是你的 Kotlin,右边是把字节码反编译回 Java 的结果。注意:那不是「Kotlin 的 Java 版本」,而是你的代码真正的样子 —— JVM 只认识那个。

这个开关的意义不在于「学会读字节码」(你不需要),而在于它是所有争论的终审法庭。「这样写会不会多分配一个对象?」「这个 lambda 有没有被内联?」「companion 里的常量到底是不是 static?」—— 别猜,反编译一次,五秒钟。

下面这台反编译台里,我把八段最日常的代码和它们的真身摆在一起。一个一个点开看,不要跳。

三条你必须记住的推论

八段代码看下来,有三条推论会在后面的章节里反复出现。现在就把它们钉死。

推论一:扩展函数是静态方法,所以它没有多态

这条在第 1 章的 Q3 里已经咬过你一次。既然 fun Animal.name() 编译成 static String name(Animal receiver),那么:

  • 它按静态类型分派。变量声明成什么类型,就调哪个扩展 —— 运行时装的是谁的实例,无关紧要。
  • 它不能被 override子类想「重写」父类的扩展函数?没有这回事。
  • 成员函数永远赢。如果类里有同名同参的成员函数,你的扩展会被静默忽略 —— 这意味着给第三方类写扩展是有时间炸弹的:哪天那个库自己加了同名方法,你的扩展就悄无声息地失效了,而且编译不会报错。
  • 它访问不了 private因为它压根不在那个类里 —— 所以扩展函数不破坏封装,这是它的优点。

推论二:属性不是字段,字段只是编译器的实现细节

Kotlin 里没有「字段」这个语言概念,只有属性(property)。你写 val id = "x",编译器可能给你生成一个后备字段(backing field);你写 val double get() = count * 2,它就不生成 —— 因为没有东西需要存。

这解释了三件本来很怪的事:为什么接口里可以声明 val(只要它是个 getter 就行,接口不需要存储);为什么 val 不等于 Java 的 finalval 只保证「没有 setter」,一个每次返回不同值的 val 完全合法);以及为什么 Java 侧访问 Kotlin 属性要写 getName() 而不是 .name —— 除非你加 @JvmField

推论三:lambda 有时是对象,有时什么都不是

捕获了外部变量的 lambda → 每次执行 new 一个 FunctionN 对象。没捕获的 lambda → 编译成单例,复用。而传给 inline 函数的 lambda → 根本不生成对象,直接粘贴代码

标准库里的 forEach / map / filter / let / apply / run / use / synchronized 全是 inline 的 —— 这就是为什么 Kotlin 敢让你在热路径上随手写函数式代码,而 Java 8 的 Stream 至今背着对象分配的包袱。第 9 章会把这件事讲透。

◆ 锭 · 一句话总结这三条

Kotlin 的每一颗糖都有价签,而反编译就是那张价签。扩展函数的价签是「没有多态」,属性的价签是「Java 侧要走 getter」,lambda 的价签是「可能分配对象,除非内联」。用糖不是问题,不知道价签才是。

那一串 @Jvm* 注解,到底在修什么

你在别人的代码里见过 @JvmStatic@JvmField@JvmOverloads@JvmName,可能也照抄过。现在你能真正理解它们了 —— 它们全都是在修「Kotlin 的糖在 Java 侧变得难吃」的问题

注解不加会怎样加了之后什么时候加
@JvmStaticJava 要写 Api.Companion.create()生成真 static,Java 写 Api.create()库要给 Java 用;或者需要真 static(比如某些框架靠反射找 static 方法)
@JvmFieldJava 要写 obj.getName()暴露成公开字段,Java 写 obj.name纯数据类给 Java 用;或者性能敏感处想省掉 getter(意义有限,JIT 会内联)
@JvmOverloadsJava 必须传全部参数 —— 默认参数在 Java 侧完全无效为每个默认参数生成一个重载自定义 View 的构造函数(Android 框架用 Java 反射调它);库的公开 API
@JvmName同签名冲突(比如两个 fun List<X>.sum() 因为擦除后签名一样)改生成的方法名泛型擦除撞车时;或者给 Java 侧一个更顺口的名字
⚠ 炉渣 · @JvmOverloads 与自定义 View

这是每个 Android 开发迟早会踩的坑。你写了一个自定义 View:

class MyView(ctx: Context, attrs: AttributeSet? = null) : View(ctx, attrs)

在 Kotlin 里用它,一切正常。然后你把它写进 XML 布局 —— 运行时崩溃NoSuchMethodException: MyView(Context, AttributeSet)

因为 Android 的 LayoutInflater 是用反射去找 (Context, AttributeSet) 这个两参构造函数的。而 Kotlin 的默认参数编译出来只有一个真构造函数(加一个带位掩码的合成桥),根本不存在「两参重载」这个东西。反射找不到,直接炸。

解法:@JvmOverloads constructor,让编译器真的生成那一串重载。这条规则的适用范围比 View 更广:任何被反射调用的 Kotlin 代码,都不能指望默认参数生效。

反编译还能帮你回答的几个问题

把这个开关当成调试工具,下次遇到这些争论直接开庭:

  • 「这个循环里的 lambda 会不会分配对象?」→ 看有没有 new Function1。是 inline 函数就没有。
  • internal 在 Java 侧真的不可见吗?」→ 反编译看看:它编译成 public,只是名字被混淆(mangling)成了 foo$module_name。Java 代码调它,只是丑到没人愿意。internal 是 Kotlin 编译器层面的约定,不是 JVM 层面的保护。
  • 「这个 when 编译成什么?」→ 对连续整数/枚举是 tableswitch(O(1) 跳转表),对 sealed 类型是一串 instanceof(顺序判断)。所以把最常命中的分支写在前面,理论上是有意义的 —— 但绝大多数情况下这点差别完全不值得你为它牺牲可读性(第 22 章会给你判断标准)。
  • 「字符串模板 "$a-$b" 慢吗?」→ 编译成 StringBuilder 链(新版 JVM 上是 invokedynamic + StringConcatFactory)。和你手写拼接一模一样,放心用。
▸ 旧模具 · 为什么 Java 调 Kotlin 总是别扭

现在你有了完整的答案:Java 看到的是 Kotlin 脱糖之后的样子。属性变成了 getX()/setX();companion 变成了 Companion 嵌套类;默认参数变成了带位掩码的 $default 桥;internal 变成了名字带 $ 的 public;suspend 函数变成了多一个 Continuation 参数的方法(第 14 章);value class 变成了底层类型(第 6 章)。

所以:如果你的模块要被 Java 调用,它的公开 API 应该是「脱糖后依然好看」的。—— 别在公开 API 上用默认参数(除非加 @JvmOverloads)、别指望 Java 侧尊重你的 internal、别把 value class 放在公开签名上。

反过来,如果这个模块只被 Kotlin 调用(今天大多数 Android 项目都是这样),上面这些顾虑一条都不成立,尽情用糖。判据是「谁调用你」,不是「哪个更纯粹」。

顺便:Kotlin 代码在 Android 上还要再走一程

你的 Kotlin 编译成 JVM 字节码之后,在 Android 上还要经过 R8(压缩、优化、混淆、脱糖),最后变成 DEX。这意味着反编译看到的「多了一个对象分配」,有时会被 R8 优化掉。

所以严格来说:反编译告诉你的是「编译器生成了什么」,不是「设备上最终跑了什么」。对于判断语义(会不会装箱、有没有多态、Java 能不能调)它是终审法庭;但对于判断性能,最终裁判是 profiler 和 benchmark,不是你的肉眼 —— 这条纪律第 22 章会重申。

✚ 动手锻打

1. 今天就打开这个开关。随便挑你项目里一个 ViewModel,Show Kotlin Bytecode → Decompile,从头读一遍。找出:有几个 new Function(lambda 对象)?有几处 Intrinsics.checkNotNullParameter(空检查守卫)?

2. 找到你项目里的自定义 View,检查构造函数有没有 @JvmOverloads。如果没有但它在 XML 里被用了 —— 恭喜,你有一个正在等待被触发的崩溃(或者有人已经手写了三个构造函数重载来绕开它)。

3. 挑一个你写过的 data class,检查它的主构造里有没有不该参与相等性判断的字段(时间戳、请求 ID、UI 状态标记)。如果有,它正在悄悄污染你的 distinct()Set 和 Compose 的重组判断。

4. 写一个 fun Any?.orEmpty() 之类的扩展函数,然后在同名成员函数存在的类上调用它 —— 亲手验证一次「成员函数永远赢」,让这条规则从知识变成肌肉记忆。

地基打完了。下一炉(熔炼)进入 Kotlin 最锋利的地方:类型系统。第一站是空安全 —— 以及它那个所有人都在用、但很少有人意识到的漏洞。