Kotlin 委托属性实战:用 by 把 getter/setter 的脏活外包出去
你有没有在一个类里写过四五段长得几乎一模一样的代码——每次读取属性都要先判空、每次修改属性都要记日志、每次保存设置都要开编辑器?
比如"懒加载一个数据库连接",Java 风格写出来是这样的:
class Repository {
private var _db: Database? = null
val db: Database
get() {
if (_db == null) {
synchronized(this) {
if (_db == null) { // 双重检查锁
_db = Database.open()
}
}
}
return _db!!
}
}
逻辑不难,但你每写一个需要懒加载的属性,就得复制一遍这坨东西。要是再加个"值变化时要通知界面刷新"的需求,你的类会被样板代码彻底淹没。
Kotlin 的 委托属性(Property Delegation) 就是来回收这些重复劳动的。语法只有一个字符:
class Repository {
val db: Database by lazy { Database.open() }
}
完事。第一次访问 db 时才会执行 Database.open(),之后每次访问都直接返回缓存好的那个实例——线程安全由 lazy 内部处理。读写逻辑被"外包"给了 by 后面的委托对象,这就是它的全部秘密。
by 的底层约定:编译器帮你转发
val p: T by delegate 的意思非常直白:把 p 的读取交给 delegate.getValue(...),把 p 的赋值交给 delegate.setValue(...)。
编译器实际生成的代码大致长这样:
// 你写的
class Person {
var name: String by Delegate()
}
// 编译器等价物
class Person {
private val name$delegate = Delegate()
var name: String
get() = name$delegate.getValue(this, ::name)
set(value) = name$delegate.setValue(this, ::name, value)
}
看到 this 和 ::name 了吗?委托对象会收到谁持有这个属性和属性本身的元信息(名字、类型都能拿到)。这就是为什么一个委托可以同时服务于不同名字的多个属性,还能在日志里打印出"是哪个属性变了"。
想自己写委托,只要满足这个约定即可——注意 operator 关键字不能少:
import kotlin.reflect.KProperty
class LoggingDelegate<T>(private var value: T) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
println("读取 ${property.name} = $value")
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
println("${property.name} 从 $value 变成 $newValue")
value = newValue
}
}
class Person {
var name: String by LoggingDelegate("张三")
var age: Int by LoggingDelegate(18)
}
fun main() {
val p = Person()
p.name = "李四" // 输出:name 从 张三 变成 李四
println(p.age) // 输出:读取 age = 18
}
val 只需要 getValue;var 需要两个都实现。写一次,全项目的属性都能复用。
标准库三个最实用的委托
lazy:首次访问才计算
除了懒加载对象,它还适合"配置读取""解析大文件"这类开销大的操作。lazy 默认是线程安全的(SYNCHRONIZED),单线程场景可以传 LazyThreadSafetyMode.NONE 省掉锁开销。
val config: AppConfig by lazy { loadConfigFromDisk() }
一个常见踩坑:lazy 只保证 lambda 执行一次,不保证里面的副作用只发生一次以外的东西——它缓存的是 lambda 的返回值。如果你在里面读了可变状态,结果可能和你预期的不一致。
observable:改完之后做点事
先看不用委托时,"值一变就刷新界面"得这么手写:
class Cart {
var total: Int = 0
set(value) {
val old = field
field = value
println("total: $old → $value") // 记日志
refreshBadge(value) // 刷界面
}
}
每加一个需要监听的属性,就得重复一遍 set 块。换成委托,这套逻辑就能抽走:
import kotlin.properties.Delegates
class Cart {
// 回调参数依次是:属性本身、旧值、新值
var total: Int by Delegates.observable(0) { prop, old, new ->
println("${prop.name}: $old → $new")
refreshBadge(new)
}
}
值变化的通知逻辑集中一处,界面刷新再也不会漏调。
vetoable:改之前先否决
observable 是"事后通知",vetoable 是"事前审批"——回调返回 false 就拒绝这次赋值,属性保持旧值:
class Session {
var level: Int by Delegates.vetoable(1) { _, _, newValue ->
newValue in 1..99 // 超出范围直接不认
}
}
fun main() {
val s = Session()
s.level = 200
println(s.level) // 1,非法值被拦下了
s.level = 5
println(s.level) // 5
}
参数校验、权限控制这类需求,从此不用在每个 setter 里写 if。
Map 委托:零成本把字典映射成对象
这一招新手最容易"哇"出来。数据源是一个 Map(JSON 解析结果、配置项很常见),你想让它伪装成对象属性:
class User(private val map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}
fun main() {
val user = User(mapOf("name" to "Alice", "age" to 20))
println(user.name) // Alice
println(user.age) // 20
}
一行 getter 都没写。键名默认取属性名,不匹配时手动指定:val userName: String by map["name"]。
要注意:Map 里找不到对应键时,访问会抛 NoSuchElementException。需要兜底就先过滤好数据,或者改用自定义委托加默认值。
实战:把 SharedPreferences 抽成委托
这个例子最贴近真实项目,也最能体现"写一次、到处用"的价值:
class PrefsDelegate<T>(
private val prefs: SharedPreferences,
private val default: T
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
val key = property.name
@Suppress("UNCHECKED_CAST")
return when (default) {
is String -> prefs.getString(key, default) as T
is Int -> prefs.getInt(key, default) as T
is Boolean -> prefs.getBoolean(key, default) as T
else -> error("不支持的类型:${default::class}")
}
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
prefs.edit().apply {
when (value) {
is String -> putString(property.name, value)
is Int -> putInt(property.name, value)
is Boolean -> putBoolean(property.name, value)
else -> error("不支持的类型:${value::class}")
}
}.apply()
}
}
class Settings(private val prefs: SharedPreferences) {
var nickname: String by PrefsDelegate(prefs, "游客")
var theme: String by PrefsDelegate(prefs, "light")
var notificationsOn: Boolean by PrefsDelegate(prefs, true)
}
使用方拿到的是彻底自然的属性读写体验:
settings.nickname = "Kotlin 新手" // 自动落盘
if (settings.notificationsOn) { ... } // 自动读取
键名、类型分发、编辑器开关、默认值全部隐藏。新增一个设置项只要加一行 var——原来的写法每次都得重复十几行 getString/putString/apply。
什么时候用、什么时候别用
适合用委托的场景有个共同特征:多个属性共享同一套读写行为。延迟计算、变化通知、值校验、外部存储映射、类型转换、缓存兜底,都属于这一类。
几个需要绕开的坑:
- 数据类主构造函数的参数不能用委托。
data class User(val name: String by map)直接编译报错。需要委托就把属性写到类体里,或者改用普通类。 - 别为了委托而委托。 属性只是简单存个值,普通
var就是最优解;加一层委托反而增加阅读成本。 by lazy不等于lateinit。 两者解决不同问题:lazy用于val、由 lambda 提供值;lateinit用于var、值由外部稍后注入。
今日挑战
给你的项目找一个"属性变化后要做点什么"的地方(计数器、主题切换、用户等级),用 Delegates.observable 或 Delegates.vetoable 重写它;然后进阶一步——写一个 RangeDelegate,让属性只能在指定区间内取值,超出范围时自动夹紧(clamp)而不是拒绝赋值。做完你会对"属性读写原来是可编程的"有全新的理解。