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>
    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.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>) {
    /**
     * 所有遥控器的集合
     */
    val remoteControllers = mutableSetOf().apply {
        rcToDeviceMap.forEach { (rcTypes, _) -> addAll(rcTypes) }
    }.toList()

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

@RcGroupToDeviceGroupDSLMarker
class RcGroupAndDeviceGroupBuilder {
    val rcs = mutableSetOf()
    val devices = mutableSetOf()

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

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

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

    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

你可能感兴趣的:(Kotlin 的 DSL 实践)