对象声明、伴生对象与对象表达式¶
Kotlin 的 object 关键字可以同时“声明一个类”和“创建一个实例”。这套能力覆盖了三个常见需求:
- 用对象声明创建命名单例。
- 用伴生对象承载与类绑定的工厂、常量和工具能力。
- 用对象表达式创建一次性的匿名对象。
Java 开发者可以把它们粗略类比为 singleton、static 成员和匿名类,但 Kotlin 的语义并不是 Java 语法的直接翻译。理解初始化时机、可见性和 JVM 暴露方式,才能写出稳定的 API。
对象声明¶
对象声明使用 object 加名称:
object MetricsRegistry {
private val counters = mutableMapOf<String, Int>()
fun increment(name: String) {
counters[name] = counters.getOrDefault(name, 0) + 1
}
fun snapshot(): Map<String, Int> = counters.toMap()
}
fun main() {
MetricsRegistry.increment("login")
println(MetricsRegistry.snapshot()) // {login=1}
}
MetricsRegistry 既是类型名,也是唯一实例的访问入口。调用时不需要 new,也不需要 MetricsRegistry.INSTANCE。
在 JVM 字节码层面,命名 object 通常会有一个 INSTANCE 字段,但 Kotlin 代码不应该依赖这个实现细节。只有写 Java 调用方时才会看到类似形态。
初始化时机¶
对象声明在第一次访问时初始化,并且初始化过程是线程安全的:
object ExpensiveConfig {
init {
println("load config")
}
val endpoint = "https://example.com"
}
fun main() {
println("before")
println(ExpensiveConfig.endpoint)
}
输出顺序是先 before,再执行对象初始化逻辑。
这与 Java 的“静态字段初始化”很像,但不要把所有初始化都塞进全局单例。对象声明适合无状态工具、进程内共享注册表、明确生命周期的全局配置入口;不适合隐藏数据库连接、请求上下文、用户会话等需要外部管理生命周期的资源。
对象声明的限制¶
对象声明有名字,因此不是表达式:
// 错误:对象声明不能放在赋值右侧
val registry = object MetricsRegistry {
val size = 0
}
如果你需要赋值右侧创建一次性对象,应该使用对象表达式:
val registry = object {
val size = 0
}
对象声明不能直接写在函数内部:
fun install() {
// 错误:命名 object 不能是局部声明
object LocalCache
}
但对象声明可以嵌套在另一个 object 或非 inner 类中:
class HttpClient {
object Defaults {
const val TIMEOUT_SECONDS = 30
}
}
object AppScope {
object Logger {
fun info(message: String) = println(message)
}
}
实现接口或继承父类¶
对象声明可以继承类、实现接口:
interface JsonAdapter<T> {
fun fromJson(text: String): T
}
data class User(val name: String)
object UserAdapter : JsonAdapter<User> {
override fun fromJson(text: String): User {
return User(text.trim('"'))
}
}
这比 Java 中“单例类 + 静态实例字段”的写法更直接:
public final class UserAdapter implements JsonAdapter<User> {
public static final UserAdapter INSTANCE = new UserAdapter();
private UserAdapter() {}
}
Kotlin 把这套样板代码内建到了语言里。
data object¶
普通 object 的默认 toString() 可能带有哈希信息,不适合日志和状态展示。data object 会生成更适合值语义的 toString()、equals() 和 hashCode():
sealed interface LoadState
data object Loading : LoadState
data class Loaded(val rows: List<String>) : LoadState
data class Failed(val reason: String) : LoadState
fun render(state: LoadState): String =
when (state) {
Loading -> "加载中"
is Loaded -> "共 ${state.rows.size} 行"
is Failed -> "失败:${state.reason}"
}
data object 特别适合和 data class 一起放在密封层级中,让日志输出和调试视图更一致。
与 data class 不同,data object 不会生成:
copy(),因为单例不应该复制出新实例。componentN(),因为它没有主构造函数属性可供解构。
你也不能为 data object 自定义 equals() 或 hashCode()。如果运行时通过 Java 反射或某些序列化机制强行制造出第二个实例,data object 的结构相等仍会把它们视为相等。因此比较 data object 时应使用 ==,不要用 === 依赖引用相等。
伴生对象¶
类内部的对象声明可以标记为 companion:
class User private constructor(val name: String) {
companion object {
fun create(name: String): User {
require(name.isNotBlank())
return User(name.trim())
}
}
}
val user = User.create(" Ada ")
伴生对象成员可以用类名作为限定符调用,看起来很像 Java 的 static 方法。但它们本质上仍然是伴生对象实例的成员。
这带来一个 Java 静态成员没有的能力:伴生对象可以实现接口。
interface Factory<T> {
fun create(raw: String): T
}
class Email private constructor(val value: String) {
companion object : Factory<Email> {
override fun create(raw: String): Email {
require('@' in raw)
return Email(raw.lowercase())
}
}
}
fun register(factory: Factory<Email>) {
println(factory.create("ADMIN@example.com").value)
}
register(Email)
当类名单独作为表达式使用时,它表示这个类的伴生对象。因此 register(Email) 传入的是 Email 的 companion object。
伴生对象名称¶
伴生对象可以命名,也可以匿名:
class Token {
companion object Parser {
fun parse(text: String): Token = Token()
}
}
class Session {
companion object {
fun create(): Session = Session()
}
}
未命名时默认名称是 Companion:
val parser = Token.Parser
val companion = Session.Companion
日常 Kotlin 调用通常使用 Token.parse() 或 Session.create(),不必直接写伴生对象名称。命名 companion 适合表达角色,例如 Factory、Parser、Serializer。
伴生对象与私有成员¶
类成员可以访问同一类伴生对象的私有成员:
class Password private constructor(private val value: String) {
fun masked(): String = mask
companion object {
private const val mask = "******"
fun of(raw: String): Password {
require(raw.length >= 8)
return Password(raw)
}
}
}
伴生对象也常用来访问私有构造函数,从而集中创建规则。这是 Kotlin 中实现命名构造、工厂方法和受控实例化的常见方式。
companion 不是 Java static¶
从 Kotlin 看:
class Ids {
companion object {
val prefix = "user"
fun next(): String = "$prefix-1"
}
}
println(Ids.next())
从 Java 默认看,调用形态通常是:
String id = Ids.Companion.next();
如果希望 Java 像调用静态方法一样调用,需要使用 @JvmStatic:
class Ids {
companion object {
@JvmStatic
fun next(): String = "user-1"
}
}
Java:
String id = Ids.next();
如果希望伴生对象或命名 object 中的属性作为 Java 静态字段暴露,可以考虑 const val 或 @JvmField:
class Api {
companion object {
const val VERSION = "v1"
@JvmField
val DEFAULT_HEADERS = mapOf("Accept" to "application/json")
}
}
互操作 API 的原则是:Kotlin 调用者优先使用自然的 companion 语法;确实有 Java 调用方时,再用 @JvmStatic、@JvmField、@file:JvmName 等注解设计 Java 体验。
对象表达式¶
对象表达式使用 object 创建匿名对象。它是表达式,可以赋值、作为参数传递,也可以直接返回。
不继承任何类型时:
val point = object {
val x = 10
val y = 20
override fun toString(): String = "($x, $y)"
}
println(point.x)
println(point)
实现接口或继承类时:
interface ClickListener {
fun onClick(x: Int, y: Int)
}
fun install(listener: ClickListener) {
listener.onClick(12, 30)
}
install(object : ClickListener {
override fun onClick(x: Int, y: Int) {
println("click at $x, $y")
}
})
Java 对比:这类似 Java 匿名内部类:
install(new ClickListener() {
@Override
public void onClick(int x, int y) {
System.out.println("click at " + x + ", " + y);
}
});
但在 Kotlin 中,如果接口是函数式接口,很多场景可以直接用 lambda,代码更短:
fun interface ClickHandler {
fun onClick(x: Int, y: Int)
}
fun install(handler: ClickHandler) {
handler.onClick(12, 30)
}
install { x, y ->
println("click at $x, $y")
}
对象表达式适合需要多个方法、需要继承类、需要额外状态或不适合 lambda 的一次性实现。
匿名对象可以捕获外部变量¶
对象表达式中的代码可以访问外层作用域变量:
fun counter(): Runnable {
var count = 0
return object : Runnable {
override fun run() {
count++
println(count)
}
}
}
与 Java 匿名类相比,Kotlin 捕获的 var 可以在对象内部修改。Java 中被匿名类捕获的局部变量必须是 final 或 effectively final。
这种能力方便,但也容易把状态藏进闭包。并发或异步代码中,如果多个线程访问被捕获的可变变量,应使用明确的线程安全结构。
匿名对象作为返回类型¶
匿名对象的返回类型规则很重要,尤其影响公共 API。
如果匿名对象来自局部变量、局部函数或 private 成员,Kotlin 可以保留它的具体匿名类型:
class Preferences {
private fun defaults() = object {
val theme = "dark"
val fontSize = 14
}
fun print() {
val d = defaults()
println("${d.theme}, ${d.fontSize}")
}
}
因为 defaults() 是私有的,调用点能访问匿名对象新增的 theme 和 fontSize。
如果返回匿名对象的函数或属性是 public、protected 或 internal,实际暴露类型会被限制:
class Notifications {
fun raw() = object {
val message = "hello"
}
}
val n = Notifications().raw()
// n.message 不能访问;公开返回类型被视为 Any
如果匿名对象声明了一个父类型,公开 API 暴露的是这个父类型:
interface Notifier {
fun notifyUser()
}
class Notifications {
fun email(): Notifier = object : Notifier {
override fun notifyUser() {
println("email")
}
val subject = "welcome"
}
}
val notifier = Notifications().email()
notifier.notifyUser()
// notifier.subject 不能访问;subject 不在 Notifier 接口中
实践建议:不要把匿名对象当作公共 API 的结构化返回值。公共 API 应该声明清晰的 data class、接口或密封类型。匿名对象更适合局部封装和一次性适配。
初始化行为对比¶
| 形式 | 初始化时机 | 典型用途 |
|---|---|---|
| 对象表达式 | 执行到表达式位置时立即创建 | 一次性实现、局部适配 |
| 对象声明 | 第一次访问时懒加载,线程安全 | 命名单例、全局注册表、无状态工具 |
| 伴生对象 | 对应类被加载或解析时初始化,语义接近 Java static initializer | 工厂、常量、类级工具、Java 互操作入口 |
如果初始化有副作用,例如读取文件、打开连接、注册全局状态,应明确选择初始化时机。不要因为 object 写法方便,就让全局副作用在难以预测的访问点发生。
常见设计选择¶
| 需求 | 推荐写法 | 原因 |
|---|---|---|
| 进程内唯一、无外部生命周期的服务入口 | object |
语义直接,懒加载,线程安全 |
| 类的工厂方法 | companion object |
与类绑定,能访问私有构造函数 |
| Java 调用方需要静态方法 | @JvmStatic 放在 companion/object 方法上 |
改善 Java API 体验 |
| 固定常量 | 顶层 const val 或 companion/object 中 const val |
JVM 上会暴露为静态常量 |
| 密封层级中的无数据状态 | data object |
与 data class 输出和相等语义更一致 |
| 临时接口实现 | 对象表达式或 lambda | 局部化实现,避免额外命名类 |
| 公共返回结构 | data class、接口或密封类型 |
API 类型稳定、文档清晰 |
Java 迁移误区¶
把 companion 当成 static 容器¶
Java 中常见:
public final class Users {
public static User create(String name) { ... }
}
Kotlin 中不一定要创建一个 Users 工具类。更自然的写法是把与 User 强相关的创建逻辑放进 User 的 companion:
class User private constructor(val name: String) {
companion object {
fun create(name: String): User = User(name.trim())
}
}
如果函数并不属于某个类,顶层函数通常比 companion 更自然:
fun normalizeName(name: String): String = name.trim()
不要为了模拟 Java static 而把所有工具函数塞进 companion。
用 object 管理可变全局状态¶
object CurrentUser {
var id: Long? = null
}
这类写法很容易造成测试污染、并发风险和生命周期混乱。更好的方式通常是显式传入依赖:
class UserSession(val userId: Long)
class OrderService(private val session: UserSession) {
fun ownerId(): Long = session.userId
}
object 不是依赖注入的替代品。它适合表达语言层面的单例,不适合隐藏业务上下文。
对匿名对象的公开成员产生错觉¶
fun config() = object {
val retries = 3
}
在类的公共成员中,这个函数的调用方不能访问 retries。如果你想公开配置结构,应写成:
data class RetryConfig(val retries: Int)
fun config(): RetryConfig = RetryConfig(retries = 3)
这也是库 API 更容易维护的写法。
实践建议¶
- 用
object表达真正的单例,而不是把它当作“方便的全局变量”。 - 用 companion 放与类强相关的工厂、解析、常量和注册入口。
- 面向 Java 暴露 API 时,显式检查 Java 调用形态,必要时加
@JvmStatic或@JvmField。 - 密封层级中的无数据分支优先考虑
data object,尤其需要好看的日志和调试输出时。 - 对象表达式适合局部实现,不适合承载公共返回类型。
- 关注初始化副作用:对象表达式立即执行,对象声明首次访问执行,companion 随类加载语义执行。
参考¶
- 官方对象声明与对象表达式文档:https://kotlinlang.org/docs/object-declarations.html
- 官方类文档:https://kotlinlang.org/docs/classes.html
- 官方从 Java 调用 Kotlin 文档:https://kotlinlang.org/docs/java-to-kotlin-interop.html