Kotlin 的 DSL 實踐

本文翻譯自 Kotlin DSL: from Theory to Practice,并且做了精簡,只摘出了重要的部分,并且配合上自己的理解。

主要的語言工具

下面我們先列舉寫出我們自己的 DSL 所需要的 Kotlin 特性:

特性 DSL 語法 一般語法
Operators overloading collection += element collection.add(element)
Type aliases typealias Point = Pair Creating empty inheritors classes and other duct tapes
get/set methods convention map["key"] = "value" map.put("key", "value")
Destructuring declaration val (x, y) = Point(0, 0) val p = Point(0, 0); val x = p.first; val y = p.second
Lambda out of parentheses list.forEach{ ... } list.forEach({ ... })
Extension functions mylist.first(); // there isn't first() method in mylist collection Utility functions
Infix functions 1 to "one" 1.to("one")
Lambda with receiver Person().apply { name = "John" } N/A
Context control @DslMarker N/A

最終結果

schedule {
    data {
        startFrom("08:00")
        subjects("Russian",
                "Literature",
                "Algebra",
                "Geometry")
        student {
            name = "Ivanov"
            subjectIndexes(0, 2)
        }
        student {
            name = "Petrov"
            subjectIndexes(1, 3)
        }
        teacher {
           subjectIndexes(0, 1)
           availability {
             monday("08:00")
             wednesday("09:00", "16:00")
           } 
        }
        teacher {
            subjectIndexes(2, 3)
            availability {
                thursday("08:00") + sameDay("11:00") + sameDay("14:00")
            }
        }
        // data { } won't be compiled here because there is scope control with
        // @DataContextMarker
    } assertions {
        for ((day, lesson, student, teacher) in scheduledEvents) {
            val teacherSchedule: Schedule = teacher.schedule
            teacherSchedule[day, lesson] shouldNotEqual null
            teacherSchedule[day, lesson]!!.student shouldEqual student
            val studentSchedule = student.schedule
            studentSchedule[day, lesson] shouldNotEqual null
            studentSchedule[day, lesson]!!.teacher shouldEqual teacher
        }
    }
}

工具箱

我們將使用第一節表格中列出的「語言工具」來構建出上面的代碼。下面我們一個一個來說。

Lambda out of parentheses

lambda 表達式不用多說了。我們看「最終結果」中的代碼,幾乎所有使用花括號的地方都是 lambda 表達式。
我們有兩種方式來寫出 x { ... } 這樣的構造函數:

  • x 是一個 object,然后調用它的 invoke 方法(這個我們后面會討論)
  • 函數 x 接收一個 lambda
    不管是哪一種,其實我們都使用了 lambda。我們來看看第二種函數 x() 的方式。在 Kotlin 中,如果 lambda 是函數的最后一個參數,那么它可以放在括號外面。如果 lambda 還是這個函數的唯一參數,那么函數的括號也可以省略。結果就是 x({...}) -> x() {...} -> x {...}。函數 x 的聲明如下所示:
fun x(lambda: () -> Unit) { lambda() }

或者

fun x(lambda: () -> Unit) = lambda()

但是如果 x 是一個類實例,或者一個 object 呢?下面是另一種常用于 DSL 的方法:操作符重載。

Operator overloading

實際上,我們在 Kotlin 中經常使用「操作符重載」,比如在兩個集合間使用的 +
這一節,我們討論一個更加小眾的操作符 invoke。本文「最終結果」中的代碼是以 schedule {} 構造開始的。這個構造不同于我們上一小節中提到的 「Lambda out of a parentheses」,而是使用了 invoke 操作符與「Lambda out of a parentheses」。只要我們重載了 invoke 操作符,即使 schedule 是一個 object,我們依然可以寫成 schedule()(這樣 schedule 就像是一個函數了,因為 invoke 操作符就是 ())。事實上,當你調用 schedule(...) 時,編譯器會將其翻譯為 schedule.invoke()。下面我們看看是如何定義 schedule 的:

object schedule {
    operator fun invoke(init: SchedulingContext.() -> Unit) {
        SchedulingContext().init()
    }
}

所以,當我們調用 schedule 時,其實是調用的 object scheduleinvoke 方法。又因為 invoke 方法接收唯一的 lambda 參數,所以當我們寫 schedule {...} 時,其實是調用的:

schedule.invoke( { code inside lambda } )

最后,你再仔細看 invoke 方法,會發現它接收的不是一個普通的 lambda 表達式,而是一個帶接收者的 lambda(「lambda with a handler」)表達式。它的類型定義如下:

SchedulingContext.() -> Unit

注意,上面的 invoke 操作符其實就是 ()

Lambda with a handler

Kotlin 允許開發者為 lambda 表達式設置一個 context(本文中 contexthandler 是同一個意思)。Context 其實就是一個對象,Context 的類型在 lambda 表達式定義時一同被指明。這樣的 lambda 表達式能夠訪問到 Context 中的非靜態 public 方法。
普通的 lambda 表達式是像這樣定義:() -> Unit,但是帶有 Context Xlambda 表達式是這樣定義的:X.() -> Unit。而且,普通的 lambda 表達式能夠像下面這樣調用:

val x: () -> Unit = {}
x()

而帶有 Contextlambda 表達式則需要傳入一個 context

class MyContext
val x: MyContext.() -> Unit = {}
// x() // 不會通過編譯,因為 context 沒有被定義
val c = MyContext() // 創建一個 context
c.x() // 正確
x(c) // 正確

記住,在前面章節中我們重載了 scheduleinvoke 操作符,并使其接收一個 lambda 表達式,這使得我們可以這樣寫:

schedule { }

invoke 接收的 lambda 是一個帶 contextlambdacontext 類型為 SchedulingContext。而 SchedulingContext 有一個 data 方法,因此我們可以這樣寫:

schedule {
    data {
        // ...
    }
}

你或許已經猜到了,data 也是一個接收帶 contextlambda 表達式的方法,只不過這是另外一個 context。這樣我們就得到了一個嵌套的結構,而且同時有多個 context。我們把所有的語法糖都去掉之后,應該寫成下面這樣:

schedule.invoke({
    this.data({
        // ...
    })
})

我們再來看一下 invoke 操作符的實現:

operator fun invoke(init: SchedulingContext.() -> Unit) {
    SchedulingContext().init()
}

我們首先構造了 context SchedulingContext(),讓后我們在 context 上調用傳入進來的 lambda 參數名 init,這樣我們就在 context SchedulingContext() 中執行了 lambda 表達式。

get/set methods convention

在創建 DSL 時,我們可以實現一種方式,以一個或多個 key 來訪問 map

availabilityTable[DayOfWeek.MONDAY, 0] = true
println(availabilityTable[DayOfWeek.MONDAY, 0]) // output: true

為了使用方括號,我們需要實現 getset 的操作符方法(帶有 operator 的方法)。如下所示:

class Matrix(...) {
    private val content: List<MutableList<T>>
    operator fun get(i: Int, j: Int) = content[i][j]
    operator fun set(i: Int, j: Int, value: T) { content[i][j] = value }
}

事實上,你可以向 getset 操作符方法傳任意參數,來完成許多有趣的功能。

Type aliases

類型別名沒什么好說的,就是為一個類型取一個別名,使其更具表意性。比如 Pair,它雖然可以方便地接收一對兒數據,但是我們卻丟失了這對兒數據需要綁定在一起的原因信息。通過別名,我們可以在不新增類型的情況下保留描述數據的信息:

typealias Point = Pair
val point = Point(0, 0)

類型別名其實只是將類型的構造函數用別名進行調用而已,因此沒有新增類型。

Destructing declaration

「解構聲明」的意思就是能夠拆解一個對象為幾個變量。舉個我們常用的例子:

val (x, y) = Point(0, 0)

「解構聲明」的主要是通過 componentN 操作符來實現的,主要使用場景也是一次性聲明多個變量。上面的代碼實際上是像如下調用的:

val pair = Point(0, 0)
val x = pair.component1()
val y = pair.component2()

上面的 component1()component2() 都是操作符。如果 PointPair 的別名,那么 Pair 是自帶 componentN() 操作符的。如果 Point 是普通的類,我們自定義 componentN() 一樣可以實現上面的效果:

class Point(val x: Int, val y: Int) {
    operator fun component1(): Int {
        return this.x
    }

    operator fun component2(): Int {
        return this.y
    }
}

除了 Pair,還有 data class 也是自帶 componentN() 的。可以看到「最終結果」代碼中有:

for ((day, lesson, student, teacher) in scheduledEvents) { ... }

其中 scheduledEvents 就是一個 Set,通過 for 循環遍歷其中的每一個元素。而每一個元素類型都是一個 data class,因此能夠直接被「解構聲明」為 4 個屬性。

Extension functions

「擴展函數」也不必多說,我們經常使用:

fun Availability.monday(from: String, to: String? = null)

AvailabilityMatrix 的別名,因此上面的聲明等同于:

fun Matrix.monday(from: String, to: String? = null)

擴展函數不僅可以用于類,還可以用于接口:

fun Iterable.first(): T

這樣,任何一個實現了 Iterable 的集合類都擁有了 first 方法。

Infix functions

「中綴函數」主要是為了讓我們擺脫過多的代碼。「最終結果」代碼中有使用的地方:

teacherSchedule[day, lesson] shouldNotEqual null

上面代碼等同于:

teacherSchedule[day, lesson].shouldNotEqual(null)

在某些情況下,括號和點號都是多余的。這種情況下我們就可以使用「中綴函數」。上面的代碼中,teacherSchedule[day, lesson] 返回一個 schedule 元素,然后 shouldNotEqual 函數會檢查該元素是否為 null
聲明「中綴函數」,你需要:

  • 使用 infix 修飾符
  • 只有一個參數

shouldNotEqual 中綴函數實現:

infix fun <T: Any?> T.shouldNotEqual(expected: T) {
    Assert.assertThat(this, not(equalTo(expected)))
}

注意,所有的泛型默認都是 Any 的子類(非空的),這種情況下我們就不能使用 null。所以我們上面需要讓 T 顯式地繼承自 Any?

Context control

當我們嵌套了太多 context 時,在內層的 context 就變得異常復雜。

schedule { // context SchedulingContext
    data { // context DataContext + external context ShedulingContext
        data {  } // possible, as there is no context control
    }
}

Kotlin 1.1 以前有一種方法能夠避免上面的混亂情況。當我們在內層的 DataContext 中創建 data 方法時,用 @Deprecated 注解該方法,并將其設置為 ERROR 級別。

class DataContext {
    @Deprecated(level = DeprecationLevel.ERROR, message = "Incorrect context")
    fun data(init: DataContext.() -> Unit) {}
}

這種注解的方法可以消除創建錯誤 DSL 的可能性。然而,當我們的 context 有大量方法時,我們需要給每一個方法都寫上注解,這是非常難以接受的。

Kotlin 1.1 提供了一個新的控制方法 —— @DslMarker 注解。這個注解用于標注你自己的注解類,然后你自己的注解類可以用于標注 context 類。

@DslMarker
annotation class MyCustomDslMarker

現在我們需要注解 context。在「最終結果」中,contextSchedulingContextDataContext

@MyCustomDslMarker
class SchedulingContext { ... }

@MyCustomDslMarker
class DataContext { ... }

fun demo() {
    schedule { // context SchedulingContext
        data { // context DataContext + external context SchedulingContext is forbidden
            // data {} // will not compile, as context are annotated with the same DSK marker
        }
    }
}
工程實踐

上面都是翻譯自原文,所以上面「最終結果」中的代碼可以去原文的 github 工程中查看。下面看下使用上面的技術后,我們工程中是如何應用的。工程中的例子如下:我們需要維護一個集合,它是一組遙控器到一組設備的映射,它的含義是,在遙控器組中的每一臺遙控器都能控制設備組中的所有設備。

/**
 * 所有遙控器 - 支持機型 的映射
 */
val RC_GROUP_TO_DEVICE_GROUP = rcGroupToDeviceGroup {
    group {
        rcGroup(
                RemoteControllerType.RC1,
                RemoteControllerType.RC2,
                RemoteControllerType.RC3
        )
        deviceGroup(
                ProductType.P1,
                ProductType.P2
        )
    }
    group {
        rcGroup(RemoteControllerType.RC4)
        deviceGroup(ProductType.P1)
    }
}

DSL 的實現如下,文章里所說的 context,在我的工程代碼里叫做 builder,因為這個 context 的作用其實就是構建實例對象,因此也是 builder。:

/**
 * 用于遙控器組 <--> 設備組 的映射
 */
data class RcGroupToDeviceGroup(val rcToDeviceMap: ArrayMap<HashSet<RemoteControllerType>, HashSet<ProductType>>) {
    /**
     * 所有遙控器的集合
     */
    val remoteControllers = mutableSetOf<RemoteControllerType>().apply {
        rcToDeviceMap.forEach { (rcTypes, _) -> addAll(rcTypes) }
    }.toList()

    /**
     * 所有設備的集合
     */
    val productTypes = mutableSetOf<ProductType>().apply {
        rcToDeviceMap.forEach { (_, productTypes) -> addAll(productTypes) }
    }.toList()
}

@RcGroupToDeviceGroupDSLMarker
class RcGroupAndDeviceGroupBuilder {
    val rcs = mutableSetOf<RemoteControllerType>()
    val devices = mutableSetOf<ProductType>()

    fun rcGroup(vararg rcGroup: RemoteControllerType) {
        rcs.addAll(rcGroup)
    }

    fun deviceGroup(vararg deviceGroup: ProductType) {
        devices.addAll(deviceGroup)
    }
}

@RcGroupToDeviceGroupDSLMarker
class RcGroupToDeviceGroupBuilder {
    private var rcGroupToDeviceGroup = arrayMapOf<HashSet<RemoteControllerType>, HashSet<ProductType>>()

    fun group(block: RcGroupAndDeviceGroupBuilder.() -> Unit) {
        val builder = RcGroupAndDeviceGroupBuilder()
        block.invoke(builder)
        rcGroupToDeviceGroup[builder.rcs.toHashSet()] = builder.devices.toHashSet()
    }

    fun build(): RcGroupToDeviceGroup = RcGroupToDeviceGroup(rcGroupToDeviceGroup)
}

// DSL 的調用從這里開始。這里使用的是方法直接調用,也可以使用文章中的 object 重載 invoke 來實現。
fun rcGroupToDeviceGroup(block: RcGroupToDeviceGroupBuilder.() -> Unit): RcGroupToDeviceGroup = RcGroupToDeviceGroupBuilder().apply(block).build()

@DslMarker
annotation class RcGroupToDeviceGroupDSLMarker
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,412評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,514評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,373評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,975評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,743評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,199評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,262評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,414評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,951評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,780評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,527評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,218評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,649評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,889評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,673評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容