扩展函数前面讲过(070 篇),这一节讲它的"孪生兄弟"——扩展属性,以及一个更冷门但很能区分基本功的话题:扩展伴生对象。这两个话题面试里问得不多,但一旦问了,能答上来的候选人很少,因为它触及"Kotlin 编译期到底做了什么"这一层。
先把结论放在前面:扩展属性没有 backing field。它本质上是一个方法,每次访问都调用一次 getter;var 形式的扩展属性则对应 getter + setter 一对方法。所以扩展属性不能保存状态,每次读都是一次计算——这是它与普通属性的根本差别。扩展伴生对象则是给"某个类的伴生对象"补扩展方法,语法上写作 val Klass.CompanionName.func() {},用来给别人的类加静态方法而不用改动原类。
机制背后的执行路径
扩展属性的本质,看一段代码就清楚:
class User(val name: String)
// 扩展属性:只有访问器,没有字段
val User.displayName: String
get() = "用户:$name" // 编译后是一个静态方法 displayName(User)
// var 形式:一对访问器
var User.tag: String
get() = storage.get(this) ?: ""
set(value) {
storage.put(this, value) }
编译产物等价于:
// 静态方法,非成员方法
public static String getDisplayName(User user) {
return "用户:" + user.getName(); }
由此推出三条硬约束:①不能有 backing field,所以扩展属性里无法直接 = 某值 存状态;②每次访问都执行 getter,所以重计算必须自己用 WeakHashMap 之类的外部结构缓存;③没有多态分派——扩展属性是静态解析的,编译器在编译期就绑定到那个具体类型上,运行期无法按实际类型重写。这与成员属性可以被子类 override 形成对比。
val 版本的扩展属性不需要写 get()(表达式体即可),var 版本必须同时提供 get 与 set(set 的参数类型必须是属性类型)。
再讲扩展伴生对象。Kotlin 允许给任何类的伴生对象补扩展:
class Database {
companion object {
fun open(): Database = Database() }
}
// 给 Database.Companion 扩展一组"静态"方法
fun Database.Companion.inMemory(): Database = Database().apply {
name = "mem" }
fun Database.Companion.isAvailable(): Boolean = try {
open(); true } catch (e: Exception) {
false }
// 调用:像静态方法一样
val db = Database.inMemory() // 读起来像静态方法,实际是扩展函数
它的用途场景很明确:给第三方类加上一组"这个类的方法"而不修改原类。比如给某个 SDK 的 Client.Companion 加上 withTimeoutOrDefault 这类扩展。这样既不污染原类,也比顶层函数更"贴"——调用时带着类名前缀,语义清楚。
需要注意的限制:扩展伴生必须与被扩展的伴生对象同名(Companion 或自定义名),且访问权限要够——如果伴生对象是 private,外部无法扩展。
真实工程场景的推演
场景一:ViewBinding 的扩展属性。工程里到处在写 binding.root,可以包一层:
val Fragment.viewBinding: FragmentHomeBinding
get() = requireNotNull(_binding) {
"必须在 onViewCreated 之后访问 binding" }
好处是把空检查与错误信息集中到一处,而且 getter 名(viewBinding)比 binding 更明确"这是一个 ViewBinding"。同时它在每次访问时做检查,比裸 _binding!! 更好排查。
场景二:重计算的扩展属性必须缓存。这是最容易踩的坑:
// 反例:每次访问都重新计算
val File.displaySize: String
get() = Formatter.formatFileSize(context, length()) // 每次读都走一遍 stat
// 修法一:显式缓存(WeakHashMap 避免持有)
private val sizeCache = WeakHashMap<File, String>()
val File.cachedDisplaySize: String
get() = sizeCache.getOrPut(this) {
Formatter.formatFileSize(context, length()) }
// 修法二:改成扩展函数并接收 context(明确依赖,无隐藏捕获)
fun File.displaySize(context: Context): String =
Formatter.formatFileSize(context, length())
扩展函数里访问外部作用域的变量(如某个 Context)不是问题——因为扩展函数是真正的静态方法,把上下文当参数传比从外部捕获更清晰。扩展属性因为没有参数、只能靠闭包捕获,反而更容易埋隐式依赖。
场景三:给第三方类加静态方法。假设用了某统计 SDK,它没有"带超时"的初始化:
fun Analytics.Companion.initWithTimeout(context: Context, ms: Long): Analytics =
runCatching {
init(context) }.getOrElse {
fallbackLocal(context) }.also {
registerTimeout(ms) }
// 调用:Analytics.initWithTimeout(app, 3_000)
这比写一个 object AnalyticsExt { fun initWithTimeout() } 更好,因为调用点带类名,读起来就是"给 Analytics 加的一个方法"。
场景四:什么时候不用扩展属性。①需要子类 override 时(扩展属性静态分派做不到);②需要保存状态时(没有 backing field);③需要多态或反射时(扩展属性不可见于运行时反射)。这三条说清楚,说明理解到位。
最常见的坑是
扩展属性里做了重计算且无缓存,每次访问都执行。 表现是列表滚动时 CPU 占用高、同一行反复触发格式化。修法:改成扩展函数并显式传依赖;或用 WeakHashMap 做进程内缓存;或在 remember/ViewModel 层算一次存起来。
其次是期望扩展属性能被反射或被子类覆盖。修法:需要这些能力就用普通属性(抽象属性 + override)或委托属性。
还有一个更隐蔽的坑:在扩展属性里捕获了 Context/Activity,形成泄漏。原因是 getter 每次执行都用到捕获的实例,而扩展属性的"隐藏依赖"从签名上看不出来。修法:把 Context 作为参数传(改扩展函数),或用 applicationContext 而非 Activity。
现场手写这一段就够了
// 1) 扩展属性:无 backing field,编译为静态方法
class User(val name: String, val age: Int)
val User.displayName: String get() = "用户:$name" // val 可用表达式体
var User.nickname: String // var 必须有 get+set
get() = storage.get(this) ?: ""
set(value) {
storage.put(this, value) }
// 等价 Java 产物:
// public static String getDisplayName(User u)
// public static String getNickname(User u)
// public static void setNickname(User u, String v)
// 2) 有状态的扩展属性 → 只能靠外部缓存
private val sizeCache = WeakHashMap<File, String>()
val File.cachedDisplaySize: String
get() = sizeCache.getOrPut(this) {
Formatter.formatFileSize(app, length()) }
// 3) 更好的做法:改成扩展函数,依赖显式传入
fun File.displaySize(context: Context): String =
Formatter.formatFileSize(context, length())
// 调用:file.displaySize(requireContext())
// 4) 扩展伴生对象:给第三方类加"静态方法"
class Database private constructor(val name: String) {
companion object {
fun open() = Database("disk") }
}
fun Database.Companion.inMemory(): Database = Database("mem") // ✅ 与伴生同名
fun Database.Companion.quickCheck(): Boolean = try {
open(); true } catch (e: Exception) {
false }
val db = Database.inMemory() // 读起来像静态方法
// 5) 扩展"自定义名"的伴生(伴生对象有名字时)
class Config private constructor() {
companion object Factory {
fun default() = Config() }
}
fun Config.Factory.withDefaults(): Config = apply {
/* 补充配置 */ }
// 6) 组合用法:给伴生加扩展,内部再调扩展函数
fun Database.Companion.migratedFrom(old: String): Database =
inMemory().also {
db -> log("migrated from $old"); db }
关键行解读:扩展属性编译为静态方法而非成员,get/set 一对;var 必须同时给出两个访问器;需要状态就用外部 WeakHashMap 或干脆改成扩展函数;扩展伴生要求与伴生对象同名(Companion 或 Factory);组合写法展示"扩展伴生内可继续调用其他扩展"。
面试追问四连
"扩展属性和普通属性有什么区别?" 答:扩展属性没有 backing field,本质是静态方法,每次访问都调 getter,且是静态分派、不能被 override;普通属性有字段、有访问器,可多态。
"扩展属性能保存状态吗?" 答:不能。没有 backing field,val 形式的扩展属性每次读取都执行 getter。要"记住"只能借助外部结构(WeakHashMap)或改成 ViewModel/委托属性来持有状态。
"扩展伴生对象有什么用?" 答:给任意类的伴生对象补方法,效果像"静态方法"但不改原类。适合给第三方 SDK 加便利 API,调用点带类名,语义清楚。
"扩展函数能访问类的私有成员吗?" 答:不能。扩展在类外部、编译为静态方法,没有访问 private/protected 成员的权限(除非在 Kotlin 编译期因同模块可见规则而允许声明,实际调用仍受访问检查)。这是它替代不了成员方法的地方。
落地建议
1. 团队约定:有状态或重计算的逻辑不要放扩展属性,用扩展函数 + 显式参数;扩展属性只用于"轻量派生"(displayName、格式化好的摘要)。 2. 提供工程级扩展集文件(Ext.kt / BindingExt.kt / SdkExt.kt),统一管理,避免散落各处重复定义。 3. 扩展属性访问 ViewBinding 时统一走一个带错误信息的 getter,把"必须在 onViewCreated 后访问"这类约束显式化。 4. 自动化预防:用 detekt 自定义规则限制扩展属性 getter 的行数(超过 3 行报警,提示改用扩展函数 + 显式依赖),从机制上防住"隐式依赖 + 无缓存"的组合。
给正在准备面试的你 把这题画成"字节码视角"对比图。左边画普通属性类 User,类框里有字段 name、虚线的方法 getName(),并标"字段存在、可 override、有多态分派";右边画扩展属性,框里是空的(打叉标"无 backing field"),旁边一个静态方法框 getDisplayName(User) 接收者参数,箭头从 User 实例指向它,标"静态方法、静态分派、不可 override"。图下再画一个小图:扩展伴生——Database 类框 + 旁边的虚线框 Database.Companion(扩展挂在这里),外面一个 Database.inMemory() 的调用箭头指向虚线框。
再补一个高分追问答案:"为什么 Kotlin 允许扩展属性却不给它 backing field?" 因为一旦给扩展属性字段,编译器就必须为每个文件/类生成持有它的容器,破坏"扩展是静态方法"的简单模型,也会带来生命周期与线程安全问题(谁来初始化、谁来释放)。语言设计上,能用扩展函数 + 显式参数表达的,就不引入隐式状态——这与 114 篇讲的"副作用推到边界"是同一种取向。答出"这体现了 Kotlin 避免隐式状态的设计原则",通常就是这题的高分点。
复习时别孤立刷题:const 与编译期常量——上一节讲编译期替换的一个具体产物,本节讲扩展在字节码里的形态,两者都在"编译器到底做了什么"这条主线上。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:const-与编译期常量:延迟初始化之外的第三个选择
下一篇预告:tailrec-与尾递归:递归优化的编译期支持
有任何问题欢迎在评论区留言交流。