Kotlin 的 init 到底咋回事儿?

前几天中午午休,我同事跑过来问我:那个 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 参数判断用户是否成年。
  • nameage 这两个属性完成赋值之后,这段初始化逻辑会立刻执行。

你会发现,即使 isAdultval 的,也能够在 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 块变成问题源头,比较稳妥的做法包括:

  1. 避免副作用: 尽量让 init 块保持纯粹,不要在里面执行外部操作或复杂流程。
  2. 保持简单: 把它限制在和主构造函数参数直接相关的、清晰可控的初始化工作里。
  3. 使用工厂方法: 如果初始化过程本身比较复杂,可以通过工厂方法或 builder 把创建过程组织得更可控。
  4. 合理使用次构造函数: 某些特定初始化场景,更适合交给次构造函数,而不是让 init 块承担太多职责。
  5. 延迟初始化: 借助惰性初始化或依赖注入,把昂贵或晚到的依赖延后处理。或者依赖类似 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;
    }
}

它的运行方式可以概括为下面几点:

  1. 直接内联进构造函数: 并不存在单独的 init() 方法。init 块的整个内容会按顺序被复制进 Java 构造函数,位置在 super() 调用之后、主构造函数属性(如 this.username = username)初始化之后。
  2. 执行顺序清晰可验证: 反编译结果验证了 Kotlin 的执行顺序规则。对象创建时,主构造函数中声明的属性先初始化,然后各个 init 块再按源码中的顺序执行。
  3. 多个 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 的运行机制,查看字节码绝对是一个不可多得的绝佳工具!

相关推荐
黄林晴2 小时前
Compose 四月稳定版来袭,测试、触控、预览工具全线革新
android
克里斯蒂亚诺更新2 小时前
uniapp适配H5和Android-apk实现获取当前位置经纬度并调用接口
android·前端·uni-app
咚咚王者2 小时前
MySQL 导出脚本
android·mysql·adb
Fate_I_C2 小时前
Android Navigation的使用说明
android·kotlin·navigation
高林雨露2 小时前
Java开发转kotlin
java·kotlin
JJay.2 小时前
高通 GAIA v1/v2/v3 共存时,Android 端该怎么做协议分层
android
哑巴湖小水怪12 小时前
Android的架构是四层还是五层
android·架构
2501_9160088914 小时前
深入解析iOS应用启动性能优化策略与实践
android·ios·性能优化·小程序·uni-app·cocoa·iphone
美狐美颜SDK开放平台15 小时前
短视频/直播双场景美颜SDK开发方案:接入、功能、架构详解
android·ios·美颜sdk·第三方美颜sdk·视频美颜sdk