委托属性 让你可以把属性的 getter 和 setter 逻辑交给另一个对象处理。
通过 by 关键字,属性会连接到一个委托对象,由它来定义属性如何存储和读取。
如何工作
正如上面提到的那样,委托属性把 getter 和 setter 委托给另一个对象(称为委托)。
委托负责管理属性的值,并提供自定义的访问或修改逻辑。
好的,接下来为了加深理解,我来举一些常见的使用场景:
延迟初始化 lazy
lazy 委托让属性只在首次访问时才初始化,避免对象创建时就进行初始化:
val lazyValue: String by lazy {
println("Computed!")
"Hello, Kotlin!"
}
这段代码中,"Computed!" 只会在第一次访问 lazyValue 时打印,值 "Hello, Kotlin!" 也只在此时被赋值。
可观察属性 observable
这个委托让你能在值变化时触发回调:
import kotlin.properties.Delegates
var observableValue: String by Delegates.observable("Initial value") { _, old, new ->
println("Value changed from $old to $new")
}
observableValue = "New value"
// 输出: Value changed from Initial value to New value
可否决属性 vetoable
vetoable 和 observable 类似,但可以根据条件否决更改:
var vetoableValue: Int by Delegates.vetoable(0) { _, old, new ->
new > old // 只有新值大于旧值才接受
}
vetoableValue = 5 // 允许
vetoableValue = 2 // 被否决,值不变
Map 委托
这种方式适合动态属性,属性名和值都存在 Map 里:
class User(val map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}
val user = User(mapOf("name" to "John Doe", "age" to 30))
println(user.name) // John Doe
println(user.age) // 30
你会发现,你无需写任何额外的代码,Kotlin 天然就给你了 map 委托的语法糖。
局部委托属性
KEEP 提案 Local delegated properties 引入了局部委托属性,允许在函数或代码块内声明的属性使用委托。
这让局部变量也能把行为委托给自定义委托,增强了代码的可重用性和封装性,同时保持了与全局和类级委托的一致性。
import kotlin.reflect.KProperty
class Delegate {
operator fun getValue(t: Any?, p: KProperty<*>): Int = 1
}
fun box(): String {
val prop: Int by Delegate()
return if (prop == 1) "OK" else "fail"
}
个人觉得这个功能算是个面子工程,至少统一了成员变量和普通变量的行为,实际意义并不大。
总结
委托属性提供了一种优雅的方式,把属性访问委托给其他对象来实现特定行为——延迟初始化、变更观察、条件否决或映射属性值。这让代码更简洁、更模块化,属性管理逻辑可以和其他代码清晰分离。
进阶:Lazy 的内部机制
Kotlin 的 lazy 委托非常实用。它创建的属性只在首次访问时计算值,然后缓存起来供后续调用使用。表面上看 API 很简单,但深入源码你会发现一个设计精良的系统——基于 Lazy 接口,提供了多种专门实现来处理不同的线程安全需求。
Lazy 接口
整个 lazy 机制围绕一个简洁的接口构建:
public interface Lazy<out T> {
/**
* 获取延迟初始化的值。
* 一旦初始化完成,值在 Lazy 实例的整个生命周期内不会改变。
*/
public val value: T
/**
* 判断值是否已初始化。
* 一旦返回 true,就会一直保持 true。
*/
public fun isInitialized(): Boolean
}
- value: T:只读属性,是主要入口。首次访问时触发初始化器 lambda 执行,后续访问直接返回缓存结果。
- isInitialized(): Boolean:让你检查初始化器是否已运行,而不会触发它。
属性委托的关键在于 getValue 操作符扩展函数:
@kotlin.internal.InlineOnly
public inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
它简单地把属性读取委托给 lazy.value。
内部状态管理
从内部看,所有的 Lazy 实现都采用相同的状态跟踪策略:使用一个特殊的单例对象 UNINITIALIZED_VALUE 作为内部标记。私有的 _value 字段初始化为这个标记。访问 value 时,检查 _value 是否 === UNINITIALIZED_VALUE——是则运行初始化器,否则返回缓存值。
初始化器运行后会发生两件事:
_value更新为计算结果- 初始化器 lambda 的引用置为
null,允许垃圾回收,防止内存泄漏
三种实现模式
如果你好奇 lazy 的实现,那么你会看到下面这样的代码:
public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
when (mode) {
LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
}
lazy 函数是一个工厂,根据 LazyThreadSafetyMode 返回三种不同的内部实现:
1. NONE
最简单、最快,但最不安全:
override val value: T
get() {
if (_value === UNINITIALIZED_VALUE) {
_value = initializer!!()
initializer = null
}
return _value as T
}
机制:简单的非同步检查。首次访问时运行初始化器并存储结果。
使用场景:完全不提供线程安全。如果两个线程同时访问未初始化的实例,初始化器可能被调用两次。多线程环境下行为未定义。只有当你能保证 lazy 属性只从单线程初始化和访问时才使用。因为没有同步开销,性能最佳。
2. SYNCHRONIZED
默认实现,最健壮,保证即使在高度并发环境下初始化器也只执行一次:
override val value: T
get() {
if (_value !== UNINITIALIZED_VALUE) {
return _value as T
}
return synchronized(lock) {
if (_value !== UNINITIALIZED_VALUE) {
_value as T
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
机制:经典的双重检查锁定模式。
- 第一次检查(无锁):先在同步块外检查
_value。如果已初始化,直接返回,省去获取锁的开销。这让首次访问后的后续访问非常快。 - 第二次检查(带锁):如果未初始化,进入
synchronized(lock)块。在锁内再次检查_value。这第二次检查很关键——处理了竞争条件:另一个线程可能在第一次检查和当前线程获取锁之间完成了初始化。 - 初始化:只有值仍未初始化时,当前线程才执行初始化器 lambda 并存储结果。
使用场景:默认模式,是任何可能被多线程访问的属性的最安全选择。保证初始化的原子性和结果在所有线程中的可见性(得益于 synchronized 的内存保证和 _value 上的 volatile 注解)。
3. PUBLICATION
提供更宽松的线程安全形式:保证只使用一个最终值,但允许初始化器被调用多次:
override val value: T
get() {
if (_value !== UNINITIALIZED_VALUE) {
return _value as T
}
val initializerValue = initializer
if (initializerValue != null) {
val newValue = initializerValue()
// 尝试原子设置值
if (valueUpdater.compareAndSet(this, UNINITIALIZED_VALUE, newValue)) {
initializer = null
return newValue
}
}
return _value as T
}
机制:使用 AtomicReferenceFieldUpdater 的无锁方法。
- 多个线程可以同时访问未初始化的值并并发调用
initializerValue()lambda,竞争成为第一个设置值的线程。 compareAndSet(CAS) 是原子操作。只有当_value仍然是UNINITIALIZED_VALUE时,才会成功设置为newValue。- 只有一个线程会"赢得"竞争,它的
newValue成为最终值。其他计算了值的线程会丢弃结果,使用胜出者的值。
使用场景:当初始化器是廉价的幂等操作(可以安全多次调用)且你想避免锁的潜在竞争时很有用。它用可能的冗余计算换取无锁并发。
进阶:lazy 的 Java 字节码
Kotlin 的 by lazy { ... } 是个巧妙的性能优化——把昂贵的计算推迟到实际需要时才执行。语法简洁,但 Kotlin 编译器做了特定的转换来实现这个行为。
从一个简单例子开始:
class UserSession {
val heavyUserData: String by lazy {
println("Computing heavy user data...")
Thread.sleep(1000)
"User Profile Data"
}
}
"Computing heavy user data..." 只在首次访问 heavyUserData 时打印。编译后反编译为 Java,lazy 属性被转换成几个组件:
public final class UserSession {
// 1. 保存 Lazy 实例的私有 final 字段
@NotNull
private final Lazy heavyUserData$delegate;
// 2. 属性的静态元数据字段
static final /* synthetic */ KProperty<Object>[] $$delegatedProperties =
new KProperty[] { (KProperty) new PropertyReference0Impl(
UserSession.class, "heavyUserData",
"getHeavyUserData()Ljava/lang/String;") };
// 3. 公共 getter 方法
@NotNull
public final String getHeavyUserData() {
return (String) this.heavyUserData$delegate.getValue(this, $$delegatedProperties[0]);
}
public UserSession() {
// Lazy 实例在构造函数中创建
this.heavyUserData$delegate = LazyKt.lazy((Function0) new Function0<String>() {
@NotNull
public final String invoke() {
System.out.println("Computing heavy user data...");
Thread.sleep(1000L);
return "User Profile Data";
}
});
}
}
三个关键组件
1. Lazy 委托字段
编译器不会创建名为 heavyUserData 的字段来存 String 值,而是创建一个带 $delegate 后缀的私有 final 字段:
private final Lazy heavyUserData$delegate;
类型是 kotlin.Lazy,用来保存延迟初始化器对象实例(比如 SynchronizedLazyImpl)。
构造函数中,这个字段通过 LazyKt.lazy(...) 初始化。你提供的 lambda 被编译成匿名 Function0 类传给 lazy 工厂函数,SynchronizedLazyImpl(或其他模式)就在这里创建和存储。
2. KProperty 元数据字段
这个静态数组支持反射功能:
static final KProperty<Object>[] $$delegatedProperties
包含委托属性的元数据:名称(heavyUserData)、所有者(UserSession.class)、getter 签名。
这让 getValue 操作符函数知道正在访问哪个属性。对于简单的 lazy 委托,主要是满足 getValue 函数签名要求。
3. 公共 Getter 方法
这是属性的公共访问点。Kotlin 中写 session.heavyUserData,实际调用的就是这个方法:
public final String getHeavyUserData() {
return (String) this.heavyUserData$delegate.getValue(this, $$delegatedProperties[0]);
}
getter 不返回简单字段,而是委托给 heavyUserData$delegate 中 Lazy 实例的 getValue 操作符函数。
如前所述,Lazy<T>.getValue(...) 是核心逻辑所在——首次调用时执行初始化器 lambda、缓存结果并返回,后续调用直接返回缓存值。
具体一点,如果以默认实现 SYNCHRONIZED 为例,当我们调用属性的 getter 时,实际上是调用的 SynchronizedLazyImpl 实现中的 getValue 方法。
这就是 lazy 行为的实现原理:实际计算只发生在 getValue 调用内部,而 getValue 只在首次调用 getter 时触发。
没什么魔法,只是 Kotlin 编译器在背后做了更多工作。
如果学习了委托,那么就不得不提到 Kotlin 的另一个特性:幕后字段和幕后属性。
幕后字段和幕后属性
幕后字段(Backing Fields)和幕后属性(Backing Properties)都是用来管理属性值并提供受控访问的机制。虽然用途相似,但实现方式和适用场景各有不同。
幕后字段
当你为属性定义自定义 getter 或 setter 并使用 field 关键字时,Kotlin 会自动生成一个幕后字段。这是一个隐式的存储机制,让属性既能保存值,又能执行自定义的访问或修改逻辑。
看个例子:
var name: String = "Default"
get() = field.uppercase() // 使用幕后字段的自定义 getter
set(value) {
field = value.trim() // 使用幕后字段的自定义 setter
}
这里的 field 就是 name 属性的幕后字段。Kotlin 会把属性值存到 field 里,然后通过你定义的 getter 和 setter 来读写它。
幕后属性
幕后属性则是你显式定义的一个变量,专门用来存储属性的实际值。和幕后字段不同,幕后属性需要手动创建,这让你能完全掌控属性的内部表示。
当你需要更高级的自定义时,这种方式就派上用场了——比如把实际存储私有化,同时对外暴露一个计算属性:
private var _age: Int = 0 // 幕后属性,存储实际值
var age: Int
get() = _age
set(value) {
if (value >= 0) _age = value // 自定义验证逻辑
}
这个例子中,_age 是私有的幕后属性,负责管理 age 属性的实际存储。公共的 age 属性通过自定义逻辑访问 _age,实现了存储的封装。
主要区别
两者的核心差异在于实现方式和灵活性:
幕后字段由 Kotlin 隐式提供,只能在属性的 getter 或 setter 内部通过 field 关键字使用。它和属性绑定在一起,适合简单场景。
幕后属性需要显式声明,提供了更灵活的属性行为处理方式。它支持更复杂的逻辑和自定义存储机制,是高级自定义场景的理想选择。
实际上,这里的区分有一个诀窍——幕后属性一定会创建一个新的变量去存储信息!
总结
幕后字段和幕后属性听起来很像,都是用来管理属性值的受控访问。区别在于:幕后字段是隐式的,直接和属性绑定,适合简单的自定义逻辑;幕后属性是显式定义的,把存储和属性本身解耦,提供了更大的灵活性。
理解什么时候用哪种机制,能帮你写出更健壮、更易维护的 Kotlin 代码。