
前几天中午午休,我同事跑过来问我:那个 Kotlin 的初始化我有点糊涂,相比较于 Java,多了个 init 块,这个是咋回事儿呢?
我说:你别急,我给你写篇文章,你等着...
开篇
各位 Kotlin 吴彦祖,周五好!
init 块是 Kotlin 用来初始化类的一种特殊结构。每次创建类的新实例时,它都会执行。它的执行时机位于主构造函数参数传入之后、类主体其余逻辑执行之前。
因此,init 块非常适合承载那些依赖构造函数参数的初始化逻辑,或者那些超出简单属性赋值范围的额外准备工作。
关键特征
下面这些特征,正是 init 块既实用又值得深入理解的原因:
init块会按照它们在类中出现的顺序执行,而且早于任何次构造函数主体。- 它可以直接访问主构造函数参数,因此很适合编写稍复杂一些的初始化逻辑。
- 一个类中允许定义多个
init块,它们会严格按照声明顺序运行。
下面这个例子展示了如何使用 init 块,根据构造参数去初始化一个属性:
kotlin
class User(val name: String, val age: Int) {
val isAdult: Boolean
init {
isAdult = age >= 18
println("User initialized: $name, age: $age, isAdult: $isAdult")
}
}
val user = User("RockByte", 35)
// output
// User initialized: RockByte, age: 35, isAdult: true
在这个例子里:
init块会根据age参数判断用户是否成年。- 当
name和age这两个属性完成赋值之后,这段初始化逻辑会立刻执行。
你会发现,即使 isAdult 是 val 的,也能够在 init 中初始化。
init 的好处
init 块的价值在于:它让你能够在对象创建的第一时间运行初始化逻辑,又不会把构造函数本身塞得过于臃肿。
主要有以下好处:
- 能把依赖构造函数参数的初始化逻辑集中在一起。
- 能避免把同样的初始化代码重复写进多个次构造函数里。
- 能让构造函数保持清晰,把注意力集中在属性声明和赋值上。
需要注意
当然,在使用 init 块时,还有几个很重要的行为细节需要理解,否则初始化逻辑可能不会按你的预期工作:
init块不能访问用lateinit声明的属性,因为执行到这个阶段时,这些属性还没有初始化。- 如果类里有多个
init块,它们会按源码中的出现顺序依次执行。 - 无论最终走的是哪个构造函数路径,
init块都会执行,因此它天然能保证初始化行为的一致性。
总结
init 块是一种既灵活又整洁的方式,可用来处理 Kotlin 类中的复杂初始化逻辑。
正因为如此,它才会成为构造函数与对象初始化机制中的核心特性之一。
进阶:有哪些缺点
虽然 init 块在处理初始化逻辑时非常方便,但它也并非没有代价。
特别是一旦它开始承载副作用或较复杂的操作,就容易引入一系列风险。常见问题包括下面这些。
1. 非预期副作用
init 块会在对象实例化时自动执行。如果这里面放了带副作用的逻辑,比如打印日志、发网络请求、修改共享资源,那么它就可能在你没预料到的时机被触发。
这样的问题尤其难排查,因为类可能会在某些不显眼的场景里被反复实例化。
kotlin
class NetworkManager(apiKey: String) {
init {
println("Initializing NetworkManager")
// 在这里发起 API 调用,可能会在实例化期间引发问题
}
}
例如,如果 NetworkManager 是通过依赖注入创建的,或者在应用启动阶段被初始化,那么这些 API 调用、日志输出之类的副作用就可能变成完全没必要的额外行为。
2. 灵活性有限
init 块是在主构造函数执行后立刻运行的,你无法把它延后。如果初始化逻辑依赖额外输入,或者依赖那些在实例创建时还不可用的对象,这种立即执行的机制就会成为限制。
kotlin
class User(val name: String, val age: Int) {
lateinit var preferences: String
init {
println("User initialized with preferences: $preferences") // 会抛出异常
}
}
这里的问题很直接:preferences 还没来得及初始化,init 块却已经开始访问它了,因此会报错。
3. 耦合过深
如果把较重的初始化逻辑都塞进 init 块,类就会越来越依赖它的构造参数。一旦这段逻辑逐渐复杂,或者开始依赖外部资源,就会让类的测试成本和维护成本一起上升。
kotlin
class ReportGenerator(val reportType: String) {
init {
println("Generating $reportType report")
// 这里的复杂逻辑会让测试变得困难
}
}
4. 性能影响
如果 init 块里包含重计算或高成本操作,例如数据库查询、文件 I/O,或者复杂循环,那么对象创建本身就会变慢。尤其是在短时间内批量创建对象时,这种性能损耗会更明显。
5. 调试困难
init 块中的错误通常也不太好排查,因为它是在对象创建过程中自动执行的。如果其中还夹杂了隐式依赖或默认假设,问题定位会进一步变得困难。
kotlin
class Example(val value: Int) {
init {
println("Value is: ${value / 0}") // 会在对象创建期间导致崩溃
}
}
像这里的除零错误,会在实例化的瞬间直接触发,这往往会让你更难第一时间看清真正的根因。
如何降低这些缺点的影响
为了避免 init 块变成问题源头,比较稳妥的做法包括:
- 避免副作用: 尽量让
init块保持纯粹,不要在里面执行外部操作或复杂流程。 - 保持简单: 把它限制在和主构造函数参数直接相关的、清晰可控的初始化工作里。
- 使用工厂方法: 如果初始化过程本身比较复杂,可以通过工厂方法或
builder把创建过程组织得更可控。 - 合理使用次构造函数: 某些特定初始化场景,更适合交给次构造函数,而不是让
init块承担太多职责。 - 延迟初始化: 借助惰性初始化或依赖注入,把昂贵或晚到的依赖延后处理。或者依赖类似
initialize这样的主动初始化函数。
小结
init 块确实是 Kotlin 中一个非常顺手的初始化工具,但它也要求使用者保持节制。只有在边界清晰、逻辑简单的情况下使用它,才能真正避免副作用、耦合和性能问题。
进阶:Java 字节码
要想知道 init 块是真的如何工作的,那么看 Java 字节码是最方便的手段,我们在之前的很多文章中已经见识过了。
在 Kotlin 里,一个类可以同时拥有一个主构造函数和一个或多个次构造函数。
为了把初始化逻辑纳入主构造流程,Kotlin 提供了 init 关键字。
带有 init 前缀的初始化块,本质上就是"对象在创建时要顺手执行的一段代码"。它是主构造函数能力的补充,让更复杂的初始化流程也能自然表达出来。
不过,从反编译后的字节码来看,init 块在 JVM 上并不会变成一个独立方法。Kotlin 编译器真正做的事情是:把所有 init 块中的代码直接内联到每一个生成出来的 Java 构造函数里。
也就是说,初始化逻辑并不是被"单独调用",而是被直接织入对象创建路径中。
先看一个简单例子。这个 Kotlin 类包含主构造函数、一个属性,以及一个负责校验和打印日志的 init 块:
kotlin
class User(val username: String) {
val creationTimestamp: Long
// 初始化块
init {
println("User object is being initialized...")
require(username.isNotBlank()) { "Username cannot be blank." }
this.creationTimestamp = System.currentTimeMillis()
println("User '$username' created at $creationTimestamp.")
}
}
在这个例子里,每次创建 User(例如 val user = User("Alice"))时,init 块中的代码都会被执行。
把这个 Kotlin 类编译后再反编译成 Java,就会看到 init 块中的逻辑直接出现在构造方法中:
java
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
public final class User {
@NotNull
private final String username;
private final long creationTimestamp;
// Java 字节码中的主构造函数就是 <init> 方法
public User(@NotNull String username) {
Intrinsics.checkNotNullParameter(username, "username");
super(); // 调用父类构造函数
this.username = username;
// --- 来自 'init' 块的代码开始 ---
System.out.println("User object is being initialized...");
// 'require' 调用会被翻译成一次检查和一个异常
boolean isBlank = StringsKt.isBlank((CharSequence)this.username);
if (isBlank) {
String message = "Username cannot be blank.";
throw new IllegalArgumentException(message.toString());
}
this.creationTimestamp = System.currentTimeMillis();
String message = "User '" + this.username + "' created at " +
this.creationTimestamp + ".";
System.out.println(message);
// --- 来自 'init' 块的代码结束 ---
}
@NotNull
public final String getUsername() {
return this.username;
}
public final long getCreationTimestamp() {
return this.creationTimestamp;
}
}
它的运行方式可以概括为下面几点:
- 直接内联进构造函数: 并不存在单独的
init()方法。init块的整个内容会按顺序被复制进 Java 构造函数,位置在super()调用之后、主构造函数属性(如this.username = username)初始化之后。 - 执行顺序清晰可验证: 反编译结果验证了 Kotlin 的执行顺序规则。对象创建时,主构造函数中声明的属性先初始化,然后各个
init块再按源码中的顺序执行。 - 多个
init块和次构造函数也遵循同样原则:- 如果存在多个
init块,编译器会按照它们在源码中的定义顺序,把代码依次内联进构造函数。 - 如果类还定义了次构造函数。由于次构造函数必须委托给主构造函数(直接或间接),所以
init代码会在主构造函数中执行完毕,这样保证了初始化流程的完整性、正确性。
- 如果存在多个
那么,如果一个类同时拥有 init 块和次构造函数,会发生什么?来看下面这个简单例子:
kotlin
private class Message(val content: String) {
init {
println("Initializer block running.")
}
// 次构造函数
constructor() : this("Default Message") {
println("Secondary constructor body running.")
println("求求你们真的关注一下 RockByte 公众号吧")
}
}
这个示例反编译成 Java 字节码后,会变成下面这样:
java
public final class Message {
// ... content 属性
// 主构造函数
public Message(String content) {
super();
this.content = content;
// init 块在这里
System.out.println("Initializer block running.");
}
// 次构造函数
public Message() {
// 委托给主构造函数。这会运行 init 块。
this("Default Message");
// 次构造函数主体随后才运行。
System.out.println("Secondary constructor body running.");
System.out.println("求求你们真的关注一下 RockByte 公众号吧");
}
}
这段反编译结果说明得很清楚:次构造函数最终调用了主构造函数 this(...),由于 init 块已经被织入主构造函数中,它总会作为主构造链的一部分被提前执行,而且一定发生在次构造函数主体真正开始运行之前。
这也说明 Kotlin 编译器在引入这些语言特性的同时,依然很好地保持了对 Java 的良好兼容性。
一点总结
init 块虽然只是 Kotlin 语法糖的一部分,但它非常巧妙地填补了主构造函数无法编写复杂逻辑的空白。
从字节码层面看,它的底层实现依然遵循 JVM 的构造规则(按需内联、保证顺序)。
在使用时,只要我们克制住向 init 块里塞入过度复杂逻辑的冲动,它就能让我们的代码保持既整洁又安全。
同时,通过反编译查看 Java 字节码来探究这些语法糖的底层逻辑,真的会让人受益良多。如果你想剥开表象,去真正深入理解 Kotlin 的运行机制,查看字节码绝对是一个不可多得的绝佳工具!