本文适合有Java基础并且想了解下Kotlin的人阅读,没有Java或别的程序语言基础的不建议继续阅读(
前言
最近在开发一个Minecraft Bukkit服务端插件,需要写一个玩家命令处理。由于Bukkit原版的命令处理非常答辩,所以基本上插件都是自己实现了一套命令处理系统。
先来看看Bukkit给JavaPlugin插件提供的onCommand事件的签名:
1 | boolean onCommand(CommandSender sender, Command command, String label, String[] args) |
可以看到该事件仅仅是传递了命令发送者sender、命令本体command、标签label和后续的参数args,对于命令的处理则是完全没有提供对应的方法。因此,你不得不自己研究一套命令处理机制。
比如CoreProtect就是嗯写了几十个if ... else ...来处理的(
正好我前段时间在写的插件InteractionSupervisor是用Kotlin进行开发的,而Kotlin正好适合用于写DSL(比如gradle就可以使用build.gradle.kts进行配置),因此我就用Kotlin写了个迫真命令处理DSL
成品展示
先看看最终效果(插件里定义的完整命令见此)
1 | val commands = buildCommand { |
以上代码定义了以下内容:
test命令,执行后输出该命令附带的参数(如test a b c会向命令发送者发送args=a,b,c),同时在输入该命令时提供补全内容1、2、和3- 命令组
group:- 该命令组会要求所有子命令都以此作为开头,如下文定义的命令
a和b需要以group a和group b的方式调用 - 同时命令组也会自动提供对其子命令的补全,玩家输入
group时,会自动提供补全内容a和b - 玩家输入错误内容时会向上查找由
usage定义的第一条有效的帮助内容并返回给玩家
- 该命令组会要求所有子命令都以此作为开头,如下文定义的命令
在onCommand和onTabComplete事件处调用commands变量内定义的对应方法即可实现以上功能。
使用buildCommand即可开始构建命令处理模块,在模块中:
- 使用
"命令(组)名称" { ... }即可定义一个命令 - 在命令组中继续使用
"命令名称" { ... }即可定义子命令,同时命令组也会提供对该组命令所有子命令的补全 - 使用
execute { ... }即可定义命令执行代码,其中command、sender、args等命令参数会被一同提供至上下文中,可以直接使用 - 使用
tabComplete { ... }可以定义命令的Tab补全内容 - 使用
usage("...")可以定义命令组的帮助信息
相比于传统的方法,使用DSL写出来的命令处理可读性更强,代码也更清晰。
能实现以上功能都得归功于Kotlin的一大堆逆天语法和特性。
前置知识介绍
以上DSL的核心代码基本就这些:
1 | class Command(val name: String) { |
完整的定义可在这里查看。
该DSL涉及到大量的Kotlin特性,我们一个个来讲。
扩展函数
Kotlin可以给任意类型添加扩展函数。扩展函数我认为可以理解成一种特殊的普通函数的简写,如以下函数:
1 | fun String.aaa() { |
在定义后即可在任意的String变量上调用,如"test".aaa()。
运算符重载
Kotlin支持运算符重载,可以重新定义各个类型的变量对于不同的运算符的操作,使用operator fun 运算符类型()即可为当前类定义运算符的重载。
例如以下定义:
1 | class A { |
重载了类型A的plus(+)运算符,当你将A类型的变量与任意别的变量相加(+)时,将会在控制台输出A 与 另一个变量 相加了!
1 | A() + 123 |
运算符重载同样可以配合上述的扩展函数使用,如以下定义:
1 | operator fun String.times(count: Int) = this.repeat(count) |
重载了字符串的times(*)运算符和Int的操作,将允许你在任意的字符串上使用*n将该字符串的内容重复n遍,如"a" * 5将得到"aaaaa"。
在扩展函数里可以直接以this来访问该变量本身,上文就是在用this表示字符串的值。
在Kotlin中还有个神秘的运算符叫invoke,实现这个运算符即可将任何对象都当作一个迫真函数来使用,非常离谱。如以下定义:
1 | operator fun String.invoke() = println(this) |
将允许你在任意字符串上以"xxx"()(或"xxx".invoke())的形式直接当成一个迫真函数来进行使用,效果是在控制台输出该字符串本身。
如果你在一个类/接口里面定义一个其他类型的运算符重载,那么这个重载就只在这个类/接口及其子类里面可用,避免外部环境受到污染。
例如我这里在Command和CommandBuilder里重载了String的invoke,使得你可以直接在一个字符串上执行代码,这样就实现了写个字符串就能定义一个命令。并且由于该重载并没有定义在全局上,这种写法只在这个DSL里面可用,不会影响到项目的其他部分。
其实Kotlin的官方文档里也存在类似的写法,在他们的HTML示例里也是通过重载字符串的unaryPlus(+)运算符,使得你可以直接使用+"xxx"的方式直接往HTML元素里添加正文。并且同样由于该重载扩展函数只存在于这个DSL的定义中,这种重载也不会污染到外部环境,可以放心使用。
至于为什么最终成品的写法能简化成"xxx" { ... }而不是"xxx"( ... ),这就要看下文的lambda表达式简写了
高阶函数
和Java不同的是,Kotlin提供了强大的函数式编程的支持(Java里的lambda最多算是个SAM(单抽象方法)的语法糖)。在Kotlin里,函数是一等公民。函数不仅可以正常定义和调用,也可以像普通变量一样被存储和传递。如果一个函数的参数里有其他函数,或者说这个函数的返回值也是一个函数,那这个函数就是一个高阶函数。
在Kotlin里,你可以直接声明一个函数类型的变量,写法:
1 | val f : (Int, Int) -> Int = { a, b -> a + b } |
以上代码定义了变量f,该变量是一个函数,可以用f(111, 222)的方式直接调用,返回的值是333。
我们可以看到这个函数变量由以下几个部分组成:
- 类型声明
(Int, Int) -> Int
前面的(Int, Int)表示的是这个函数所接收的参数,这里是接收两个Int。
后面的-> Int表示函数的返回值类型,这里就是返回一个Int。 - 代码块
{ a, b -> a + b }
这里的a, b就是上面说的函数所接收的参数,后面的a + b就是这个函数具体执行的内容了。
类似{ a, b -> a + b }的这种定义函数的方法有很多名称,例如匿名函数、lambda表达式、箭头函数等。
匿名函数:因为用这种方式定义函数的时候,你没有说明函数的名称(正常情况下定义函数都是需要声明函数名称的),所以用这种写法就是定义了一个没有名字的匿名函数。
lambda表达式:这个名字来源于数学中的λ演算,具体的我也不懂,反正在编程里lambda表达式指的就是用这种方式定义的匿名函数。
箭头函数:顾名思义,在各个编程语言里,定义这种函数往往都需要使用->或=>。
匿名函数的简写
只是使用以上方法还写不出这个DSL,因为按上述定义:
1 | operator fun String.invoke(init: Command.() -> Unit) = Command(this).apply(init).also(commands::add) |
,那要调用这个扩展函数应该是这么写的:
1 | "xxx"({ |
那为什么能写成现在这样呢?这就要提到kotlin对于lambda表达式的专用简写了。
匿名函数作为函数的最后一个参数
kotlin规定,当一个匿名函数是函数的最后一个参数时,可以把它挪到小括号外面,所以上文我们可以改成:
1 | "xxx"() { |
函数参数仅有单个匿名函数时的小括号省略
kotlin还规定了,当一个函数的参数仅有一个匿名函数时,小括号也可以省略,所以我们还可以改成:
1 | "xxx" { |
这就已经是成品的样子了。
对于单个参数的匿名函数的变量声明省略
虽然这里没有用到这个特性,但也单独讲一下,当lambda表达式的参数只有一个的时候,这个参数可以省略不写,kotlin会直接将it作为它的名称:
1 | val f : (Int) -> Int = { it * 2 } |
这里的it就是那个参数的名字,可以直接在lambda表达式里面使用。
最终原理讲解
讲完上述这些就可以开始讲解这个DSL究竟是如何做到的了。
我们先来逐个看上文的定义:
1 | buildCommand { |
有上文基础就能看懂这段在干什么了。很明显,buildCommand是一个函数,接收一个匿名函数作为参数。因此,小括号可以省略,并且能把大括号写到外面,大括号里面的内容就是这个匿名函数的代码了。
回头看一眼buildCommand的定义:
1 | fun buildCommand(init: CommandBuilder.() -> Unit) = CommandBuilder().apply(init).build() |
可以看到这个函数接收的参数init是一个针对CommandBuilder的扩展函数,返回类型为Unit,即空。
这个函数的作用就是创建一个CommandBuilder的实例,并调用init初始化它的内容,最后build()得到我们需要的CommandExecutor实例。
这里的apply表示将指定的函数作用于该对象上,这里的效果基本等同于CommandBuilder().init()。
既然init是CommandBuilder的扩展函数,那这个函数的this也就会被赋值为上文的CommandBuilder实例,因此我们可以直接在buildCommand { ... }里使用CommandBuilder的成员实例String.invoke(即上文使用的"xxx" { ... }语法,看到这里就能明白这其实是在调用字符串的invoke运算符操作)了。
接下来我们看看核心部分,String.invoke到底定义了什么:
1 | operator fun String.invoke(init: Command.() -> Unit) = Command(this).apply(init).also(commands::add) |
创建一个命令(组),可以看到传递的参数还是一个init,所以依然可以使用kotlin特有的匿名函数简写。后面的代码大同小异,同样是apply(init),然后附加一个also将定义的命令加到命令列表里。
同样的,由于init是Command的扩展函数,因此也可以直接在里面使用Command类的tabComplete、execute和usage方法,以及定义子命令的同样的String.invoke扩展函数。
最后这几个方法就很简单了,比如execute的函数签名长这样:
1 | fun execute(init: CommandExecuteParams.() -> Unit) { |
仅仅是将Command内部的execute变量赋值为init。
同样的,由于init是CommandExecuteParams的扩展函数,函数内的this直接指向CommandExecuteParams实例,因此可以直接在里面使用CommandExecuteParams内定义的命令参数而无需单独传递。
总结
以上就是这个迫真命令DSL的实现原理,希望本文能多少让你了解点Kotlin的逆天特性()