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 实体。

kotlindelegated propertieslazyobservabledelegates