
object 是 Kotlin 中一种专门用来创建单例实例的构造。
也就是说,一个 object 在程序里只会存在一个实例。借助这个特性,你可以在定义类的同时直接创建它的实例。
它非常适合用来编写工具类、保存全局常量,或者在不引入额外样板代码的前提下,以线程安全的方式管理共享资源。
关键特性
Kotlin 的 object 声明不仅能方便地创建单例,还自带一些非常实用的特性:
- 天然单例 :
object关键字会保证这个类型在应用生命周期内只会创建一个实例。 - 线程安全初始化:默认情况下,这个单例实例的初始化就是线程安全的,因此在多线程环境下也不会出现重复创建的问题。
- 不需要显式实例化 :不同于普通类,你不能显式创建一个
object类型的实例。它会在第一次被访问时自动创建。 - 惰性初始化 :
object声明只有在第一次访问时才会初始化。
下面是一个 object 的简单示例:
kotlin
object Logger {
fun log(message: String) {
println("Log: $message")
}
}
fun main() {
Logger.log("This is a singleton logger.")
Logger.log("Logging another message.")
}
So easy, right?
在这个例子里,Logger 被定义成了一个单例对象,因此所有 log() 调用共享的都是同一个实例,而且完全不需要手动实例化。Kotlin 会自动负责 Logger 实例的创建与管理。
使用场景
当你需要一个全局可访问、并且只需要一个实例的对象时,object 声明就非常合适,例如:
- 工具类:比如日志、数据格式化、输入校验等通用能力。
- 全局常量:比如保存配置 key、固定设置等在全局范围内复用的值。
- 状态管理:比如维护共享资源、缓存数据或应用级配置。
与普通类的区别
虽然 object 和 class 都可以用来定义类型,但它们解决的问题并不一样。理解两者的差异,能够帮助你在 Kotlin 中做出更加正确的选择。
- 实例化方式 :
object会在第一次访问时由 Kotlin 运行时惰性创建,而且只创建一次。相对地,class更像是一个蓝图;每当你需要一个新对象时,都必须通过构造函数(如MyClass())显式创建。 - 使用目的 :
object适合单实例场景,也就是单例;而普通类适合用来创建多个对象,这些对象还可以拥有彼此不同的状态。 - 线程安全 :单例
object的初始化由 Kotlin 运行时保证线程安全,因此在多线程环境里也只会生成一个实例。这倒不是说普通类不是线程安全的,但如果你想基于 class 手动实现单例模式,就必须自己处理线程安全初始化这类复杂问题,而object已经替你把这些细节处理好了。
Java 中写单例,一直是一个高频考点,下次面试官问你的话,你说你用 Kotlin,🤣
小结
object 把单例的定义与管理都做了内建支持,因此它特别适合共享资源、全局常量和工具函数。它既减少了样板代码,又通过默认的线程安全初始化提升了使用体验;而它的 companion object 形式,则进一步为普通类补上了类似静态成员的能力。
进阶:declaration 与 expression
object 关键字有一个很强大的能力:它允许你在一步之内既定义一个类,又创建这个类的实例。
当你需要一个可复用的单例对象,或者一个只用一次的匿名对象时,这种能力就非常方便。围绕这一点,Kotlin 提供了两种核心写法:object declaration 用来创建具名单例,object expression 用来创建匿名的一次性对象。
两者虽然都写作 object,但它们在用途、作用域以及初始化时机上有着本质差别。理解这些差别,才能在实际开发中把它们用对地方。
declaration:更简洁的单例模式
object declaration 会在一条语句中同时完成两件事:定义一个类,并创建它唯一的实例。
它总是有名字,而这个名字也就是访问该单例实例的全局入口。这正是 Kotlin 对单例模式给出的惯用且内建的实现方式。
kotlin
// 声明一个单例对象,用来管理共享资源。
object DataProviderManager {
fun registerDataProvider(provider: DataProvider) { /* ... */ }
val allDataProviders: Collection<DataProvider>
get() = // ...
}
// 直接通过名字访问唯一实例。
DataProviderManager.registerDataProvider(myProvider)
object declaration 的核心特点包括:
- 单例实例 :编译器会保证
DataProviderManager在整个应用生命周期中只存在一个实例。 - 具名且全局可访问 :它拥有名称(
DataProviderManager),因此可以从代码库中的任意位置访问。 - 惰性初始化:实例只会在第一次访问时创建,这本身就是一种重要的性能优化;同时,这个初始化过程还是线程安全的(这里开发者要与单例中的饿汉式以及懒汉式做区别,这里惰性不是指单例的初始化方式)。
- 没有构造函数 :你不能手动实例化一个 object declaration,因此它也不能拥有构造函数。
- 不能定义为局部对象声明 :object declaration 必须出现在文件顶层,或者嵌套在另一个类 / 对象内部,不能直接声明在函数内部。
object declaration 非常适合用于管理共享状态、提供工具函数,或者充当中心注册表,DataProviderManager 就是一个典型例子。
此外,object declaration 还有一种特殊形式叫 companion object,而这个,我们下一篇文章会专门展开说明。
expression:匿名的一次性对象
与之相对,object expression 会创建一个匿名对象。它会在同一时间、同一位置定义一个类并实例化它,但不会给这个类命名。它特别适合那种只出现一次的接口实现,或者为了局部场景临时扩展一个类的需求。
kotlin
// 经典的 Android 匿名对象示例。
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
// 这个对象只是为这个监听器而创建的。
}
})
object expression 的典型特征包括:
- 匿名:它定义出来的类没有名字,实例通常会赋给一个变量,或者直接作为参数传入。
- 立即初始化 :不像 object declaration 那样惰性初始化,object expression 会在定义它的位置立刻创建实例。
- 每次执行都会产生新对象 :object expression 并不是单例,每执行一次都会新建一个实例。
- 可以出现在局部作用域中:它可以写在任何地方,包括函数内部。
- 可以捕获外层作用域 :object expression 内部的代码可以访问外层作用域中的变量,也就是具备闭包能力。
因此,object expression 很适合用来实现事件监听器、简单接口实现,或者任何只需要一个临时对象、却不值得单独定义完整具名类的场景。
核心区别
可能看到这里,你已经有个大概了,大多数有 Java 经验的开发者应该能总结出这样一段话:object declaration 为单例服务,而 object expression 为匿名类服务。
那我就说的详细一点。
object declaration 与 object expression 的本质区别,主要体现在实例化方式、作用域以及使用目的上。object declaration 创建的是一个具名单例,采用惰性初始化,并且会在整个应用生命周期内持续存在,因此非常适合共享资源这类长期存在的对象。
而 object expression 每执行一次都会创建一个新的匿名实例。它会在定义处立即初始化,更适合那种局部、一次性的任务,比如临时实现一个事件监听器,因为它的生命周期只和这次使用本身相关。
总之,
在 Kotlin 中,object declaration 和 object expression 虽然都依赖 object 关键字,但它们面向的是完全不同的使用场景。
前者提供的是具名、惰性初始化、可全局共享的单例;后者提供的是匿名、立即创建、用于一次性任务的临时实例。
进阶:data object
data object 是 data 与 object 两个关键字的结合体,于 Kotlin 1.9 引入,目的是让"既需要单例语义、又需要保存只读数据"的对象写起来更自然。它把 data class 和单例 object 的优势合并在了一起,让你能够表达"保存数据的唯一只读实例"。
和 data class 一样,data object 会基于声明的属性自动生成 toString()、hashCode() 和 equals() 等常用方法;但它同时又是一个 object,因此天然保证只存在一个实例,也就不需要再为相同状态创建多个对象。
关键特性
- 单例本质 :
data object只会创建一次,并在整个应用内共享。 - 自动生成方法 :它会像
data class一样,根据属性自动生成toString()、hashCode()和equals()等方法。 - 不可变倾向:它的属性通常是只读的,因此很适合写出简洁、稳定且线程安全的设计。
下面是一个定义和使用 data object 的例子:
kotlin
data object Configuration {
val appName: String = "Dove Letter"
val version: String = "1.0.0"
}
fun main() {
println(Configuration) // 输出:Configuration(appName=Dove Letter, version=1.0.0)
}
在这个例子中,Configuration 是一个 data object 的单例实例。Kotlin 编译器会自动为它生成 toString(),从而给出属性内容的字符串表示。
使用场景
当你需要全局可访问的数据或行为,但又不希望承担额外实例化成本时,data object 尤其适合:
- 全局配置或常量:当你需要一个只读配置值的唯一真实来源时。
- 共享数据:当某份数据需要在整个应用中共享,而且本身是只读的、不需要多个实例时。
data class vs object vs data object
Kotlin 提供了多种表示数据的方式,它们各有适用场景:
- data Class:需要显式实例化,并允许存在多个实例,适合表示拥有独立状态的数据模型。
- object :适合表示单例,但不会自动生成
toString()、equals()等辅助方法。 - data object :同时拥有
object的单例语义与data class的自动生成方法,因此很适合表达单实例、只读的数据表示。
KEEP 提案
KEEP Data Objects 提案 对 data object 做了一个很简洁的总结:
该 KEEP 引入了 data objects ,用来修复当前 Kotlin 设计中的若干不一致问题,包括如何通过 data classes 处理不可变数据与代数数据类型(ADTs),以及如何避免为
object手动编写默认toString所带来的样板代码。作为效果之一,它还修复了 KT-4107 这个功能请求。
总之,
data object 让"保存不可变数据的单例对象"变得更容易书写,同时还能享受到 data class 提供的那些实用方法。它特别适合全局配置、常量,以及那些要求在应用中始终只有一个不可变实例的共享数据模型。
进阶:Java 字节码
先看一个简单的例子:我们定义一个用于日志配置的单例对象。
kotlin
object LoggerConfig {
val logLevel: String = "DEBUG"
fun log(message: String) {
println("[$logLevel]: $message")
}
init {
println("LoggerConfig initialized.")
}
}
在 Kotlin 中,你可以直接通过对象名访问它的成员,比如 LoggerConfig.log("App started")。其中的 init 代码块只会在第一次访问 LoggerConfig 时执行一次。
当这段 Kotlin 代码被编译,再反编译回 Java 后,就能看到一个经典且线程安全的 Java 单例实现。
java
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
// 1. object 会变成一个 final Java 类。
public final class LoggerConfig {
// 2. 会创建一个唯一的、static 的、final 的实例。这就是单例
@NotNull
public static final LoggerConfig INSTANCE = new LoggerConfig();
@NotNull
private static final String logLevel = "DEBUG";
public static final int $stable;
// 3. 构造函数会被设为 private,以阻止外部实例化。
private LoggerConfig() {
}
@NotNull
public final String getLogLevel() {
return logLevel;
}
public final void log(@NotNull String message) {
Intrinsics.checkNotNullParameter(message, "message");
System.out.println('[' + logLevel + "]: " + message);
}
// 4. init 代码块会被搬进 static 初始化块中。
static {
System.out.println("LoggerConfig initialized.");
}
}
需要说明的是,不同的 Kotlin 版本,不同的 Kotlin 编写方式,object 的初始化逻辑有点区别。当前展示的是 Kotlin 2.2.10 版本在 JVM 21 下的反编译代码。
编译器会通过几项关键策略,把 object 关键字变成一个可靠的单例实现:
- 变成 final 类 :
object会被编译成一个标准的 Java 类,并且带有final修饰。这能阻止其他类继承它,而这正是单例实现中常见的做法之一,用来避免子类化带来的问题。 - 生成公共的 INSTANCE 字段 :单例模式的核心,在于创建一个名为
INSTANCE的public static final字段。这个字段持有该类唯一的实例。当你在 Kotlin 中写下LoggerConfig.log(...)时,编译器实际上会把它翻译成字节码里的LoggerConfig.INSTANCE.log(...)。也正是这个INSTANCE字段,让你可以像访问静态成员那样访问单例对象的成员。同时在此刻,也完成了初始化。 - 生成私有构造函数 :编译器会生成一个
private constructor。这是保证单例约束的重要一步。构造函数私有化之后,外部代码就无法通过new LoggerConfig()再创建新的实例。获取该对象实例的唯一方式,就是通过公开的INSTANCE字段。 - 使用 static 初始化块 :Kotlin
object中的init代码块,以及属性的初始化逻辑,都会被移到 Java 的static { ... }初始化块里。这个代码块会由 JVM 在类第一次加载进内存时执行,而且只执行一次。
Kotlin 里的 companion object 也会遵循类似的编译模式。它会被编译成所属类中的一个静态嵌套类,并同样拥有一个 public static final INSTANCE 字段。Kotlin 正是借此在 JVM 上实现了类似静态成员的能力,而无需为成员提供 static 关键字。当你调用 MyClass.myCompanionFunction() 时,实际调用的是 MyClass.Companion.INSTANCE.myCompanionFunction()。