本文适合有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的逆天特性()