本文翻譯自 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 schedule
的 invoke
方法。又因為 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(本文中 context 和 handler 是同一個意思)。Context 其實就是一個對象,Context 的類型在 lambda 表達式定義時一同被指明。這樣的 lambda 表達式能夠訪問到 Context 中的非靜態 public
方法。
普通的 lambda 表達式是像這樣定義:() -> Unit
,但是帶有 Context X
的 lambda 表達式是這樣定義的:X.() -> Unit
。而且,普通的 lambda 表達式能夠像下面這樣調用:
val x: () -> Unit = {}
x()
而帶有 Context 的 lambda 表達式則需要傳入一個 context
:
class MyContext
val x: MyContext.() -> Unit = {}
// x() // 不會通過編譯,因為 context 沒有被定義
val c = MyContext() // 創建一個 context
c.x() // 正確
x(c) // 正確
記住,在前面章節中我們重載了 schedule
的 invoke
操作符,并使其接收一個 lambda
表達式,這使得我們可以這樣寫:
schedule { }
invoke
接收的 lambda 是一個帶 context 的 lambda,context 類型為 SchedulingContext
。而 SchedulingContext
有一個 data
方法,因此我們可以這樣寫:
schedule {
data {
// ...
}
}
你或許已經猜到了,data
也是一個接收帶 context 的 lambda 表達式的方法,只不過這是另外一個 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
為了使用方括號,我們需要實現 get
或 set
的操作符方法(帶有 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 }
}
事實上,你可以向 get
和 set
操作符方法傳任意參數,來完成許多有趣的功能。
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()
都是操作符。如果 Point
是 Pair
的別名,那么 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)
Availability
是 Matrix
的別名,因此上面的聲明等同于:
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。在「最終結果」中,context 是 SchedulingContext
和 DataContext
:
@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