聊聊 Kotlin 代理的“缺陷”與應對

語言: CN / TW / HK

theme: simplicity-green highlight: androidstudio


Kotlin 代理是面試中經常被問到的問題,比如介紹一下代理的實現原理以及在使用中的一些注意事項等,本文將帶你梳理這些問題,讓你從更高維度上認識“代理”

Kotlin 有很多讓人津津樂道的語法,“代理”就是經常被提及的一個。Kotlin 在語言級別通過 by 關鍵字支援了代理模式的實現。代理模式是最常用的設計模式之一,它是使用“組合”替代“繼承”的最佳實踐。下面取自 Wiki 中關於代理模式的例子:

```kotlin 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` 關鍵字實現同樣邏輯,程式碼變成下面這樣:kotlin 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 關鍵字只能基於介面進行代理,所以我們需要抽象出 WindowRectangle 的共同介面 ClosedShape,通過 by 關鍵字, Windowarea() 委託給 bounds 來實現, Window 內部中省掉了直接呼叫 bounds 的程式碼。這個例子比較簡單,優勢體現的不明顯,試想隨著介面方法的增多,by 可以幫我們減少大量的模板程式碼。

雖然 by 關鍵字為我們帶來了方便,但是它的一些機制也受到不少開發者詬病,甚至連 Kotlin 首席設計師 Andrey Breslav 都曾公開表示不喜歡這個功能。Kotlin 介面代理被詬病的問題主要有兩個:

  • 代理中無法訪問 this
  • 代理無法執行時替換

缺陷1:代理中無法訪問 "this"

代理與繼承的一個重要區別在於,繼承關係中父類可以通過 this 訪問執行時的真正例項;而代理關係中代理無法通過 this 直接訪問接收方物件(例子中的 Window),但有時我們確實需要獲取接收方的狀態參與計算,在 Java 中的常見做法是接收方在建立代理時注入自身例項。而 Kotlin 的 by 關鍵字需要在接收方例項化之前建立好代理,因此無法為代理注入 this 物件。

上面的例子中,假設 widthheightWindow 維護的狀態而非 Rectangle,我們在 Rectanglearea() 中依賴它們來進行計算,此時該如何解決呢?一個可行的做法是在 Windowinit 中注入向 Rectangle 注入所需的狀態。這裡需要注意兩點,

  • 第一,直接注入 width 和 height 是不行的,假設 Window 的 size 會變化,所以 Rectangle 需要在計算 area 時始終獲取最新的數值,
  • 第二,注入 Window 例項作為 “this”,通過 this 獲取最新的 widht 和 height?這也是不妥的,Rectangle 依賴 Window 型別,會降低 Rectangle 的可複用性。

兼顧上述兩點後,更合理的做法是為 Rectangle 定義一個可以獲取 width/height 的函式型別,然後由 Wiindow 注入這個回撥,程式碼如下:

```kotlin interface ClosedShape { fun area(): Int }

class Rectangle : ClosedShape { lateinit var size: () -> Pair 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()增加引數,動態傳入widthheight,但是這增加了Window` 的呼叫方的負擔,違背面向物件中封裝性的設計原則。

缺陷2:無法執行時替換代理

不少人希望代理模式中的代理能夠根據需要動態替換,實現類似策略模式的效果。但這在目前 Kotlin 代理中是無法實現的。不少 Kotlin 的初學者曾經誤認為通過 var 替換代理例項,比如下面程式碼中,我們將 Window 的引數 bounds 的宣告從 val 改為 var kotlin class Window(private var bounds: ClosedShape) : ClosedShape by bounds 但是經編譯後的程式碼實際是下面這樣,代理儲存在 bounds 之外的另一個 final 成員 `$$delegate_0 中。

```java 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,此時該如何解決呢? 一個不難想到的思路是:增加代理的“代理”,實現代理例項的可替換

```kotlin class Proxy(var target: ClosedShape) : ClosedShape { override fun area() = target.area() }

class Rectangle : ClosedShape { lateinit var size: () -> Pair override fun area() = size().let { it.first * it.second } }

class Oval : ClosedShape { lateinit var size: () -> Pair 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 的代理,而真正被呼叫到的物件是 Proxytarget,它可以在執行時根據需要做出變化。

但這也帶來一個問題,如果介面中的方法很多,Proxy 中會出現大量的 target 的轉發程式碼,增加我們的工作量。此時我們可以使用動態代理對其優化: ```kotlin 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?) = method.invoke(target, args) } ) as ClosedShape } }

class Window(private val bounds: Proxy) : ClosedShape by bounds.create() {

//...省略

} `` 上面程式碼中,Proxycreate()` 返回一個動態代理物件,幫節省了原本需要手動實現的轉發程式碼。

對比其他解決方案

通過上面分析我們知道,使用 by 關鍵字建立的代理需要在接收方(例子中的 Window)例項化之前確定,並且在編譯後儲存在一個不可見的 final 成員上,這使得接收方缺少對代理的直接控制的能力,比如無法在 Window 內建立代理,也無法在執行時替換代理。而對比 Kotlin 之外的其他同類解決方案中,你會發現接收方的控制力明顯要強得多:

  • Lombook (Kotlin 出現前常用的語法糖工具)提供了 @Delegate 註解,它可以幫助我們將接收方的成員宣告為代理,無需再通過建構函式傳入,接收方可以在自行建立代理的同時方便地做一些注入工作;
  • Guava(Google 提供的 JDK 增強庫)也提供了實現代理模式的 ForwardingObject,它允許我們在接收方內部通過重寫 protected abstract Object delegate(); 返回最新的代理物件,實現代理的可替換。

因此,我們可以簡單下一個結論:Kotlin 代理之所以被人詬病,其根本原因在於相對於其他同類方案,接收方缺少對代理的直接控制的能力。目前有不少開發者提了相關 Issue,也許可以期待 Kotlin 在未來的版本中出現更合理的解決方案。在此之前,我們只能通過本文介紹一些 Workaround 進行應對。需要注意本文講的代理僅僅指介面代理,相比之下,屬性代理的設計合理得多,不存在上述這些問題。