真身:你的 Kotlin 编译成了什么
这是全书最该先读的一章,因为它给后面二十四章提供地基。Kotlin 是一门糖很多的语言:data class 一行换六十行、?. 背后有真实的字节码、forEach { } 可能分配对象也可能不分配。糖让你写得快,也让你对代价失去感觉 —— 而所有「说不清」的地方,几乎都能靠一次反编译说清。这一章教你打开那个开关,然后带你看八段最日常的代码的真身。看完你会有点不适:有一半和你以为的不一样。
先学会开这个开关
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 的 final(val 只保证「没有 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 侧变得难吃」的问题。
| 注解 | 不加会怎样 | 加了之后 | 什么时候加 |
|---|---|---|---|
@JvmStatic | Java 要写 Api.Companion.create() | 生成真 static,Java 写 Api.create() | 库要给 Java 用;或者需要真 static(比如某些框架靠反射找 static 方法) |
@JvmField | Java 要写 obj.getName() | 暴露成公开字段,Java 写 obj.name | 纯数据类给 Java 用;或者性能敏感处想省掉 getter(意义有限,JIT 会内联) |
@JvmOverloads | Java 必须传全部参数 —— 默认参数在 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 脱糖之后的样子。属性变成了 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 最锋利的地方:类型系统。第一站是空安全 —— 以及它那个所有人都在用、但很少有人意识到的漏洞。