论 Kotlin 代理的“缺陷”与应对
Kotlin 代理是面试中经常出现的问题,面试官会让你介绍一下实现原理及注意事项,本文将从另一个角度聊一聊 Kotlin 代理的缺陷及应对办法
Kotlin 有很多让人津津乐道的语法,“代理”就是经常被提及的一个。Kotlin 在语言级别通过 by 关键字支持了代理模式的实现。代理模式是最常用的设计模式之一,它是使用“组合”替代“继承”的最佳实践。下面取自 Wiki 中关于代理模式的例子:
class Rectangle(val width: Int, val height: Int) {
fun area() = width * height
}
class Window(val bounds: Rectangle) {
// Delegation
fun area() = bounds.area()
}
这是一个代理模式的典型场景:Window
将 area()
的具体实现委托给了 Retangle
类型对象 bounds
,Rectangle
与 Window
是代理与接收的关系。如果我们使用 Kotlin 的 by
关键字实现同样逻辑,代码变成下面这样:
interface ClosedShape {
fun area(): Int
}
class Rectangle(val width: Int, val height: Int) : ClosedShape {
override fun area() = width * height
}
class Window(private val bounds: ClosedShape) : ClosedShape by bounds
Kotlin 的 by
关键字只能基于接口进行代理,所以我们需要抽象出 Window
和 Rectangle
的共同接口 ClosedShape
,通过 by
关键字, Window
将 area()
委托给 bounds
来实现, Window
内部中省掉了直接调用 bounds
的代码。这个例子比较简单,优势体现的不明显,试想随着接口方法的增多,by 可以帮我们减少大量的模板代码。
虽然 by
关键字为我们带来了方便,但是它的一些机制也受到不少开发者诟病,甚至连 Kotlin 首席设计师 Andrey Breslav 都曾公开表示不喜欢这个功能:
Kotlin 接口代理被诟病的问题主要有两个:
代理中无法访问 this
代理无法运行时替换
缺陷1:代理中无法访问 "this"
代理与继承的一个重要区别在于,继承关系中父类可以通过 this
访问运行时的真正实例;而代理关系中代理无法通过 this
直接访问接收方对象(例子中的 Window
),但有时我们确实需要获取接收方的状态参与计算,在 Java 中的常见做法是接收方在创建代理时注入自身实例。而 Kotlin 的 by
关键字需要在接收方实例化之前创建好代理,因此无法为代理注入 this
对象。
上面的例子中,假设 width
和 height
是 Window
维护的状态而非 Rectangle
,我们在 Rectangle
的 area()
中依赖它们来进行计算,此时该如何解决呢?一个可行的做法是在 Window
的 init
中注入向 Rectangle
注入所需的状态。这里需要注意两点,
第一,直接注入 width 和 height 是不行的,假设 Window 的 size 会变化,所以 Rectangle 需要在计算 area 时始终获取最新的数值,
第二,注入 Window 实例作为 “this”,通过 this 获取最新的 widht 和 height?这也是不妥的,Rectangle 依赖 Window 类型,会降低 Rectangle 的可复用性。
兼顾上述两点后,更合理的做法是为 Rectangle
定义一个可以获取 width/height
的函数类型,然后由 Wiindow
注入这个回调,代码如下:
interface ClosedShape {
fun area(): Int
}
class Rectangle : ClosedShape {
lateinit var size: () -> Pair<Int, Int>
override fun area() = size().let { it.first * it.second }
}
class Window(private val bounds: Rectangle) : ClosedShape by bounds {
private var width: Int = TODO()
private var height: Int = TODO()
init {
bounds.size = { width to height }
}
}
也许有人会提议为 area()
增加参数,动态传入 width
和 height
,但是这增加了 Window
的调用方的负担,违背面向对象中封装性的设计原则。
缺陷2:无法运行时替换代理
不少人希望代理模式中的代理能够根据需要动态替换,实现类似策略模式的效果。但这在目前 Kotlin 代理中是无法实现的。不少 Kotlin 的初学者曾经误认为通过 var
替换代理实例,比如下面代码中,我们将 Window
的参数 bounds
的声明从 val
改为 var
class Window(private var bounds: ClosedShape) : ClosedShape by bounds
但是经编译后的代码实际是下面这样,代理存储在 bounds
之外的另一个 final
成员 `$$delegate_0
中。
public final class Window implements ClosedShape {
private ClosedShape bounds;
// $FF: synthetic field
private final ClosedShape $$delegate_0;
public Window(@NotNull ClosedShape bounds) {
Intrinsics.checkNotNullParameter(bounds, "bounds");
super();
this.$$delegate_0 = bounds;
this.bounds = bounds;
}
public int area() {
return this.$$delegate_0.area();
}
}
即使我们在运行时为 bounds
赋值新的对象,代理中的实例也不会发生变化。
假设有这样的场景, Window
的形状在运行时会发生变化,相应地我们需要计算 area
的代理由 Rectangle
变为 Oval
,此时该如何解决呢? 一个不难想到的思路是:增加代理的“代理”,实现代理实例的可替换:
class Proxy(var target: ClosedShape) : ClosedShape {
override fun area() = target.area()
}
class Rectangle : ClosedShape {
lateinit var size: () -> Pair<Int, Int>
override fun area() = size().let { it.first * it.second }
}
class Oval : ClosedShape {
lateinit var size: () -> Pair<Int, Int>
override fun area() = size().let { Pi * it.first / 2 * it.second / 2 }
}
class Window(private val bounds: Proxy) : ClosedShape by bounds {
private var width: Int = TODO()
private var height: Int = TODO()
private val rectangle by lazy {
Rectangle().apply { size = { width to height } }
}
private val oval by lazy {
Oval().apply { size = { width to height } }
}
fun changeShape(mode: Shape) {
when (mode) {
Rectangle -> bounds.target = rectangle
Oval -> bounds.target = oval
}
}
}
上面代码中,我们定义了一个 Proxy
作为 Window
的代理,而真正被调用到的对象是 Proxy
的 target
,它可以在运行时根据需要做出变化。
但这也带来一个问题,如果接口中的方法很多,Proxy
中会出现大量的 target 的转发代码,增加我们的工作量。此时我们可以使用动态代理对其优化:
class Proxy(var target: ClosedShape?) {
fun create() : ClosedShape {
return newProxyInstance(
ClosedShape::class.java.getClassLoader(), arrayOf<Class<*>>(ClosedShape::class.java), object : InvocationHandler {
override fun invoke(proxy: Any?, method: Method, args: Array<out Any>?) = method.invoke(target, args)
}
) as ClosedShape
}
}
class Window(private val bounds: Proxy) : ClosedShape by bounds.create() {
//...省略
}
上面代码中,Proxy
的 create()
返回一个动态代理对象,帮节省了原本需要手动实现的转发代码。
对比其他解决方案
通过上面分析我们知道,使用 by
关键字创建的代理需要在接收方(例子中的 Window
)实例化之前确定,并且在编译后存储在一个不可见的 final
成员上,这使得接收方缺少对代理的直接控制的能力,比如无法在 Window
内创建代理,也无法在运行时替换代理。而对比 Kotlin 之外的其他同类解决方案中,你会发现接收方的控制力明显要强得多:
Lombook (Kotlin 出现前常用的语法糖工具)提供了 @Delegate[1] 注解,它可以帮助我们将接收方的成员声明为代理,无需再通过构造函数传入,接收方可以在自行创建代理的同时方便地做一些注入工作;
Guava(Google 提供的 JDK 增强库)也提供了实现代理模式的 ForwardingObject[2],它允许我们在接收方内部通过重写
protected abstract Object delegate();
返回最新的代理对象,实现代理的可替换。
通过对比我们能够发现 Kotlin 代理之所以被人诟病,其根本原因在于相对于其他同类方案,接收方缺少对代理的直接控制的能力。目前也有一些开发者提了相关 Issue[3],也许可以期待在未来某个版本中出现更合理的解决方案。在此之前,我们只能通过本文介绍一些 Workaround 进行应对。需要注意本文讲的代理仅仅指接口代理,相比之下,属性代理的设计合理得多,不存在上述这些问题。
[1]https://projectlombok.org/features/Delegate.html
[2]https://guava.dev/releases/19.0/api/docs/com/google/common/collect/ForwardingObject.html
[3]https://youtrack.jetbrains.com/issue/KT-83
推荐文章