Object、companion object 与 Kotlin 中的单例
1. 概述
Kotlin 没有 static 关键字。相反,该语言提供了 object 关键字,有三种用法:对象声明、对象表达式 和 companion object。
本文讲解每种用法、Kotlin 如何将它们编译到 JVM 字节码、如何从 Java 调用它们、以及何时 不 应该使用它们。读完本文后,你将了解如何创建线程安全的单例、编写简洁的工厂方法,以及在测试时避免常见错误。
2. 设置
示例使用 Kotlin 2.x 和 JVM 21。要运行测试,请将 JUnit 5 和 AssertJ 添加到 pom.xml:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
项目需要 kotlin-maven-plugin 来编译 Kotlin 代码。
3. 对象声明 – 语言内置的单例
当在顶级或在类内部声明 object 时,你得到一个 单例。Kotlin 自动为其创建恰好一个实例。你不需要私有构造函数,不需要 getInstance() 方法,也不需要双检查锁定。
import java.util.concurrent.atomic.AtomicLong
object OrderIdGenerator {
private val counter = AtomicLong(0)
fun next(): String = "ORD-${counter.incrementAndGet()}"
}
fun main() {
println(OrderIdGenerator.next())
println(OrderIdGenerator.next())
}
你通过对象名称直接调用函数:OrderIdGenerator.next()。这里没有 new 关键字,也没有构造函数调用。
变量 counter 使用 AtomicLong,因为多个线程可能同时调用 next()。单例只保证 有一个实例。它不会自动使内部状态对线程安全。
ORD-1
ORD-2
3.1. 惰性和线程安全的初始化
对象在 第一次访问时 被初始化,而不是在程序启动时:
object AppConfig {
init {
println("AppConfig 被初始化了")
}
val currency = "VND"
}
fun main() {
println("开始")
println(AppConfig.currency)
println(AppConfig.currency)
}
开始
AppConfig 被初始化了
VND
VND
init 块只执行一次,就在第一次读取 currency 之前。在字节码层面,Kotlin 将 AppConfig 编译为一个 final 类,其中有一个字段 public static final AppConfig INSTANCE。该字段在 static 初始化器中被赋值。
JVM 保证一个类的 static 初始化器只运行一次,且在多个线程并发访问时是安全的。因此,Kotlin 的单例既是惰性的,又是线程安全的,而你无需编写任何额外代码。
3.2. 对象可以继承并实现接口
对象是真实的类型。它可以实现接口,并像任何其他值一样被传递:
interface DiscountPolicy {
fun apply(amount: Long): Long
}
object NoDiscount : DiscountPolicy {
override fun apply(amount: Long): Long = amount
}
class TenPercentOff : DiscountPolicy {
override fun apply(amount: Long): Long = amount * 90 / 100
}
fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)
fun main() {
println(checkout(200_000, NoDiscount))
println(checkout(200_000, TenPercentOff()))
}
200000
180000
NoDiscount 没有状态,所以创建多个实例是浪费。将其声明为 object 清楚地表达了意图:只需要恰好一个实例。
4. 对象表达式 – 匿名对象
当在 表达式 位置放置 object 时,你创建一个匿名对象。这种用法类似于 Java 中的匿名类:
data class Order(val id: String, val total: Long)
fun main() {
val byTotalDesc = object : Comparator<Order> {
override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
}
val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]
重要的区别是:对象表达式不是单例。每次执行该表达式时,都会创建一个新实例。此外,对象表达式在原地立即初始化,不像对象声明那样是惰性的。
你也可以创建一个没有超类型的对象,以在本地范围内临时聚集几个值:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("平均值:${stats.sum / stats.count}")
}
stats 的类型只能在函数内或 private 声明中访问。如果你通过公共函数将其返回,类型将被强制转换为 Any,你将失去访问属性的权限。在这种情况下,应该使用 data class。
注意: 对于只有一个抽象方法的接口,你应该使用 lambda(SAM 转换)而不是对象表达式,例如
Comparator<Order> { a, b -> b.total.compareTo(a.total) }。只有在需要重写多个方法或需要保持状态时才使用对象表达式。
5. Companion object – static 的替代品
有时你需要一个绑定到 类 而不是实例的函数,例如工厂方法。在 Java 中,你会使用 static。在 Kotlin 中,你使用 companion object:
class Customer private constructor(val email: String, val name: String) {
companion object {
private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
fun of(email: String, name: String): Customer {
require(EMAIL_REGEX.matches(email)) { "电子邮件无效:$email" }
return Customer(email.trim().lowercase(), name.trim())
}
}
}
fun main() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An
上面的代码工作原理如下:
- 构造函数是
private的,因此外部必须通过Customer.of(...)创建Customer。 - Companion object 可以访问包含它的类的私有构造函数。
EMAIL_REGEX只编译一次,并由所有调用共享。
Companion object 仍然是一个 真实的对象:一个嵌套在类中的单例,默认名称为 Companion。它在其包含的类被加载时被初始化,等价于 Java 中的 static 初始化器。每个类最多只能有 一个 companion object。
5.1. 命名和实现接口
因为是对象,companion 可以有自己的名称并实现接口。这是 Java 中的 static 无法做到的:
interface Parser<T> {
fun parse(raw: String): T
}
data class Money(val amount: Long, val currency: String) {
companion object Factory : Parser<Money> {
override fun parse(raw: String): Money {
val (amount, currency) = raw.trim().split(" ")
return Money(amount.toLong(), currency)
}
}
}
fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)
fun main() {
println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]
请注意调用 parseAll(Money, ...)。当将类名 Money 用作值时,Kotlin 会将其理解为其 companion object。因此,类 Money 本身就充当了 Parser<Money>。
5.2. 在 companion 上的扩展函数
你可以为一个类添加“静态类型”的函数,而无需修改其源代码,只要该类声明了 companion:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
如果 companion 没有命名,你写成 fun Money.Companion.zero(...)。
6. 从 Java 调用 object 和 companion
默认情况下,Java 将 object 和 companion 视为普通实例:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
这种调用方式冗长且难以阅读。Kotlin 提供了三个工具使 Java 代码看起来更自然:
class PaymentService {
companion object {
const val MAX_RETRY = 3
@JvmField
val SUPPORTED_CURRENCIES = listOf("VND", "USD")
@JvmStatic
fun create(): PaymentService = PaymentService()
fun legacyCreate(): PaymentService = PaymentService()
}
}
Java 端的调用如下:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
每个工具的含义:
const val只能用于原始类型和String,其值在编译时已知。常量被内联到使用位置,成为public static final字段。@JvmField生成 public static 字段,不生成 getter。此注解适用于非编译时常量的值,如List。@JvmStatic在PaymentService类上生成额外的 static 方法。没有此注解的函数仍然必须通过Companion调用。
如果项目仅包含 Kotlin 代码,则不需要这些注解。例外是 const val:它仍然很有用,因为值可以在注解中使用,例如 @Retry(maxAttempts = MAX_RETRY)。
7. data object – 用于无数据的状态
从 Kotlin 1.9 开始,你可以声明 data object。这种用法常见于 sealed 层次结构:
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun main() {
println(PaymentResult.Pending)
println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)
如果去掉 data,toString() 会打印 PaymentResult$Pending@1b6d3586 的形式,在日志中很难阅读。data object 还会生成一致的 equals/hashCode。这在对象被反序列化为另一个实例时很重要。
8. 编写测试
为 companion object 中的工厂方法编写测试:
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `of 规范化电子邮件和名称`() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
assertThat(customer.email).isEqualTo("an.nguyen@example.com")
assertThat(customer.name).isEqualTo("Nguyễn An")
}
@Test
fun `当电子邮件无效时 of 抛出异常`() {
assertThatThrownBy { Customer.of("khong-phai-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("khong-phai-email")
}
@Test
fun `companion 可以解析多行`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object 声明总是返回同一个实例`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
这些测试独立运行,因为 Customer.of 和 Money.parse 不保持状态。而 OrderIdGenerator 不同:计数器在整个 JVM 生命周期内存活。一个检查 next() == "ORD-1" 的测试是否正确取决于测试运行的顺序。这正是下一部分中的第一个错误。
9. 常见错误及何时不应使用
对象保持可变状态。 全局状态使得测试相互依赖,并在并行运行时造成困难。如果对象需要状态,应该将其分离为一个 class 并将实例传递给需要它的地方(依赖注入)。
注意: 对象声明没有参数化的构造函数。如果单例依赖于配置或其他服务,例如
DataSource或支付网关的 URL,那么object不是合适的选择。
在 Spring 应用程序中使用 object。 Spring 中的 Bean 默认在 ApplicationContext 范围内是单例的。编写 object PaymentService 会使你失去构造函数注入,难以在测试中 mock,并创建两个并行的生命周期管理机制。在这种情况下,应该使用 @Service class PaymentService(...)。仅将 object 用于纯粹的、不依赖任何东西的部分:常量、工具函数、无状态的策略。
混淆 companion object 与 static。 Companion 是一个真实的实例。调用 Customer.of(...) 实际上是调用 Customer.Companion.of(...)。这样做的成本微不足道,但它解释了为什么 Java 需要 @JvmStatic。
将所有工具函数放入 companion。 对于与类没有直接关系的函数,顶级函数如 fun formatVnd(amount: Long): String 更简洁且更容易找到。
初始化中的循环依赖。 如果对象 A 的 init 读取对象 B,而 B 又读取 A,你可能会为声明为 non-null 的属性获得 null 值。应该保持对象的初始化块非常简单。
10. 总结
Kotlin 中的 object 关键字有三个角色:
- 对象声明 通过 JVM 的类初始化机制创建惰性且线程安全的单例。
- 对象表达式 创建匿名对象。每次执行表达式时都是一个新实例。
- Companion object 替代
static。它可以有名称、实现接口并接收扩展函数。
与 Java 一起工作时,使用 const val、@JvmField 和 @JvmStatic 使 API 看起来自然。最重要的规则是保持对象 没有可变状态。当需要依赖或在测试中需要 mock 时,普通的 class 或 Spring bean 总是更好的选择。