Skip to content

Kotlin 委托属性(Delegated Properties) ​

回到总览:00 基础知识(Foundations)相关模块:Koin 依赖注入 4.0(其旧版 by inject() 就是属性委托;4.0 改用函数式 koinInject(),可对比属性委托的适用边界)

一句话定义 ​

委托属性用 by 把属性的 getter / setter 逻辑交给另一个对象实现,类本身不写存取代码、也没有 backing field。读写属性时,编译器插入对委托对象 getValue / setValue 的调用。

代码索引 ​

主题Lab 说明源码
委托属性运行验证Lab: Kotlin 委托属性运行验证DelegatedPropertiesDemo.kt

1. 实现原理 ​

1.1 by 编译后发生什么 ​

val x by d 不是别名,而是编译器在每次访问 x 时插入一次转发:

  • 读取 x → 调用 d.getValue(thisRef, property)
  • 写入 x(var)→ 调用 d.setValue(thisRef, property, value)

其中 property 是 KProperty<*> 元数据(属性名、类型等)。属性本身没有 backing field,它只是一个「转发壳」。

kotlin
class Foo {
    val x by lazy { "computed once" }   // 读 x 时 -> lazy.getValue(this, ::x)
}

对应 Lab 可验证:首次读 x 打印一次、后续读直接返回缓存(见 DelegatedPropertiesDemo.kt)。

1.2 内置委托的内部机制 ​

标准库提供四个开箱即用的委托工厂,本质都是返回实现了 getValue / setValue 的对象:

委托内部机制行为
lazy持有 lambda + 状态(UNINITIALIZED / INITIALIZED)与缓存值;默认 LazyThreadSafetyMode.SYNCHRONIZED首次访问求值并缓存,之后直接返回
observable持值 + onChange 回调,设值后触发回调、不改写值设值后通知
vetoable同 observable,但在设值前调用校验 lambda,返回 false 则保持旧值设值前可拒绝
map把属性名当作 key,读写转发到背后的 Map / MutableMap用字典作后端存储

1.3 这几个例子分别想让你建立什么判断 ​

  • lazy
    • 作用:把“什么时候初始化”收口成统一语义——首次真正读取时再创建。
    • 意图:避免把昂贵初始化散落成手写 if (x == null) 或重复 getter 逻辑。
    • 特点:只算一次并缓存;默认线程安全;更像“每个属性一份懒值”,不是全局单例。
    • 典型用例:昂贵对象、配置解析结果、首次进入页面才真正需要的 helper / repository。
  • observable
    • 作用:把“字段变化后我要收到通知”挂在属性本身。
    • 意图:把日志、埋点、调试观察从业务逻辑里抽离。
    • 特点:赋值已经成功后才回调;能拿到 property / old / new;不能拦截也不能改写最终值。
    • 典型用例:状态变化日志、调试期追踪字段被谁改了、轻量级变更监听。
  • vetoable
    • 作用:把“赋值前先审批”嵌到属性写入路径里。
    • 意图:阻止属性进入非法状态,而不是事后补救。
    • 特点:返回 true 才写入;返回 false 静默保持旧值,不抛异常。
    • 典型用例:分数范围、重试次数、分页参数、状态机合法流转。
  • map
    • 作用:把“从字典里用 key 取值”的样板代码改成对象属性访问。
    • 意图:让轻量配置 / JSON / 动态字段映射更紧凑、更好读。
    • 特点:属性名与 Map key 绑定;适合轻量后端,不适合复杂强类型领域模型。
    • 典型用例:配置表、轻量 JSON、arguments / extras、动态字段集合。
  • 自定义委托
    • 作用:把统一读写策略抽成一个可复用组件。
    • 意图:避免在每个字段上重复写 trim、校验、归一化、持久化逻辑。
    • 特点:外部看起来像普通属性,内部可在 getValue / setValue 做改写、代理或持久化。
    • 典型用例:统一 trim、大小写归一、脱敏、SharedPreferences / DataStore 代理。

1.4 自定义委托 ​

任何对象只要提供 operator fun getValue(thisRef, property)(写属性再加 setValue),就能当委托;也可直接实现接口 ReadOnlyProperty / ReadWriteProperty,省去手写 operator 关键字:

kotlin
class UpperCaseDelegate : ReadWriteProperty<Any?, String> {
    private var value = ""
    override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value.uppercase()
    }
}
var name by UpperCaseDelegate()   // name = "ada" 后读出来是 "ADA"

2. 特点与优势 ​

2.1 它解决什么 ​

把「何时算、算几次、存哪、是否通知」这类存取逻辑从调用方抽离,集中到一个可复用的委托对象里。同一个 by 语法即可复用懒加载、变更监听、字段校验、外部存储等逻辑,调用方代码不变。

2.2 与其他实现方式 / 概念的区别 ​

  • vs 手写 getter / setter:手写要在每个类里重复「判断 + 缓存 + 通知」代码;委托把这些逻辑写一次、到处 by,优势是消除重复、逻辑集中。
  • vs 扩展属性:扩展属性是给已有类加访问器,底层仍读写已有字段或计算;委托属性是本类属性的存取外包给另一对象,本类无 backing field。
  • vs 双重检查锁(DCL)单例:lazy 默认线程安全且锁粒度更细,比手写 DCL 简洁;但 lazy 是「每个属性一份懒值」,DCL 是「每个类一份单例」,语义层级不同。
  • vs 组合(持有另一对象并调用其方法) :委托是存取行为的编译期转发;组合是运行期持有关系。两者不冲突,可叠加。
  • 适用边界(与 Koin 的回扣) :属性委托的「首次访问缓存」语义适合「生命周期内稳定一次」的取值(单例、懒加载);但在 Compose 重组模型里「随重组重算」的取值用 remember / derivedStateOf 更直接。Koin 4.0 因此把 by inject() 改成函数式 koinInject()——这不是委托属性「不好」,而是缓存语义与重组重算语义冲突。

3. 用法 ​

3.1 内置委托 ​

对应 Lab:delegated-properties README · DelegatedPropertiesDemo.kt

kotlin
// 懒加载重资源(替代 DCL)
class Reporter {
    val html by lazy { buildExpensiveHtml() }   // 用到才建,且只建一次
}

// 变更监听
var score by Delegates.observable(0) { _, old, new -> println("score: $old -> $new") }

// 设值前校验(返回 false 拒绝)
var level by Delegates.vetoable(0) { _, _, new -> new in 0..100 }
level = 150   // 被拒,level 仍为 0

// 配置 / JSON -> 对象字段(免逐个 get("key"))
val config = mapOf("timeout" to "30", "host" to "api")
val timeout: String by config   // 读 timeout 即读 config["timeout"]

3.2 自定义委托 ​

对应 Lab:delegated-properties README · DelegatedPropertiesDemo.kt

实现 ReadOnlyProperty(只读)或 ReadWriteProperty(读写),见 §1.4 示例。常见用途:从外部存储(SharedPreferences / 数据库)读字段、统一日志、字段级校验。

3.3 运行验证 ​

delegated-properties Lab 已实跑通过:验证 lazy 只算一次、observable 设值后通知、vetoable 拒绝赋值、自定义 UpperCaseDelegate 与 map 后端。

本页回答的问题(一句话答) ​

  • 委托属性读写时实际调用什么、有 backing field 吗?
    • 一句话答:读取调用 delegate.getValue(thisRef, property)、写入调用 delegate.setValue(...);属性无 backing field,只是转发壳。
  • lazy 解决的到底是什么问题?
    • 一句话答:它解决“昂贵对象不想一开始就建、但建完后又希望整个生命周期只复用一份”的问题。
  • observable 和 vetoable 的根本区别是什么?
    • 一句话答:observable 是写入后通知,vetoable 是写入前审批;前者只观察,后者可拒绝。
  • map 委托适合拿来替代正式领域模型吗?
    • 一句话答:不适合;它更适合轻量配置和动态字段映射,复杂强类型对象仍应建明确模型。
  • lazy 默认线程安全吗?与 DCL 单例区别?
    • 一句话答:默认 SYNCHRONIZED 线程安全;lazy 是「每属性一份懒值」,DCL 是「每类一份单例」。
  • vetoable 拒绝赋值时抛异常还是静默保持旧值?
    • 一句话答:静默保持旧值,不抛异常;依赖赋值结果需自行读回确认。
  • 为什么 Koin 4.0 弃用 by inject()?
    • 一句话答:Compose 重组要求「随重组重算」用函数调用更直接;by 的首次缓存语义在重组里反而是坑,故改 koinInject()。

对应实验 ​

Lab说明源码
delegated-properties运行验证 lazy 只算一次、observable 设值后通知、vetoable 拒绝、自定义委托与 map 后端Lab: Kotlin 委托属性运行验证 · DelegatedPropertiesDemo.kt

复习检查题 ​

  1. val x by delegate 读取 x 时,字节码层面实际调用了什么?有 backing field 吗? 答:编译后属性无 backing field,读取被重写为 delegate.getValue(thisRef, property);写入属性则是 delegate.setValue(...)。属性只是对委托对象的转发壳。
  2. observable 和 vetoable 的根本区别是什么? 答:observable 在写入完成后通知,只能观察变化;vetoable 在写入前校验,返回 false 时可静默拒绝本次赋值。
  3. lazy 默认线程安全吗?它与手写双重检查锁(DCL)单例的核心区别是什么? 答:lazy 默认 LazyThreadSafetyMode.SYNCHRONIZED,线程安全且锁粒度细;区别在于 lazy 是「每个属性一份懒值」,DCL 是「每个类一份单例」,语义层级不同。
  4. map 委托为什么适合轻量配置映射,却不适合复杂强类型模型? 答:因为它本质仍依赖字符串 key 与运行时取值,适合轻量、动态、字段少的场景;复杂模型需要更明确的类型约束、校验与演进能力。
  5. vetoable 拒绝一次非法赋值时,会抛异常还是静默保持旧值? 答:静默保持旧值,不抛异常。调用方若依赖赋值结果,需读回确认或改用抛异常的显式校验。
  6. 为什么 Koin 4.0 把 by inject() 弃用、改成 koinInject()? 答:Compose 重组模型要求「随重组重算」的取值用 Composable 函数更直接;by inject() 的「首次访问缓存」语义在 Compose 里反而是坑,故改为函数式 koinInject()。

速记 ​

  • by = 把 get/set 外包:属性无 backing field,读写都转发给委托的 getValue / setValue。
  • 四类内置:lazy(只算一次)/ observable(设值后通知)/ vetoable(设值前可拒)/ map(字典后端)。
  • 自定义:实现 getValue(+setValue) 或 ReadOnlyProperty / ReadWriteProperty。
  • 工程判断:lazy 解决延迟初始化,observable 解决事后通知,vetoable 解决事前拦截,map 解决轻量映射。
  • 边界:随重组重算用 remember / derivedStateOf;by 只适合「稳定一次」的取值。

站点构建时间:2026/8/24 23:43:17