第十一章 DSL构建
这一章会讨论怎样使用领域特定语言DSL(Domain-Specific-Language)为Kotlin类设计更有表现力、更符合语言习惯的API。
Kotlin DSL依赖于众多的语言特性,本章会完整讨论两个:带接收者的lambda(第五章介绍过)和invoke约定。
11.1 从API到DSL
我们的目标是尽可能地让代码具有最佳可读性和可维护性。因此不仅要关注单独的类,更重要的是要关注交互发生的接口-也就是类的API。
一个API整洁的标准:
- 需要能够让读者清楚地知道在代码中发生了什么。这可以通过选择良好的名称和概念来做到。
- 代码需要看起来整洁,极少使用浮夸的代码且不存在多余的语法。这也是本章关注的目标。
Kotlin允许构建整洁API的功能的代表包括:
- 扩展函数
- 中缀调用
- lambda简明语法
- 运算符重载
DSL比由单独方法构造出的API更具表现力且更适宜工作。
Kotlin的DSL是完全静态类型的。
11.1.1 领域特定语言的概念
有两个相对概念:
- 通用编程语言(GPL):有一系列足够完善的能力来解决几乎所有能被计算机解决的问题。
- 领域特定语言(DSL):专注在特定任务或者领域上,并放弃与该领域无关的功能。
常见的DSL有SQL和正则表达式。
这类DSL的优点或者说特点:
- 通过减少提供的功能来有效完成目标
- 更趋向于声明式。
有一个缺点:很难与使用通用编程语言的宿主应用程序结合起来使用。
要解决上述DSL的缺点,并且保留优点,目前有个概念是内部DSL。
11.1.2 内部DSL
内部DSL是用通用编程语言编写的程序的一部分,使用语言的特定方式,保留具有独立语法的DSL的主要优点。
示例为Exposed框架,Kotlin编写的用来访问数据库的框架。请自行搜索查看demo。
11.1.3 DSL的结构
DSL与普通的API之间没有明确定义的边界,但DSL中经常会出现一个通常在其他API中不存在的特征:结构或者说是文法。
在Kotlin DSL中,结构通常是通过嵌套的lambda表达式或链式方法调用来创建。
这种文法使得能够将一个内部DSL称作一门语言。
DSL结构的一个好处是允许在多个函数调用之间重用一个上下文。
11.1.4 使用内部DSL构建HTML
示例:
fun createSimpleTable() = createHTML().
table{
tr{
td{ +"cell" }
}
}
//对应的HTML
<table>
<tr>
<td>cell</td>
</tr>
</table>
示例是kotlinx.html库里的代码,下面一节会讲解说明。
11.2 构建结构化的API:DSL中带接受者的lambda
11.2.1 带接受者的lambda和扩展函数类型
1.扩展函数类型
扩展函数类型:在声明的时候,将普通函数类型签名中的一个参数(类型)移到括号前面,并用一个.将它与其他的参数(类型)分隔开。
示例:
StringBuilder.() -> Unit
String.(int,int) -> Unit
//相当于
(StringBuilder) -> Unit
(String,int,int) -> Unit
其中,(StringBuilder)
叫做接受者类型,传递给lambda的这个类型值就叫做接受者对象。
之所以使用扩展函数类型是因为同之前章节介绍的扩展函数一样,最明显的是调用方式发生了变化,由普通的builderAction(sb)变为sb.builderAction()。
此外就是在函数块内不需要显式的修饰符就可以访问一个外部类型的成员。
示例:
//以普通lambda为参数
fun buildString(builderAction:(StringBuilder) -> Unit):String{
val sb = StringBuilder()
builderAction(sb)
return sb.toString()
}
//调用
val s = buildString{
//it指向StirngBuilder实例
it.append("Hello,")
it.append("world")
}
//以扩展函数类型lambda为参数
fun buildString(builderAction:StringBuilder.() -> Unit):String{
val sb = StringBuilder()
sb.builderAction()
return sb.toString()
}
//调用
val s = buildString{
//this指向StirngBuilder实例
this.append("Hello,")
append("world")//可以省略this
}
2.扩展函数变量
可以声明一个扩展函数类型变量,然后可以像扩展函数一样调用它,也可以将它作为参数传递给一个期望带接受者的lambda为参数的函数。
示例:
//声明一个变量
val appendExcl:StringBuilder.() -> Unit = { this.append("!") }
val stringBuilder = StringBuilder("Hi")
//当扩展函数样调用
stringBuilder.appendExcel()
println(stringBuilder)
//当lambda类型参数调用
println(buildString(appendExcl))
3.apply和with的实现
标准库中的apply和with函数都是对提供给它的接收者调用扩展函数类型的参数。
不同的是apply被声明为这个接受者的扩展函数,而with是把接受者作为第一个参数。
还有就是apply返回的是接受者本身,with返回的是调用lambda后的结果
标准库中的实现代码:
inline fun <T> T.apply(block:T.() -> Unit):T{
block()//可以省略this
return this
}
inline fun <T,R> with(receiver:T,block:T.()->R):R =
receiver.block()
上面的buildString可以用apply进一步被改写为:
fun buildString(builderAction:StringBuilder.()->Unit):String =
StringBuilder().apply(builderAction).toString
11.2.2 在HTML构建器中使用带接受者的lambda
用于HTML的Kotlin构建器通常被叫做HTML构建器,它代表了一个更普遍的概念叫做类型安全的构建器。
直接看示例及注释解析:
//一个标签类
open class Tag(val name:String){
//嵌套标签列表
private val children = mutableListOf<Tag>()
//每一个子标签都要做的事,初始化,添加进子标签列表
protected fun <T:Tag> doInit(child:T,init:T.() -> Unit){
child.init()
children.add(child)
}
override fun toString() = "<$name>${children.joinToString("")</$name>"
}
//继承标签类,同时拥有一个可以创建子标签的函数
class TABLE:Tag("table"){
//这个子标签的函数特点是以子标签的扩展函数为参数类型,这样就限制了子标签的创建位置
fun tr(init:TR.() -> Unit) = doInit(TR(),init)
}
class TR:Tag("tr"){
fun td(init:TD.() -> Unit) = doInit(TD(),init)
}
class TD:Tag("td")
fun table(init:TABLE.() -> Unit) = TABLE().apply(init)
fun createTable() =
table{
tr{//本质是TABLE().tr,只是接受者被省略了
td{}
}
tr{}
}
//调用
println(createTable())
>>>>>>
<table><tr><td></td></tr><tr></tr></table>
//另一个使用方式
fun createAnotherTable() = table{
for(i in 1..2){
tr{
td{}
}
}
}
如示例所见带接收者的lambda是构建DSL的好工具,可以在代码块中改变命名解析上下文,也就是可以在API中创建结构。这是区分平铺的方法调用序列与DSL的关键特征之一。
上面示例里createAnotherTable函数展示了这种内部DSL的极大的拓展性和自由性,像不像angularJS或者react native中的那种逻辑数据和页面共生的写法。Android中马上要推出正式版本的compose也是此种写法,底层原理应与此类似。
11.2.3 Kotlin构建器:促成抽象和重用
本小节以kotlinx.html库为示例演示了如何重构改进代码,没有新知识点,在此不赘述。
11.3 使用"invoke"约定构建更灵活的代码块嵌套
invoke约定允许把自定义类型的对象当做函数一样调用。
11.3.1 "invoke"约定:像函数一样可以调用的对象
先看示例:
//声明
class Greeter(val greeting:String){
operator fun invoke(name:String){
println("$greeting,$name")
}
}
//调用
val newGreeter = Greeter("Haha")
newGreeter("Hehe")
>>>>>
Haha Hehe
上面的表达式newGreeter("Hehe")会被编译成newGreeter.invoke("Hehe")。
就如同一个普通约定一样:提供了一种方法,用更简洁清晰的表达式替换了冗长的表达式。
invoke方法没有限制任何特殊签名,可以给它定义任意数量的参数和任意的返回类型。
11.3.2 "invoke"约定和函数式类型
本书前面部分说过invoke,像lambda?.invoke()调用一个可空函数类型的变量。
这就是invoke约定的应用。
当将lambda作为函数调用时,这种操作被转换成一次invoke的调用。
知道了这个,就可以明白这样一种优化代码的方式:将复杂lambda代码拆分成多个方法,同时仍然允许将它与接收函数类型参数的函数一起使用。
要做到这一点,可以定义一个实现了函数类型接口的类。
示例:
data class Person(val name:String,val age:Int)
class PersonPredicate(val name:String):(Person)->Boolean{
override fun invoke(person:Person):Boolean{
return person.name == name && person.isAlive()
}
//将更多的判断逻辑抽取出来成为单独方法
private fun Person.isAlive():Boolean{
return age < 100
}
}
//调用
val list = listOf(Person("aa",10),Person("aa",120),Person("bb",10))
val predicate = PersonPredicate("aa")
println(list.filter(predicate))
>>>>>
[Person(name=aa, age=10)]
11.3.3 DSL中的"invoke"约定:在Gradle中声明依赖
如果想同时支持嵌套代码块结构和扁平调用结构,如下所示:
dependencies.compile("junit:junit:4.11")
dependencies{
compile("junit:junit:4.11")
}
这需要把invoke约定和普通函数以及带接受者的扩展函数类型结合起来,如下所示:
class DependencyHandler{
//普通函数
fun compile(coordinate){ ... }
//invoke函数,这里注意body()其实是this.body()
operator fun invoke(body:DependencyHandler.() -> Unit){ body() }
}
这样一段短小的代码片段,仅仅重定义了一个invoke方法,就显著地增加了DSL API的灵活性。这个模式很通用,稍加修改就可以在自己的代码里应用。
11.4 实践中的Kotlin DSL
本节就是展示一些实际的DSL构建案例,没有新知识点。
11.4.1 把中缀调用链接起来:测试框架中的"should"
kotlintext框架里有如下写法:
s should startWith("kot")
s should start with "kot"
上面第一种使用了中缀调用,以及一些特别的命名:
//中缀调用
infix fun <T> T.should(matcher:Matcher<T>) = matcher.test(this)
interface Matcher<T>{
fun test(value:T)
}
//这里将类名用小写字母开头是为了调用时更加像日常用语习惯
class startWith(val prefix:String):Matcher<String>{
override fun test(value:String){
if(!value.startsWith(prefix)){
throw AssertionError("wrong ...")
}
}
}
上面示例中的第二种调用稍微复杂一点,用了两个中缀调用及object类型:
//等同的写法是
s.should(start).with("kot")
//声明
//object不是用来给函数传递任何数据的,而是DSL文法的一部分
object start
//should在这里是一个特殊的重载,使用了start对象作为参数类型并返回一个中间包装器
infix fun String.should(x:start):StartWrapper = StartWrapper(this)
//包装器类型里又声明了一个中缀调用函数
class StartWrapper(val value:String){
infix fun with(prefix:String) =
if(!value.startsWith(prefix)) throw ssertionError("wrong ...")
}
11.4.2 在基本数据类型上定义扩展:处理日期
先看下面代码思考下如何做到:
val yesterday = 1.days.ago
val tomorrow = 1.days.fromNow
想到了吗,这里是对Int类型增加了扩展属性,然后又增加了一次扩展:
//Period是JDK8 中用来表示两个日期之间间隔的类型
val Int.days:Period
get() = Period.ofDays(this)
val Period.ago:LocalDate
get() = LocalDate.now() - this //使用运算符语法调用LocalDate.minus
val Period.fromNow:LocalDate
get() = LocalDate.now() + this //使用运算符语法调用LocalDate.plus
//调用
println(1.days.ago)
println(1.days.fromNow)
>>>>>
2016-08-16
2016-08-18
11.4.3 成员扩展函数:为SQL设计的内部DSL
成员扩展:在类中声明扩展函数和扩展属性。这样的属性或函数既是它容器类的成员,也是某些其他类型的扩展。
本小节以Exposed框架为例,展示成员扩展函数的应用。
先看一个使用代码:
//语义很明确,创建一个简单的带两列的Country表
object Country:Table(){
val id = integer("id").autoIncrement().primaryKey()
val name = varchar("name",50)
}
下面来看Table的声明:
class Table{
fun integer(name:String):Column<Int>
fun varchar(name:String,length:Int):Column<String>
//扩展函数,将该列设成表的主键,省略了实现
fun <T> Column<T>.primaryKey():Column<T>
//扩展函数,只有整型值能够自增,省略了实现
fun Column<Int>.autoIncrement():Column<Int>
}
如上所示,这些扩展函数式Table类的成员,也就意味着不能在这个类作用域之外去使用他们。
同时在自增函数里有一点做的很好的是限制了接受者类型,只有数字列才可以自增。
成员扩展的缺点在于其扩展性的缺失:它们是属于类的,不能另外定义新的成员扩展。
例如想要增加新的列属性的时候,必须修改Table类的定义来为新属性增加成员扩展函数,而不能像普通扩展那样,不改动原始类添加必要的声明。
11.4.4 Anko:动态创建Android UI
本小节的示例其实和前面提到的创建HTML的代码大同小异,都是利用了带接收者的lambda这一种来简化代码。在此不赘述。
11.5 小结
略。