第085篇 类委托 by:装饰器模式的一行实现

简介: Kotlin类委托`by`是编译期语法糖,自动生成接口抽象方法的转发实现(如`delegate.foo()`),零反射、零运行时开销。但仅支持接口/抽象方法,不委托`Any`三方法、具体类方法及Java默认方法;委托对象天然共享状态,需注意隔离与可变性边界。

类委托 by 是 Kotlin 里最省样板代码的语法糖——一句话把"手写装饰器"变成"一行声明"。但它有明确的适用边界与两个容易踩的坑。面试里问"类委托怎么实现的",考的是你知道编译器生成了什么,而不是"你会用 by"。

先把结论放在前面:class Derived(b: Base) : Base by b 让编译器生成每个接口方法的转发实现——每一个 Base 的方法都编译成 b.方法名(参数)。所以类委托在实现层面等价于手写装饰器,零运行时魔法、零反射。关键限制是:只能委托接口(interface)或抽象类的抽象方法,不能委托具体类的具体方法——因为具体类的方法有实现,委托不会覆盖它。

机制拆解

看编译产物就一目了然。给定:

interface Logger {
    fun log(msg: String); fun level(): Int }
class ConsoleLogger : Logger {
    override fun log(msg: String) {
    } ; override fun level() = 0 }
class Service(private val delegate: Logger) : Logger by delegate

编译器为 Service 生成两个方法:log(msg) { delegate.log(msg) } 与 level() { return delegate.level() }。如果 Logger 有 20 个方法,就有 20 个这样的转发方法。 这带来两个直接推论:①字节码随接口方法数增长——和上一批讲的 inline 一样会膨胀,但方向相反(inline 是每个调用点一份,这个是每个实现类一份);②加接口方法会要求所有实现类跟进(委托类由编译器生成,所以它自动跟进;手写实现类则要自己加)。

第三个推论是关于 equals/hashCode/toString:委托不会覆盖 Any 的这三个方法。Service 的 toString() 打印的是 Service@1a2b3c 而不是被委托对象的字符串。这在日志与调试时容易造成误判——看到 Service@xxx 不能认为委托出了问题。

这些坑的正确绕法

最常见的坑是委托对象被外部共享修改,两个包装者互相影响状态。 表现是 val shared = CacheImpl(); val a: Cache by shared; val b: Cache by shared,通过 a 写入的数据在 b 里也能看到;或者两个本应互相隔离的组件因为共用了一个委托对象而串数据。根因是委托只是转发,包装者之间不持有独立状态——这是它的设计意图,但不符合"装饰者应该有自己的状态"的直觉。修法:①如果需要隔离,就让两个包装者各自 new 一个委托对象;②如果确实是"同一个共享资源"(连接池、缓存),那么共享是正确用法,但要在文档/注释里写明这是有意共享,避免后来者当成 bug 改掉。

其次是把 by 用在了"想替换实现"的场景,但被委托对象创建在构造期,替换不了。 表现是 class Service by loggerImpl 在构造时就固定了实现,测试时想注入假实现做不到;或者运行期要切换实现(比如从 HTTP 切到缓存)却发现无从下手。根因是by 的委托对象是构造参数,没有 setter。修法三条:①在委托对象上做可替换的内部策略(委托给一个内部的 var 实现,切换时改这个 var),②改用组合(持有 var delegate 并手写转发方法),③用接口 + 工厂方法在需要时构造不同的委托对象。"用 by 表达固定委托,用组合表达可变委托" 是这条边界的口诀。

还有一个更隐蔽的坑:委托的是 Java 接口时,default method 不参与转发,调用会静默走到接口默认实现。 Kotlin 接口自己写的方法体不存在这个问题(Kotlin 1.4 起新的 jvm-default 模式已修好,委托对象覆写的方法会被正常转发);但 Java 的 default method 按设计就不被转发——即使委托对象已经覆写了它,调用方拿到的仍是接口里那行默认实现。这个坑的可怕之处在于它不报错:日志打出来是"方法没生效",而不是崩溃,排查时很难联想到委托。典型场景是给一个 Java 库接口做包装,Java 接口里有一两个 default method 兜底逻辑,你以为自己覆盖了、实际委托类调的是接口默认实现,行为悄悄不同。修法:给这类委托类补一条单测,逐个断言走的是委托对象的实现;或者在类体里显式 override 转发给委托对象,把行为钉死。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 1) 基础类委托:等价于手写装饰器
interface Cache {
    fun get(k: String): String?; fun put(k: String, v: String); fun size(): Int }
class MemCache : Cache {
   
    private val m = HashMap<String, String>()
    override fun get(k: String) = m[k]
    override fun put(k: String, v: String) {
    m[k] = v }
    override fun size() = m.size
}
class LoggingCache(private val inner: Cache) : Cache by inner     // 编译器生成 3 个转发方法
// 手写等价物(模板代码):
// class LoggingCache(private val inner: Cache) : Cache {
   
//     override fun get(k: String) = inner.get(k)
//     override fun put(k: String, v: String) = inner.put(k, v)
//     override fun size() = inner.size
// }

Log.d("t", LoggingCache(MemCache()).toString())   // Service-like 类的 toString 不被委托

// 2) 共享 vs 隔离:by 天然共享
val shared = MemCache()
val a: Cache by shared
val b: Cache by shared
a.put("k", "v")
Log.d("t", b.get("k"))                            // v —— 有意共享
val iso1: Cache by MemCache()                     // 各自独立
val iso2: Cache by MemCache()

// 3) 可替换委托:by 不行,要用组合
class SwitchableCache(private var impl: Cache = MemCache()) : Cache {
   
    fun switchTo(next: Cache) {
    impl = next }      // 组合才能切换
    override fun get(k: String) = impl.get(k)
    override fun put(k: String, v: String) = impl.put(k, v)
    override fun size() = impl.size
}
// 折中:委托给一个可切换的包装器
class Switchable(private var impl: Cache) : Cache by impl {
   
    fun switchTo(next: Cache) {
    impl = next }      // 仍需至少手写一个切换方法
}

// 4) Android 上的真实落点:View 的点击装饰、缓存层
interface Presenter {
    fun attach(v: View); fun detach() }
class LoggingPresenter(private val real: Presenter) : Presenter by real {
   
    override fun attach(v: View) {
    Log.d("t", "attach"); real.attach(v) }   // 加行为
}

// 5) 委托不能覆盖具体类的具体方法
open class Base {
    open fun f() = "base" }
// class Impl(b: Base) : Base by b   ← 错误:Base 是类不是接口
// 反例:非 open 方法不会被转发
class Derived(b: Base) : Base() {
   
    private val inner = b
    override fun f(): String = inner.f() + "+derived"   // 必须手写
}

这段代码值得盯三处:第一处,第 1 段把 by 与"手写等价物"注释并列,直接说明"样板代码归零"是什么意思;第二处,第 3 段给出 by 不适用的场景与两种解法(组合、可切换包装器),并在注释里点出那句口诀"用 by 表达固定委托,用组合表达可变委托";第三处,第 5 段说明委托只能作用于接口的抽象方法,具体类的非 open 方法必须手写。

这题在面试里怎么问、怎么答

"类委托的实现原理是什么?"答:编译器为委托类生成每个抽象接口方法的转发实现,每个方法体是 delegate.方法(参数)。字节码层面是普通的方法转发,invokevirtual 调用被委托对象,没有反射也没有动态代理(对比 Java 的 java.lang.reflect.Proxy 动态代理,Kotlin 的 by 是编译期生成的静态转发,更快)。

"类委托和 Java 动态代理有什么区别?"答三点:①生成时机——by 编译期生成固定代码,动态代理运行期生成;②类型支持——by 只能委托接口的抽象方法,动态代理也能代理接口但要处理 InvocationHandler 的反射调用;③性能——by 的转发是普通虚调用,动态代理每次调用要走 invoke 反射;④行为——by 不拦截 toString/equals,动态代理可以 intercept 所有方法包括 Object 的。

"by 能委托多个对象吗?"答:能,但必须是不同的接口各配一个 by,写法是 class Multi(p: P, s: S) : P by p, S by s——两个接口各转发给各自的对象,编译器为每个接口分别生成转发方法,这才是"组合多个能力"最省事的写法。真正要避开的是两种情况:①同一个接口不能委托给两个对象(编译器不知道该转发给谁,会直接报错);②两个接口有同名同签名方法时存在冲突,必须在类体里手写 override 裁决转发给谁。把多个委托对象套成多层(Logging(Timed(cache)))也行,但那是手工装饰器叠装饰器,比一行多接口委托贵得多。

"使用中遇到过什么问题?"案例一:一个缓存装饰器被两个 Repository 共享,导致其中一个的清理操作清掉了另一个的数据;定位到 by 天然共享委托对象的状态;修复为各自 new 一个。案例二:想给 Service 换一个测试用实现,却发现 by 的委托在构造期固定;修复为改成"委托给内部可切换的 var 实现"。

再补一个工程上值得讲清的一点:by 的最大价值是"在不改原类的前提下加一层行为",但 Android 上更常用的做法是组合而非委托。 原因是 Android 的生命周期管理让"状态隔离"比"少写几行样板"更重要——by 天然共享委托对象的状态,而 Android 组件(Activity、Fragment、ViewModel)各自需要独立状态。所以实践中的判据是:当被装饰对象是全局共享资源(连接池、日志、缓存实例)时用 by;当被装饰对象属于某个组件、需要状态隔离时用组合。 面试里能把这个判据说清楚,比背 by 的语法有用得多。

给正在准备面试的你

把类委托画成一张"编译前 → 编译后"的对照图:左边写一行 class Service(private val delegate: Logger) : Logger by delegate 与接口的三方法声明;中间箭头标"编译期生成转发";右边画出三个转发方法的方法体(每个都是一行 delegate.xxx(...))。图的右侧画一个红色框,写"注意:toString/equals 不被委托"。图的下方画一行三个关键词:"接口抽象方法(能委托)→ 具体类非 open 方法(不能委托)→ 接口默认实现(不转发)"。这张图把机制与三条边界一次讲清。

再补工程案例与踩坑——应用落点是清点项目里用 by 的类,确认被委托对象是否应共享(共享资源 vs 组件状态);对需要运行期切换实现的场景改用组合;给关键接口的委托加一条单测固定"默认实现 vs 委托实现"的行为差异。

复习时别孤立刷题:委托属性与 Delegates——类委托是"整个接口的委托",属性委托是"单个属性读写的委托",机制同源。

划两句重点:by 编译期生成每个抽象接口方法的转发实现,比动态代理快;只能委托接口抽象方法,不委托具体类方法与 Any 的三个方法;委托对象天然共享状态,要隔离就各自 new;需要运行期切换就改组合;by 表示固定委托,组合表示可变委托。

下一篇聊泛型型变 in 与 out:声明处型变——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:委托属性与-Delegates:属性系统的深水区

下一篇预告:泛型型变-in-与-out:声明处型变

有任何问题欢迎在评论区留言交流。

相关文章
|
18天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8618 25
|
16天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3040 14
|
16天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2110 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
16天前
|
云安全 人工智能 安全
|
11天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
11天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章