Kotlin object 关键字详解:对象表达式、对象声明与伴生对象
在 Java 里,"匿名内部类""单例""静态成员"是三套各不相同的写法,琐碎且易错。Kotlin 用一个 object 关键字把它们统一了——根据出现位置和是否带名字,分别表达"匿名对象""单例""类级静态成员"三种语义。本文把这三种用法、它们的差异与陷阱一次讲透。
参考文档:Kotlin 官方文档 · Object declarations。
1、一个 object,三种用法
Kotlin 的 object 关键字有三种形态,先建立全局认知,后面再逐个拆解:
// 形态一:对象表达式(object expression)—— 匿名对象,用完即弃
val listener = object : MouseAdapter() { /* ... */ }
// 形态二:对象声明(object declaration)—— 命名单例
object Singleton { fun hello() = println("hi") }
// 形态三:伴生对象(companion object)—— 类内部的"静态"成员
class Foo {
companion object { const val TAG = "Foo" }
}- 对象表达式:创建一个匿名对象,类似 Java 匿名内部类,每次执行都生成新实例
- 对象声明:定义一个命名的单例,全局唯一,首次访问时初始化
- 伴生对象:写在类内部、用 companion 标记,提供"类级别"的成员(Kotlin 没有 static 关键字,靠它实现)
2、对象表达式:匿名对象
对象表达式用 object : 开头,当场创建一个匿名对象,不必事先声明子类。最常见的用法是把它作为方法参数,比如注册一个鼠标监听器:
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /* ... */ }
override fun mouseEntered(e: MouseEvent) { /* ... */ }
})它可以同时继承基类并实现多个接口(用逗号分隔)。若超类型有构造函数,必须传参:
open class A(x: Int) { open val y: Int = x }
interface B { /* ... */ }
val ab: A = object : A(1), B {
override val y = 15
}甚至可以不带任何超类型,直接得到一个"裸"匿名对象——适合临时封装几个字段:
fun main(args: Array<String>) {
val site = object {
var name: String = "菜鸟教程"
var url: String = "www.runoob.com"
}
println(site.name) // 菜鸟教程
println(site.url) // www.runoob.com
}对象表达式还能捕获作用域中的变量(闭包),这对回调尤其方便——可以在匿名对象里直接读写外层的局部变量:
fun countClicks(window: JComponent) {
var clickCount = 0
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
clickCount++ // 直接访问并修改外层变量
}
})
}3、对象声明:优雅的单例
对象声明用 object Name { ... },给一个名字,就得到一个线程安全的单例——无需手写 Java 那套"私有构造 + 静态实例 + getInstance"。引用时直接用名字:
object DataProviderManager {
fun registerDataProvider(provider: DataProvider) { /* ... */ }
val allDataProviders: Collection<DataProvider>
get() = /* ... */
}
// 直接用名称调用,无需创建实例
DataProviderManager.registerDataProvider(/* ... */)它是货真价实的单例:无论用几个变量接收,指向的都是同一个对象,改一个全都变:
object Site {
var url: String = ""
val name: String = "菜鸟教程"
}
fun main() {
val s1 = Site
val s2 = Site
s1.url = "www.runoob.com"
println(s2.url) // www.runoob.com —— s1、s2 是同一实例
}对象声明同样可以有超类型(继承基类/实现接口),用于"带默认实现的唯一实例"场景:
object DefaultListener : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /* ... */ }
}4、伴生对象:Kotlin 的"静态"成员
Kotlin 没有 static 关键字。要在类上挂"类级别"的成员(常量、工厂方法),就把对象声明写进类里、并用 companion 标记,这就是伴生对象。它的成员通过外部类名直接访问:
class MyClass {
companion object Factory {
fun create(): MyClass = MyClass()
}
}
val instance = MyClass.create() // 像调用静态方法一样伴生对象的名字可以省略,默认叫 Companion:
class MyClass {
companion object { /* ... */ }
}
val x = MyClass.Companion // 用默认名访问伴生对象本身两个要点:第一,一个类只能有一个伴生对象(companion 只能用一次);第二,伴生对象的成员"看起来像静态",但运行时它仍是真实对象的实例成员——所以伴生对象本身还能实现接口,常用于"工厂接口"模式:
interface Factory<T> { fun create(): T }
class MyClass {
// 伴生对象实现 Factory 接口,充当类的工厂
companion object : Factory<MyClass> {
override fun create(): MyClass = MyClass()
}
}5、三者对比与初始化时机
三者最本质的差别在"实例数量"与"初始化时机":
形态 语法 实例数 初始化时机 典型用途
----------------------------------------------------------------------------------
对象表达式 object : Super {…} 每次新建 使用处立即执行 匿名对象/回调/监听器
对象声明 object Name {…} 全局唯一 首次访问时延迟初始化 单例
伴生对象 companion object {…} 全局唯一 类被加载(解析)时 类级常量/工厂方法这个初始化时机的"语义差异"最容易踩坑,单独强调:对象表达式在使用处立即执行(每次走到这行就 new 一个);对象声明在首次被访问时延迟初始化(懒加载);伴生对象在外部类被加载时初始化,语义与 Java 的静态初始化器完全匹配。理解这一点,才能解释"为什么单例里的初始化逻辑不会在程序启动就跑"。
6、对象表达式的作用域陷阱
对象表达式有一个隐蔽规则:匿名对象的类型,取决于它在哪暴露。如果只用于局部变量或私有函数返回值,它能保留完整的匿名类型,新增的成员都可访问;但一旦作为公有函数的返回值或公有属性的类型,它的类型会"退化"成声明的超类型(没声明超类型就退化成 Any),你在匿名对象里新增的成员就访问不到了:
class C {
// 私有函数 → 返回类型是匿名对象类型,成员可访问
private fun foo() = object { val x: String = "x" }
// 公有函数 → 返回类型退化为 Any,成员访问不到
fun publicFoo() = object { val x: String = "x" }
fun bar() {
val x1 = foo().x // OK
val x2 = publicFoo().x // 错误:未能解析的引用 "x"
}
}对策:要让匿名对象的成员对外可见,就显式声明一个超类型(接口或抽象类),让它作为返回类型,而不是裸用 object。
7、嵌套对象声明的访问规则
对象声明嵌套在类内部时(非 companion),有两条容易踩的规则:第一,不能通过外部类的实例去访问它,只能通过类名;第二,它不能直接访问外部类的成员——因为它是"类级别的单例",和某个外部实例没有绑定关系。
class Site {
var name = "菜鸟教程"
object DeskTop {
var url = "www.runoob.com"
fun showName() {
// print("desk legs $name") // 错误:访问不到外部类实例的 name
}
}
}
fun main() {
val site = Site()
// site.DeskTop.url // 错误:不能通过实例访问
Site.DeskTop.url // 正确:只能用类名访问
}如果你需要"和外部类实例绑定的静态式成员",那不是 object 的职责,而应该用普通内部类(inner class)或直接用成员属性。
8、实战场景与最佳实践
- 单例:用对象声明替代 Java 的各种手写单例(饿汉/双重检查锁),代码更短、线程安全由语言保证
- 工厂方法:放伴生对象里,调用形如 MyClass.create(),比独立工厂对象更内聚
- 常量:类级常量用 companion object + const val,编译期内联、零开销
- 回调/监听器:临时实现一个接口用对象表达式,比单独写个类更轻
- 与 Java 互操作:在伴生对象里加 @JvmStatic 可暴露成真正的静态方法、@JvmField 暴露成静态字段,方便 Java 调用方
- 一个判断口诀:要"唯一实例"用对象声明,要"一次性匿名对象"用对象表达式,要"挂在类上的静态成员"用伴生对象
9、总结
Kotlin 用 object 一个关键字,把"匿名对象、单例、静态成员"三件事优雅地统一起来:对象表达式(object :)当场造匿名对象,对象声明(object Name)定义单例,伴生对象(companion object)提供类级成员。三者的本质差异在实例数量与初始化时机——对象表达式用完即弃、立即执行,对象声明全局唯一、懒加载,伴生对象随类加载初始化。
关键要点:
- object : 表达式 = 匿名对象(闭包可捕获外层变量);object Name 声明 = 单例;companion object = 类级静态成员
- 伴生对象运行时仍是真实对象,可实现接口;一个类只能有一个 companion
- 初始化时机:对象表达式立即、对象声明懒加载、伴生对象随类加载
- 陷阱:匿名对象作为公有返回会退化为 Any;嵌套 object 不能经实例访问、访问不到外部类成员
对于从 Java 转向 Kotlin 的开发者而言,把 object 的三种用法与差异内化,是写出地道 Kotlin 的关键一步。下次面对"要不要写个单例""这个回调怎么传""常量放哪里",先想想 object 的哪一种形态最贴合——一旦用顺手,你会发现 Kotlin 在这类"日常样板"上比 Java 简洁太多,代码量少了,可读性和正确性反而更高。

