Kotlin擴充套件方法進化之Context Receiver

語言: CN / TW / HK

黎趙太郎 的部落格 地址:

https://lizhaotailang.works/about/

為什麼會有 Context Receiver

我們都愛擴充套件方法。在介紹 Context Receiver 之前,我們先了解一下 Kotlin 目前的擴充套件方法實現存在的問題。舉個例子,在 Android 中,我們通常都會寫一個擴充套件方法將 int 轉換為 dp:

fun Int.dp(context: Context): Int {     // ... }

還有另外一種方式:

fun Context.dp(value: Int): Int {     // ... }

那麼,首先為什麼會存在上面兩種實現方式呢?因為目前 Kotlin 的擴充套件方法或者屬性只支援擴充套件在一個類或者介面上。其次,上面這兩種擴充套件方法,哪種更符合直覺呢?我認為兩種擴充套件方式都符合,或者說,都不符合,為什麼呢,我們可以這樣理解上面的擴充套件方法,只有在同時有 Context 和 Int 兩個參與者的情況下,dp() 這個擴充套件方法才能夠被使用。

我們假設一種實現方式:

fun (Context, Int).dp(): Int {     // ... }

目前的 Kotlin 實現是不支援這樣的定義的(實際上這也是 Kotlin 團隊最初設想的實現方式)。不過這種方式是否比上面的兩種實現更加符合直覺呢? 

再舉一個例子:

interface Entity interface Scope {     fun Entity.doAction() { // 成員擴充套件方法         // ...     } } class ScopeImpl: Scope {     init {         val entity = object : Entity { }         entity.doAction()     } } class EntityImpl: Entity {     init {         val entity = object : Entity { }         this.doAction() //  <- 錯誤         entity.doAction() // <- 錯誤     } }

上面的例子展示了成員擴充套件方法(member extension function)是如何工作的。那我們為什麼需要成員擴充套件方法呢?簡單來說就是限制擴充套件方法能夠被誰呼叫。示例中的 doAction 方法只有在 Scope 類及其子類中才能被呼叫。事實上,成員擴充套件屬性和方法可以宣告為 open 並在子類中被複寫的(回想一下擴充套件方法的實現原理)。

我們用成員擴充套件方法改造一下文章開頭的 dp 方法:

class Context {     fun Int.dp(): Int {         // ...     } }

因為 Context 類在 Android 中是在 SDK 裡定義的,我們並不能隨意修改,所以上面的實現只是一個美好願望,實際上並不能實現。

OK,到這裡,我們可以總結一下 Kotlin 的擴充套件方法和屬性目前存在的一些限制,或者說,Context Receiver 要解決什麼問題:

  1. 首先,只支援擴充套件在單一的類或者介面上,這就限制了多種抽象的組合能力,我們也不能宣告一個方法,它只有在多個條件都滿足的情況下,才能被呼叫。而這導致了實現一個功能有好幾種方式(雖然在 Kotlin 中這種現象非常常見,但這顯然和 Python 之禪中 “應該提供一種,且最好只提供一種,一目瞭然的途徑”的理念不太一致),並且這幾種實現方式都不完美,造成重複實現以及增加理解難度;

  2. 其次,成員擴充套件方法想法很好,但是現實太骨感,它的應用範圍太窄,在遇到第三方類的時候就無能為力了,這也就限制了它在大型應用中解耦合、模組化和結構化 API 的能力;

  3. 最後,成員擴充套件方法始終是一個擴充套件,它只能通過 entity.doAction() 的方式呼叫,而一些方法呼叫不應該依賴特定的例項,也不應該這樣宣告。而現在又沒有一種方式,可以在特定的上下文作用域內,直接宣告 頂級方法(top-level function),通過 doAction() 的方式呼叫。

Context Receiver

什麼是 Context Receiver

Kotlin 在 Kotlin 1.6.20 版本引入了在 Kotlin/JVM 上的 Context Receiver 的原型實現,解決了上面提到的問題。它目前仍然處於草案階段。可以在其設計草案中檢視語法和詳細介紹。

官方示例是這樣的:

interface LoggingContext {     val log: Logger // LoggingContext 作為上下文提供了一個 Logger 的引用 } context(LoggingContext) fun startBusinessOperation() {     // 你可以訪問 log 欄位,因為 LoggingContext 是一個隱式的接收者     log.info("Operation has started") } fun test(loggingContext: LoggingContext) {     with(loggingContext) {         // 在作用域內,你需要有 LoggingContext 作為隱式接收者來呼叫 startBusinessOperation()         startBusinessOperation()     } }

如何使用 Context Receiver

因為目前 Context Receiver 處於實驗階段,所以預設是沒有開啟的。要使用這項功能,我們需要在模組的 build.gradle 或者 build.gradle.kts 構建指令碼中增加額外的編譯器引數 -Xcontext-receivers :

tasks.withType<KotlinCompile>().configureEach {     kotlinOptions {         jvmTarget = JavaVersion.VERSION_1_8.toString()         freeCompilerArgs = freeCompilerArgs + listOf(             "-Xcontext-receivers"         )     } }

注意事項:

  • 在啟用 -Xcontext-receivers 的情況下,編譯器將生成不能用於生產程式碼的預釋出二進位制檔案;

  • 目前,IDE 對上下文接收器的支援並不完善。

講完注意事項,我們仿照官方示例,用 Context Receiver 實際改造一下之前的 dp 方法。

context(Context, Int) fun dp(): Int {     // ... }

小菜一碟,再看看如何呼叫:

class MainActivity : ComponentActivity() {     override fun onCreate(savedInstanceState: Bundle?) {         // scope 1         val oneDp = with(1) {             // scope 2             dp()         }     } }

為什麼要顯式的使用 with(1) 這個看起來很奇怪的寫法?回想一下 Java 中 this 關鍵字的作用。scope1 處 this 指向的是 MainActivity 的物件,是 Context 的作用域(注意不是 Android 中的 android.content.Context);scope2 處 this 指向的是 1 這個物件,是 Int 的作用域。我們也可以在 Context Receiver 中切換上下文物件。

context(Context, Int) fun dp(): Int {     println(this@Context)     println(this@Int)     // ... }

Context Receiver 與 Java 程式碼的互動

我們先回想一下 Context Receiver 之前的擴充套件方法是如何實現和 Java 互動的:

// Extensions.kt fun Context.dp(value: Int): Int {     // ... } context(Context, Int) fun dp(): Int {     // ... }

反編譯後的 Java 程式碼:

public final class ExtensionsKt {     // Context Receiver     public static final int dp(Context $this$dp, int $this$dp1) {         // ...        }     // 普通擴充套件方法     public static final int dp(@NotNull Context $this$dp, int value) {         Intrinsics.checkNotNullParameter($this$dp, "$this$dp");         // ...     } }

為什麼反編譯後的程式碼沒有太大區別?因為 Context Receiver 本質上還是擴充套件方法,只是擴充套件方法的 pro plus 版本而已。那下面的程式碼反編譯後的 Java 程式碼會有區別嗎?

interface Ext {     context(Context)     fun abc(): Int     fun Context.abc2(): Int }

Context Receiver 之“箭頭型”程式碼

context(Foo, Bar, Baz, Qux, Quux, Corge, Grault, Garply, Waldo, Fred, Plugh, Xyzzy, Thud) fun function() { } with(foo) {     with(bar) {         with(baz) {             with(qux) {                 with(quux) {                     with(corge) {                         with(grault) {                             with(garply) {                                 with(waldo) {                                     with(fred) {                                         with(plugh) {                                             with(xyzzy) {                                                 with(thud) {                                                     function()                                                 }                                             }                                         }                                     }                                 }                             }                         }                     }                 }             }         }     } }

這段程式碼有誇張的嫌疑,而且我想到了一個計算機領域的經典笑話:

據說某俄國特工九死一生偷到了NASA太空火箭發射程式原始碼的最後一頁,程式碼是:

))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))有人在講這個笑話的時候說是}}}}}}}},覺得是黑C/C++,但其實這個段子是用來調侃lisp的,不然也沒必要套在NASA頭上。不過現在NASA都用Python了(所以最後一頁會偷到大片的空格嗎……)

如果 NASA 也用 Kotlin,還趕新潮地用上了 Context Receiver ,那俄國特工偷到的就是一整頁的花括號了(如何同時黑俄國特工、NASA 和 Kotlin)。

當然,這樣的程式碼並不算“箭頭形”程式碼,只是形似,並不是神似。事實上,Kotlin 團隊是非常不支援你在 context() 中寫一長串引數的:

context(Foo, Bar, Baz, Qux, Quux, Corge, Grault, Garply, Waldo, Fred, Plugh, Xyzzy, Thud) // 壞的實踐,太多獨立的上下文 fun function() { }

他們推薦的方案是:

interface TopLevelContext: Foo, Bar, Baz, Qux, Quux, Corge, Grault, Garply, Waldo, Fred, Plugh, Xyzzy, Thud context(TopLevelContext) // 好的實踐 fun function() { }

那麼問題來了,如果我的確需要用到很多獨立上下文,比如前面提到的 dp 方法就需要 Context 和 Int,應該如何抽象這個 TopLevelContext 呢?

  1. 抽象為介面 interface TopLevelContext:Context, Int 並不能成功,Context 為 abstract class, Int 為 final class

  2. 抽象為抽象類 abstract class TopLevelContext:Context(),Int() 也不能成功,不支援同時繼承多個類,且 Int 為 final class

所以,按照 Kotlin 團隊推薦的方案並不能成功抽象出 TopLevelContext。如果要你來解決這個問題,你會怎麼做呢?一個可能的思路是 with 支援多引數:

with(foo, bar, baz) {   // ... }

結論

在嘗試過 Context Receiver 後,我相信它在不遠的將來就會穩定下來,然後在幫助工程師改善程式碼質量的同時被濫用(參考擴充套件方法的使用現狀,擴充套件方法是一個好的特性,但也請不要為了擴充套件而擴充套件,參考Bad Kotlin Extensions)。

另外,非常建議讀一下 Context Receiver 的 KEEP 文件,裡面詳細記錄了這樣一個功能是如何設計和實現的,讀完之後相信你會對 Kotlin 這門語言有更加深入的理解。

關注我獲取更多知識或者投稿