Kotlin 中的委托属性:lazy 和 observable
1. 概述
Kotlin 允许你通过 by 关键字将一个属性的读写操作委托给另一个对象。这种机制称为委托属性。标准库提供了几个常用的委托。最常见的是用于延迟初始化的 lazy、用于监听变化的 observable 和用于阻止变化的 vetoable。
读完本文,你将了解如何使用每个委托,以及如何为 lazy 选择正确的线程安全模式。你还会学到如何测试它们,以及应该避免的情况。
2. 项目设置
本文中的委托已经包含在 kotlin-stdlib 中,你不需要添加任何库。要编写测试,请添加 JUnit 5 和 AssertJ:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
如果你使用 Spring Boot 3.x,spring-boot-starter-test 已经包含了这两个库。
3. 委托属性如何工作
当你写 val x: T by someDelegate 时,编译器会生成一个隐藏字段来保存委托对象。x 的 getter 会调用 someDelegate.getValue(...)。如果 x 是 var,setter 会调用 setValue(...)。
你可以编写一个简单的委托来了解这个机制:
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.trim()
}
}
class Customer {
var name: String by TrimmedString()
}
fun main() {
val customer = Customer()
customer.name = " Nguyễn Văn An "
println("[${customer.name}]")
}
让我逐部分解释:
TrimmedString实现了ReadWriteProperty,包含两个函数getValue和setValue。- 每次分配
customer.name时,Kotlin 会调用setValue,字符串在保存前会被trim()。 property参数告诉你正在访问哪个属性的名称。observable使用这个参数来告诉你哪个属性刚刚改变了。
[Nguyễn Văn An]
你很少需要自己编写委托,因为标准库已经提供了最常用的。
4. 使用 by lazy 延迟初始化
4.1. 基本用法
lazy 接收一个 lambda。lambda 只在你第一次读取属性时运行。结果会被缓存下来,供后续每次读取使用。
class ReportService {
val exchangeRates: Map<String, Double> by lazy {
println("正在加载汇率...")
mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
}
}
fun main() {
val service = ReportService()
println("Service 已创建")
println(service.exchangeRates["USD"])
println(service.exchangeRates["EUR"])
}
输出:
Service 已创建
正在加载汇率...
25400.0
27300.0
有三点需要注意:
- 创建
ReportService不会触发汇率加载。 - “正在加载汇率…” 这一行只打印了一次,尽管属性被读取了两次。
lazy只能用于val。写var x by lazy { ... }会导致编译错误,因为Lazy没有setValue。
lazy 适合那些创建成本高但不一定总是需要的值。例如:复杂的正则表达式、从文件读取的配置或调用外部服务的客户端。
4.2. 线程安全模式
默认情况下,lazy 使用 LazyThreadSafetyMode.SYNCHRONIZED。此模式确保 lambda 恰好运行一次,即使多个线程同时访问。你可以在需要时更改模式:
class PriceCalculator {
// 只有一个线程可以运行初始化器,其他线程等待
val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
mapOf("VN" to 0.1, "SG" to 0.09)
}
// 多个线程可以同时运行初始化器,保留第一个值
val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
listOf("BLACK_FRIDAY", "TET_SALE")
}
// 无同步,仅在确信属性只从一个线程访问时使用
val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
java.text.DecimalFormat("#,###")
}
}
fun main() {
val calculator = PriceCalculator()
println(calculator.taxTable["VN"])
println(calculator.discountRules.first())
println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000
你可以根据以下表格选择模式:
| 模式 | 初始化器运行 | 何时使用 |
|---|---|---|
SYNCHRONIZED |
恰好 1 次 | 默认,对大多数情况都是安全的 |
PUBLICATION |
可能多次,保留第一个结果 | 初始化器成本低,无副作用,想避免锁 |
NONE |
不保证 | 代码仅在一个线程上运行(UI 线程、内部循环) |
注意:
NONE稍快一些,因为没有锁。如果你在一个被许多请求并行访问的 Spring singleton bean 中使用它,初始化器可能会运行多次。在这种情况下,不同的线程可能会看到不同的实例。只有在测量表明有必要时才更改模式。
4.3. 初始化器抛出异常时
如果 lambda 抛出异常,值不会被缓存。下次读取时会重新运行 lambda:
class ConfigLoader {
var attempts = 0
val apiKey: String by lazy {
attempts++
if (attempts < 2) error("未能读取配置文件")
"secret-key-123"
}
}
fun main() {
val loader = ConfigLoader()
runCatching { loader.apiKey }.onFailure { println("错误:${it.message}") }
println(loader.apiKey)
println("尝试次数:${loader.attempts}")
}
错误:未能读取配置文件
secret-key-123
尝试次数:2
当错误是临时性的时,这种行为很有用。但如果初始化器非常昂贵且错误反复发生,每次访问都会再次付出这个代价。
4.4. 检查是否已初始化
如果你需要知道值是否已被创建,请将 Lazy 对象保存在一个单独的变量中,然后委托给它:
class CacheHolder {
private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
val productCache: MutableMap<String, Int> by productCacheDelegate
fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}
fun main() {
val holder = CacheHolder()
println(holder.isCacheReady())
holder.productCache["SKU-1"] = 10
println(holder.isCacheReady())
}
false
true
当你想在关闭时释放资源,但只在资源确实已被创建时,这种方法很有用。
4.5. lazy 还是 lateinit?
这两种方式经常被混淆:
lazy:用于val。属性值由 property 本身计算,在第一次读取时。lateinit:用于var和非空类型,不适用于原始类型如Int。值由外部代码赋值,例如通过依赖注入或测试中的@BeforeEach方法。
如果你知道如何在 class 内计算值,使用 lazy。如果值是从外部“注入”的,使用 lateinit。
5. 使用 Delegates.observable 监听变化
Delegates.observable 接收一个初始值和一个回调。回调在新值已被赋值之后运行。
import kotlin.properties.Delegates
enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }
class Order(val id: String) {
val history = mutableListOf<String>()
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
history += "${property.name}: $old -> $new"
}
}
fun main() {
val order = Order("ORD-001")
order.status = OrderStatus.PAID
order.status = OrderStatus.SHIPPED
order.status = OrderStatus.SHIPPED
order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED
解释:
OrderStatus.NEW是初始值。将其赋值不会触发回调。- 回调接收三个参数:
property(元数据)、old(旧值)和new(新值)。 - 第三行表明回调仍会在你赋予相同值时运行。
如果你只想在值真正改变时做出反应,请在回调中自己过滤:
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
if (old != new) history += "$old -> $new"
}
observable 通常用于审计日志、将对象标记为“脏”需要保存或更新衍生值。
6. 使用 Delegates.vetoable 阻止无效值
vetoable 类似于 observable,但回调在赋值之前运行并返回 Boolean。如果回调返回 false,旧值保持不变。
import kotlin.properties.Delegates
class CartItem(val sku: String) {
var quantity: Int by Delegates.vetoable(1) { _, _, new ->
new in 1..99
}
}
fun main() {
val item = CartItem("SKU-42")
item.quantity = 5
println(item.quantity)
item.quantity = 0
println(item.quantity)
item.quantity = 150
println(item.quantity)
}
5
5
5
赋值 0 和 150 被默默拒绝,quantity 仍然是 5。
注意: 因为
vetoable不抛出异常,调用代码不会知道赋值失败了。对于重要的业务数据,例如支付金额,你应该在 setter 中使用require(...)来明确报告错误。vetoable适合那些忽略无效值是期望行为的情况,例如来自 UI 滑块的值限制。
7. 为委托编写测试
下面的测试部分检查三个主要行为:lazy 仅初始化一次、observable 记录历史和 vetoable 拒绝无效值。
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class DelegatedPropertiesTest {
class ExpensiveHolder(private val counter: AtomicInteger) {
val value: String by lazy {
counter.incrementAndGet()
Thread.sleep(50)
"ready"
}
}
@Test
fun `lazy 仅运行一次初始化器,即使多个线程同时访问`() {
val counter = AtomicInteger()
val holder = ExpensiveHolder(counter)
val executor = Executors.newFixedThreadPool(8)
repeat(8) { executor.submit { holder.value } }
executor.shutdown()
executor.awaitTermination(5, TimeUnit.SECONDS)
assertThat(holder.value).isEqualTo("ready")
assertThat(counter.get()).isEqualTo(1)
}
@Test
fun `observable 记录每次状态变化`() {
val order = Order("ORD-002")
order.status = OrderStatus.PAID
order.status = OrderStatus.DELIVERED
assertThat(order.history).containsExactly(
"status: NEW -> PAID",
"status: PAID -> DELIVERED"
)
}
@Test
fun `vetoable 在被拒绝时保持旧值`() {
val item = CartItem("SKU-7")
item.quantity = 10
item.quantity = -3
assertThat(item.quantity).isEqualTo(10)
}
}
第一个测试使用 8 个线程同时读取 value。使用默认的 SYNCHRONIZED 模式,counter 总是等于 1。如果你改为 LazyThreadSafetyMode.NONE 并多次运行,测试有时会失败。这是快速验证不同模式差异的方法。
8. 常见错误和何时不应该使用
在 JPA 实体中使用委托。 编译器生成的实际字段是 status$delegate,类型为 ObservableProperty,不是 OrderStatus。Hibernate 无法自然地映射这类字段。对于实体,请使用普通字段加自定义 setter 或实体监听器。
lazy 的 lambda 保留引用。 lambda 会被保留到运行完毕。如果它捕获了一个大对象而属性从不被读取,该对象仍会在内存中被保留。
在 init 块中过早读取 lazy。 如果 lambda 使用了在下面声明的属性,而你又在 init 中立即读取该 lazy 属性,你可能会得到未初始化的值,如 null 或 0。
替代框架的机制。 在 Spring 中,不要使用 by lazy 自己创建 bean 或管理连接池的生命周期。使用 @Lazy 或 ObjectProvider,让容器能够管理资源的关闭。
在 observable 中进行重操作。 回调在 setter 内同步运行。在回调中调用 HTTP 或写数据库会让一个简单的赋值变得缓慢且容易出错。改为发送事件而不是在原地做重操作。
误认为 observable 是线程安全的。 observable 和 vetoable 没有任何同步机制。如果多个线程同时赋值,回调可能会收到不正确的 old 值。
9. 总结
委托属性帮助你将属性访问逻辑从 class 中分离出来,使代码更简洁易读。你使用 by lazy 处理创建成本高的值,并保持默认的 SYNCHRONIZED 模式,除非有明确的理由改变。你使用 Delegates.observable 当需要在每次改变后做出反应,记住即使值没有改变,回调仍会运行。你使用 Delegates.vetoable 当想悄无声息地忽略无效值,而当需要明确报告错误时则改用 setter 中的 require。
一旦掌握了 getValue 和 setValue,你完全可以为自己的需求编写自定义委托,如字符串规范化、从 Map 读值或记录访问日志。你只需避免在框架需要看到真实字段的地方使用它们,例如 JPA 实体。