对象声明、伴生对象与对象表达式

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 适合表达角色,例如 FactoryParserSerializer

伴生对象与私有成员

类成员可以访问同一类伴生对象的私有成员:

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() 是私有的,调用点能访问匿名对象新增的 themefontSize

如果返回匿名对象的函数或属性是 publicprotectedinternal,实际暴露类型会被限制:

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