Kotlin Context Receivers 的那一兩件事情

Jast Lai
Jastzeonic
Published in
16 min readApr 29, 2022

Context Receivers 是甚麼個玩意?

前言

不知道各位有沒有遇過一種情況是這樣的,我這有兩個 interface 分別長這樣;

interface A {
val a: Int
}

interface B {
val b: Int
}

那使用上會是這樣

fun method(a: A, b: B) {
println("I want to combine this those parameter ${a.a} ${b.b}")
}

覺得這樣要把所有的 parameter 帶進來有一點麻煩,理想上是寫這樣:

fun method() {
println("I want to combine this three parameter $a $b")
}

但我要怎麼知道,a b c 三個項目是打哪來的呢?

這個時候我們可能會先想到 Extension

fun A.method() {
println("I want to combine this three parameter $a $b")
}

但是這個 Extension 只是針對 A 的,我如果要把 B 的 b 傳進來,那我還是得傳入 B。

這裡我想到一個方法:

interface A {
val a: Int
}

interface B {
val b: Int
fun A.method() {
println("I want to combine this three parameter $a $b")
}

}

在語法上的陳述是,我希望在有 B object 的情況下可以外掛 A 的 method。

只是實際寫要怎麼寫呢?

fun main() {
val a: A = AImpl()
val b: B = BImpl()
b ... // How should I write this ?
a.method()
}

Kotlin 在這裡提供一個 inline function 叫做 with

fun main() {
val a: A = AImpl()
val b: B = BImpl()
with(b) {
a.method()
}
}

這樣就可以做到在限定在某個 object 底下使用某個 Extension 。

只是我這樣寫的時候被我同事看到,他露出了困惑的表情,然後問:「你這是在寫三小?」

況且,如果我們今天面對的是這種情況:

interface A {
val a: Int
}

interface B {
val b: Int
}

interface C {
val c: Int
}

fun method(a: A, b: B, c: C) {
println("I want to combine this three parameter ${a.a} ${b.b} ${c.c}")
}

那像是這情況,就算在 interface 掛上 A 的 extension ,但你一樣也是得面對 要傳入 C instance 的問題。

什麼是 Receiver ?

若要了解 Context Receivers 為何物,勢必要先了解在 Kotlin 裡頭的 Receiver 為何物?

恩,照理說如果有寫 Kotlin ,而且還寫上一段時間,應該有很大的機率看過,或者是使用過 Receiver 了,當然我們最常見的 case ,就是在使用 Extension 或者是 lambda 了,這裡用 Extension 舉個例子:

fun main(){
"This is a String.".extensionFun()
}

private fun String.extensionFun() {
println(this)
}

Emm … 有意思的地方是,誰是 receiver ,具體又 receive 了甚麼東西呢?

這一部分其實可以在 kotlin source code 裏頭一看究竟,用我們剛才用到的 with function 來看。

我們可以看到 with 傳入的東西叫做 receiver ,也就是說如果我把上面的 Code 改成這樣:

fun main() {
with("This is a String.") {
extensionFun()
}
}

private fun String.extensionFun() {
println(this)
}

那可以看到 "This is a String" 這個 String 就是 receiver ,而他 receive 了那個 block。

換句話說,可以把上頭的 extensionFun 解釋為:要讓一個 String 作為 Receiver 去接收名為 extension 的這個 Block。

這樣的好處就是可以在寫 Code 時可以不用這麼專注在整體的結構上頭。

那麼 Context Receivers 又是啥?

知道 Receiver 是甚麼玩意之後,那 context receivers 解釋起來就不難了, receivers 是 receiver 的複數, context 我們可以解釋成是帶有某些條件的意境(在語言上就是語境,也就是上下文,context 這個在中文真的很難翻到位),也就是說 Kotlin 的 Context Receivers 就是指某個 object 或 method 帶有一個多個 Receiver 的屬性。

別廢話讓我看 Code

Context Receiver 在 Jetbrains 目前的實作上仍定調為實驗階段,所以官方目前的推薦是在可以隨便玩玩的 Project 上嘗試就好(雖然我自己在寫 Compose 的時候會有點想用他),目前也只能在 JVM 上頭被編譯,而且也不是預設開啟,所以如果說要玩,那麼就需要改一下 Gradle 的設定:

KTS:

tasks.withType<KotlinCompile>{
kotlinOptions.jvmTarget = "1.8"
kotlinOptions.freeCompilerArgs = listOf("-Xcontext-receivers")
}

Groovy:

kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs = ["-Xcontext-receivers"]
}

不設定你會看到 IDE 凶巴巴的跟你說:

編譯不會過就算惹,IDE 也根本不知道你想幹嘛就是了。

好,那麼就來舉個例子吧:

fun addMonthAndPrintFormattedDate(calendar: Calendar, sdf: SimpleDateFormat) {
calendar.add(Calendar.MONTH, 1)
println(sdf.format(calendar.time))
}

這個方法很簡單,我們給 calendar 加上一個月,然後印出來。

那實作上會是這樣:

fun main() {
val calendar = Calendar.getInstance()
val sdf = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())

addMonthAndPrintFormattedDate(calendar, sdf)
addMonthAndPrintFormattedDate(calendar, sdf)
addMonthAndPrintFormattedDate(calendar, sdf)
}

那個 sdf 每次用每次都得傳入還是有點擾人,我們在傳統的方式上可能會採用把這玩意包成一個 class 的做法,或者是用 Extension 的方式。 extension 的方法就回到這篇文章一開始的問題上了。

那寫法可以改成這樣:

fun SimpleDateFormat.addMonthAndPrintFormattedDate(calendar: Calendar) {
calendar.add(Calendar.MONTH, 1)
println(format(calendar.time))
}

最後會變成這樣:

fun main() {
val calendar = Calendar.getInstance()
val sdf = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())

with(sdf) {
addAMonthAndPrintFormattedDate(calendar)
addAMonthAndPrintFormattedDate(calendar)
addAMonthAndPrintFormattedDate(calendar)
}
}

只是有個問題是,如果仔細端看 SimpleDateFormat 的 Extension addMonthAndPrintFormattedDate 會發現,在 Calendar 上加入一個月這件事情是由 SimpleDateFormat 來做了,這樣在語境上相當的不合邏輯:「簡易日期格式工具給 Calendar 加了一個月上去並印出規格化後的日期」,日期格式工具怎麼會負責修改 Calendar 呢?

在我理解到我一個月後回來看到這段會不知道當時的我在 do 三小的時候,我默默地說了一句:「加個註解好惹」

而且每次都要傳入 calendar 也有點煩。

這也便是 Context Receivers 想要達成的目的之一:當你需要類似 SimpleDataFormat 的屬性作為 Context 的同時,省去上面攏長的變數和定義。

那其實要改也不難,就是類似這樣:

context(SimpleDateFormat)
fun addMonthAndPrintFormattedDate(calendar: Calendar) {
calendar.add(Calendar.MONTH, 1)
println(format(calendar.time))
}

那實際的 control flow 也不需要變動,按照原樣就好,或者可以改成用 run:

fun main() {
val calendar = Calendar.getInstance()
val sdf = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())

sdf.run {
addMonthAndPrintFormattedDate(calendar)
addMonthAndPrintFormattedDate(calendar)
addMonthAndPrintFormattedDate(calendar)
}
}

那更進一步,可以把 calender 也給加上去 context 上頭:

context(SimpleDateFormat, Calendar)
fun addMonthAndPrintFormattedDate() {
add(Calendar.MONTH, 1)
println(format(time))

}

control flow 則會變成這樣:

fun main() {
val calendar = Calendar.getInstance()
val sdf = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())

with(sdf) {
with(calendar) {
addMonthAndPrintFormattedDate()
addMonthAndPrintFormattedDate()
addMonthAndPrintFormattedDate()
}
}
}

當然要注意到, Kotlin 畢竟也只是一碼程式語言,他還沒本事推斷出具體要使用甚麼實體,所以使用上沒加 with 或者是 run 或者是 apply 之類定義 receiver 的 method 會辨識不出來。

是說這樣的 with 和 with 會有波動拳的情況,只是在 Jetbrains 的影片有提到,未來會有更優雅的寫法。

Multiple Receivers

那有了上面的例子,我們應該可以很清楚地回答我一開始提到的問題了,當我有 A B C 三個 interface 的時候,我有沒有比較省事的寫法呢?

interface A {
val a: Int
}

interface B {
val b: Int
}

interface C {
val c: Int
}

context(A, B, C)
fun method() {
println("I want to combine this three parameter $a $b $c")
}

實際寫起來要讓他 work 會長成這樣

interface A {
val a: Int
}

interface B {
val b: Int
}

interface C {
val c: Int
}

class AImpl : A {
override val a: Int = 0
}

class BImpl : B {
override val b: Int = 0
}

class CImpl : C {
override val c: Int = 0
}


context(A, B, C)
fun method() {
println("I want to combine this three parameter $a $b $c")
}



fun main(){
val a:A = AImpl()
val b:B = BImpl()
val c:C = CImpl()
with(a){
with(b){
with(c){
method()
}
}
}
}

yep ~就是這樣。

只是在寫的過程中,應該就會發現到這會有一點問題了,上述的情況是在參數和方法名稱都不一樣的時候,那如果說我 interface A 有個 methodA ,而我 interface B也有個 methodA 時,這該怎麼辦呢?

interface A {
val a: Int
fun method1()
}

interface B {
val b: Int
fun method1()
}
context(A, B, C)
fun method() {
println("I want to combine this three parameter $a $b $c")
method1()
}

其實情況跟在用巢狀的 run 中的 this 一樣,解決的方法也是一樣的 — 用 labeled

context(A, B, C)
fun method() {
println("I want to combine this three parameter $a $b $c")
this@A.method1()
this@B.method1()

}

只是這樣就很有趣了,當你有大量會有 overload resolution ambiguity 的 method 或 parameter 在 context receivers 中時,你會需要標註大量的 labeled 了,這樣好像沒省事到哪去。

但事實上會有這種情況,就不合乎 context receivers 的精神了, Context Receivers 的精神是,在該物件底下的 field 的都可以明確被辨別時,才會用這個方法去節省讀寫標記這些物件的力氣,如果有大量需要標記 labeled 的物件,那使用 Context Receivers 就不是那麼的合適了。

當然也有很多方法可以轉圜,只是適合與否得由撰寫者自己決定了,例如官方影片提到的包一層 interface ,或者是乾脆另外寫一個 method ,像是這樣:

context(A, B, C)
fun method() {
println("I want to combine this three parameter $a $b $c")
methodForA()
methodForB()

}

context(A)
fun methodForA(){
method1()
}
context(B)
fun methodForB(){
method1()
}

都是一個轉圜的方式,就看怎麼樣去做取捨了。

Context Receivers 和 Extension 的差別

問題是在, Context Receivers 和 Extension 實作上的差別好像就在 Receiver 的數量,當 Receiver 都只有一個的時候,引擎蓋底下的實作方法甚至相同。

名稱相同還會吃 Kotlin 拐子

但是在語境上,卻有 in 跟 on 的區別,意思是,在 Extensions 語意上,你是在這個 object 上呼叫這個 method ;在 Context Receivers 語意上,你是在有這個 object 時呼叫這個 method。

所以不會看到 Context Receivers 被這樣寫:

不只 function

如果必要的話,也是可以把 Context Receivers 放進去 class 裏頭的:

class SimpleClass{
context(A, B, C)
fun method() {
println("I want to combine this three parameter $a $b $c")
}
}

fun main() {
val a: A = AImpl()
val b: B = BImpl()
val c: C = CImpl()
val simpleClass = SimpleClass()
with(a) {
with(b) {
with(c) {
sample.method()
}
}
}
}

當然,如果必要的話,也是可以把 Context Receivers 放到 class 上頭:

context(A, B, C)
class SimpleClass {
fun method() {
println("I want to combine this three parameter $a $b $c")
}
}

fun main() {
val a: A = AImpl()
val b: B = BImpl()
val c: C = CImpl()
val simpleClass = with(a) {
with(b) {
with(c) {
SimpleClass()
}
}
}
simpleClass.method()
}

結語

總而言之,我認為 Context Receivers 主要的目標是要區別 Extension 在使用上會有「讓醬油去做飯」這種語法謬誤的情況,若是他在語境上會更接近「在有醬油的情況做飯」。

此外我認為 Context Receivers 也可以解決一些寫語法很攏的問題,就像我們寫 Android 在定義一些 View 的 attribution 時,可能會使用語法糖 apply 或者 run 之類的方法,來省去讀寫一大堆東西的功夫。

雖然這玩意目前還在實驗階段,但還挺讓人期待他未來會怎麼樣被應用的就是了。

如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我寫文章最大的動力。

參考資料

https://github.com/Kotlin/KEEP/blob/master/proposals/context-receivers.md

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.