我将尝试尽可能简单地解释语言语法,但是,本文仍然吸引那些将Kotlin用作自定义DSL构建语言的开发人员。 在文章的结尾,我将提到值得考虑的Kotlin缺点。 所提供的代码段与Kotlin 1.2.0版相关,可在GitHub上找到。
什么是DSL?
所有编程语言都可以分为通用语言和领域特定语言。 通常将SQL,正则表达式,build.gradle用作DSL的示例。 这些语言的功能受到限制,但是它们能够有效解决特定问题。 它们允许编写非强制性代码(我们不应该解释如何解决问题),而是以或多或少的声明性方式(我们只是声明任务)来编写,以便基于给定的数据获得解决方案。
假设您具有标准的流程定义,可以最终对其进行更改和增强,但是通常您希望将其用于不同的数据和结果格式。 通过创建DSL,您可以创建一种灵活的工具来解决一个主题领域内的各种问题,无论您的DSL最终用户如何获得解决方案。 因此,您创建了一种API,如果掌握了该API,可以简化您的生活,并使长期保持系统更新更加容易。
本文讨论了如何在Kotlin中构建“嵌入式” DSL,并将其作为一种基于通用语言语法实现的语言。 您可以在此处了解更多信息。
实施领域
在我看来,使用和演示Kotlin DSL的最佳方法之一就是测试。
假设您来自Java世界。 声明一个广泛的数据模型的实体实例的频率是多少? 您可能曾经使用过一些构建器,或者更糟的是,使用特殊的实用程序类来填充内部的默认值。 您有多少种替代方法? 您需要多久更改一次默认值?今天需要多少努力? 如果这些问题只会激起负面情绪,那么这篇文章对您有用。
这就是我们在教育领域的项目中长期以来一直采用的方法:我们使用建筑商和实用程序类来覆盖测试,这是我们最重要的模块之一,即学校时间表安排。 现在,这种方法已被Kotlin语言和DSL所取代,后者用于描述测试方案并检查结果。 在下面,您可以看到我们如何利用Kotlin的优势,从而不再需要对调度子系统进行测试。
在本文中,我们将深入探讨构建DSL的细节,该DSL有助于测试构建教师和学生时间表的算法。
关键工具
以下是基本的语言功能,可让您使用Kotlin编写更简洁的代码并创建自己的DSL。 下表演示了值得使用的主要语法增强功能。 仔细看看。 如果您不熟悉大多数这些工具,则最好阅读整篇文章。 如果您不知道其中的一两个,请随时前进至相应的部分。 如果没有什么适合您的,请跳到本文结尾处的DSL缺陷审查。 您也可以欢迎在评论中提出更多工具。
工具 | DSL语法 | 一般语法 |
---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
找到新东西了吗? 如果是这样,那就继续吧。
我故意省略了委托属性,因为我认为它们至少对于构建DSL毫无用处。 使用上述功能,我们可以编写更干净的代码,并摆脱繁琐的“嘈杂”语法,使开发工作变得更加愉快(可能吗?)。
我喜欢我在《行动中的Kotlin》一书中遇到的比较:在自然语言中,英语是由单词构成的句子,而语法规则则定义了组合这些单词的方式。 类似地,在DSL中,一个操作可以由多个方法调用构成,并且类型检查可确保该构成有意义。 可以肯定的是,调用顺序并不总是很明显,但这完全取决于DSL设计人员。
需要强调的是,本文研究了“嵌入式DSL”,因此它基于通用语言,即Kotlin。
最终结果示例
在我们开始设计自己的领域特定语言之前,我想向您展示一个示例,说明您在阅读本文后将能够创建的内容。 整个代码可通过链接在GitHub存储库上获得。
以下基于DSL的代码旨在测试已定义学科的教师对学生的分配。 在此示例中,我们有固定的时间表,并检查是否在老师和学生的时间表中都安排了课程。
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
}
}
}
工具包
上面列出了构建DSL的所有功能。 上一节的示例中使用了它们中的每一个。 您可以在GitHub上的项目中检查如何定义此类DSL结构。
我们将在下面再次参考此示例,以演示不同工具的用法。 请记住,所描述的方法仅用于说明目的,并且可能有其他选择来实现期望的结果。 因此,让我们一一发现这些工具。 与其他语言结合使用时,某些语言功能最强大,该列表中的第一个是括号之外的lambda。
括号之外的Lambda
Lambda表达式是一个代码块,可以传递给函数,进行保存或调用。 在Kotlin中,lambda类型的定义如下:(参数类型列表)->返回的类型。 按照此规则,最原始的lambda类型是()-> Unit,其中Unit是Void的等效项,但有一个重要的例外。 在lambda的结尾,我们不必编写return ...结构。 因此,我们总是有一个返回的类型,但是在Kotlin中,这是隐式完成的。
以下是将lambda分配给变量的基本示例:
val helloPrint: (String) -> Unit = { println(it) }
通常,编译器会尝试从已知的类型推断类型。 在我们的例子中,有一个参数。 可以按以下方式调用此lambda:
helloPrint("Hello")
在上面的示例中,lambda采用一个参数。 在lambda内部,默认情况下将其称为参数,但是如果有更多参数,则必须显式指定其名称或使用下划线忽略它们。 请参阅以下这种情况:
val helloPrint: (String, Int) -> Unit = { _, _ -> println("Do nothing") }
helloPrint("Does not matter", 42) //output: Do nothing
基本工具(您可能已经从Groovy中知道了)是括号之外的lambda。 再从文章开头看这个例子:除了标准构造,几乎所有使用花括号的都是lambda。 至少有两种方法可以制作类似x {…}:的结构:
- 对象x及其一元运算符调用 (我们将在后面讨论);
- 函数x需要一个lambda。
在两种情况下,我们都使用lambda。 假设有一个函数x()。 在Kotlin中,如果lambda是函数的最后一个参数,则可以将其放在括号之外,此外,如果lambda是唯一函数的参数,则可以省略括号。 结果,可以将结构x({…})转换为x(){},然后,通过省略括号,我们得到x {}。 这就是我们声明此类函数的方式:
fun x( lambda: () -> Unit ) { lambda() }
简洁的单行函数也可以这样编写:
fun x( lambda: () -> Unit ) = lambda()
但是,如果x是类实例或对象而不是函数,该怎么办? 以下是基于基本域特定概念的另一个有趣的解决方案:运算符重载。
运算符重载
Kotlin提供的操作员种类繁多,但数量有限。 运算符修饰符使您可以通过在某些条件下将要调用的约定来定义函数。 显而易见,如果在两个对象之间使用“ +”运算符,则会执行加号功能。 运营商的完整列表可以通过上面的链接在文档中找到。
让我们考虑一个比较简单的运算符invoke 。 本文的主要示例以schedule {}构造开始,该构造定义了代码块,负责测试计划。 此构造的构建方式与上述方法略有不同:我们使用invoke运算符+“括号之外的lambda”。 定义了invoke操作符之后,我们现在可以使用schedule(…)构造,尽管schedule是一个对象。 实际上,当您调用schedule(…)时 ,编译器会将其解释为schedule.invoke(…) 。 让我们看看时间表如何声明:
object schedule {
operator fun invoke(init: SchedulingContext.() -> Unit) {
SchedulingContext().init()
}
}
计划标识符将我们指向唯一一个由特殊关键字对象标记的计划类实例(单个)(您可以在此处找到有关此类对象的更多信息)。 因此,我们调用调度实例的invoke方法,将lambda作为单个参数并将其放在括号之外。 结果, 进度表{…}的结构与以下内容匹配:
schedule.invoke( { code inside lambda } )
但是,如果仔细看一下invoke方法,您将不会看到普通的lambda,而是看到“ lambda with handler”或“ lambda with context”,其类型定义为
SchedulingContext.() -> Unit
让我们详细研究一下。
带处理程序的Lambda
Kotlin使我们能够为lambda表达式设置上下文(此处的上下文和处理程序含义相同)。 上下文仅仅是一个对象。 上下文类型与lambda表达式类型一起定义。 这样的lambda获取上下文类中的非静态方法的属性,但只能访问此类的公共方法。
虽然普通lambda的类型定义为()-> Unit,但是具有X上下文的lambda的类型定义如下:X。()-> Unit,如果可以用通常的方式调用普通lambda:
val x : () -> Unit = {}
x()
带上下文的lambda需要一个上下文:
class MyContext
val x : MyContext.() -> Unit = {}
//x() //won’t be compiled, because a context isn’t defined
val c = MyContext() //create the context
c.x() //works
x(c) //works as well
我想提醒一下,我们已经在schedule对象中定义了invoke操作符(请参见上一段),该操作符允许我们使用以下构造:
schedule { }
我们正在使用的lambda具有SchedulingContext类型的上下文。 此类中具有数据方法。 结果,我们得到以下构造:
schedule {
data {
//...
}
}
您可能已经猜到了,数据方法也带有上下文的lambda,但是它是不同的上下文。 因此,我们获得了同时具有多个上下文的嵌套结构。 为了了解其工作原理,让我们从示例中删除所有语法糖:
schedule.invoke({
this.data({
})
})
如您所见,这非常简单。 让我们看一下invoke操作符的实现。
operator fun invoke(init: SchedulingContext.() -> Unit) {
SchedulingContext().init()
}
我们调用上下文SchedulingContext()的构造函数,然后使用创建的对象( context )调用带有作为参数传递的init标识符的lambda。 这很像一个通用函数调用。 结果,在一行SchedulingContext()。init()中,我们创建了上下文并调用传递给运算符的lambda。 更多的例子,可以考虑申请并与Kotlin标准库的方法。
在最后的示例中,我们发现了invoke操作符及其与其他工具的组合。 接下来,我们将关注正式成为操作员并使代码更整洁的工具-get / set方法约定 。
获取/设置方法约定
创建DSL时,我们可以实现一种通过一个或多个键访问映射的方法。 让我们看下面的例子:
availabilityTable[DayOfWeek.MONDAY, 0] = true
println(availabilityTable[DayOfWeek.MONDAY, 0]) //output: true
为了使用方括号,我们需要使用操作符修饰符实现get或set方法(取决于我们需要的内容,读取或更新的内容)。 您可以在GitHub的Matrix类中找到此类实现的示例。 它是矩阵操作的简单包装器。 在下面,您可以看到关于该主题的代码片段:
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 }
}
您可以使用任何get和set参数类型,唯一的限制就是想像力。 您可以自由地将一个或多个参数用于get / set函数,以提供一种方便的数据访问语法。 Kotlin中的操作员提供了许多有趣的功能,这些功能在文档中进行了介绍 。
令人惊讶的是,在Kotlin标准库中有一个Pair类。 开发人员社区的大部分成员认为Pair有害:当您使用Pair时 ,链接两个对象的逻辑丢失了,因此配对的原因并不透明。 接下来,我将向您展示的两个工具将演示如何保持配对的意义,而无需创建其他类。
输入别名
假设我们需要一个带有整数坐标的地理位置的包装类。 实际上,我们可以使用Pair类,但是有了这样的变量,我们可能很快就无法理解为什么将这些值配对了。
一个简单的解决方案是创建一个自定义类,甚至更糟。 Kotlin通过使用以下标记的类型别名丰富了开发人员的工具包:
typealias Point = Pair
实际上,无非就是重命名一个结构。 由于这种方法,我们不再需要创建Point类,因为它将仅复制Pair 。 现在我们可以用这种方式创建一个点:
val point = Point(0, 0)
但是, Pair类具有两个属性, first和second ,我们需要以某种方式重命名以模糊所需Point和初始Pair类之间的任何差异。 当然,我们不能自己重命名属性(但是您可以创建扩展属性 ),但是我们的工具包中还有一个值得注意的功能,称为解构声明 。
销毁声明
让我们考虑一个简单的情况:假设我们有一个Point类型的对象,正如我们已经知道的那样,它只是一个重命名的Pair类型。 如果我们查看标准库中的Pair类实现,我们将看到它具有一个数据修饰符,该修饰符指示编译器在此类中实现componentN方法。 让我们进一步了解它。
对于任何类,我们都可以定义componentN运算符,该运算符将负责提供对对象属性之一的访问。 这意味着,调用point.component1将等于调用point.first。 为什么我们需要这样的重复?
销毁声明是将对象“分解”为变量的一种方式。 此功能使我们可以编写以下类型的构造:
val (x, y) = Point(0, 0)
我们可以一次声明几个变量,但是将给它们赋什么值呢? 这就是为什么我们需要生成componentN方法的原因:使用从1而不是N开始的索引,我们可以将一个对象分解为一组属性。 因此,上述构造等于以下内容:
val pair = Point(0, 0)
val x = pair.component1()
val y = pair.component2()
依次等于:
val pair = Point(0, 0)
val x = pair.first
val y = pair.second
其中第一个和第二个是Point对象属性。
Kotlin中的for循环如下所示,其中x取值为1、2和3:
for(x in listOf(1, 2, 3)) { … }
请注意主要示例中DSL中的assertions块。 为了方便起见,我将重复其中的一部分:
for ((day, lesson, student, teacher) in scheduledEvents) { … }
这条线应该很明显。 我们遍历ScheduledEvents的集合, 将其每个元素分解为4个属性。
扩展功能
许多开发人员一直梦想着将新方法添加到来自第三方库的对象或Java Collection Framework中。 现在我们有了这样的机会。 这是我们声明扩展函数的方式:
fun AvailabilityTable.monday(from: String, to: String? = null)
与标准方法相比,我们添加了类名作为前缀来定义我们扩展的类。 在上面的示例中,AvailabilityTable是Matrix类型的别名,并且由于Kotlin中的别名只不过是重命名,因此此声明等于以下声明,这并不总是很方便:
fun Matrix.monday(from: String, to: String? = null)
不幸的是,除了不使用该工具或仅将方法添加到特定的上下文类之外,我们在这里无能为力。 在这种情况下,魔术只会出现在您需要的地方。 此外,即使扩展接口,也可以使用这些功能。 很好的例子,第一个方法扩展了任何可迭代的对象 :
fun Iterable.first(): T
本质上,不管元素类型如何,任何基于Iterable接口的集合都将采用第一种方法。 值得一提的是,我们可以将扩展方法放置在上下文类中,从而仅在此上下文中可以访问扩展方法(类似于具有上下文的lambda)。 此外,我们可以创建可空类型扩展功能( 可空类型的解释是超出范围在这里,详细内容见本链接 )。 例如,这就是我们如何使用标准Kotlin库中的isNullOrEmpty函数来扩展CharSequence类型的方法:
val s: String? = null
s.isNullOrEmpty() //true
以下是此函数的签名:
fun CharSequence?.isNullOrEmpty(): Boolean
使用Java的此类Kotlin扩展功能时,它们可以作为静态功能访问。
中缀功能
简化语法的另一种方法是使用infix函数。 简而言之,此工具可帮助我们在简单的情况下消除过多的代码。 主代码段中的assertions块演示了此工具的用例:
teacherSchedule[day, lesson] shouldNotEqual null
此构造等效于以下内容:
teacherSchedule[day, lesson].shouldNotEqual(null)
在某些情况下,方括号和点可能是多余的。 在这种情况下,我们可以将infix修饰符用于函数。
在上面的代码中,构造TeacherSchedule [day,lesson]返回一个schedule元素,函数shouldNotEqual会检查该元素是否为null。
要声明一个infix函数,您需要:
- 使用infix修饰符;
- 仅使用一个参数。
结合最后两个工具,我们可以获得以下代码:
infix fun T.shouldNotEqual(expected: T)
请注意,默认情况下,泛型类型是Any继承者(不是Nullable),但是,在这种情况下,我们不能使用null,这就是为什么您应该显式定义Any类型的原因?
上下文控制
当我们使用大量嵌套上下文时,在较低级别上,我们冒着狂放混合的风险。 由于缺乏控制,以下无意义的构造成为可能:
schedule { //context SchedulingContext
data { //context DataContext + external context SchedulingContext
data { } //possible, as there is no context control
}
}
在Kotlin v.1.1之前,已经有一种避免混乱的方法。 它在于在嵌套上下文DataContext中创建自定义方法数据,然后使用带有ERROR级别的Deprecated注释对其进行标记。
class DataContext {
@Deprecated(level = DeprecationLevel.ERROR, message = "Incorrect context")
fun data(init: DataContext.() -> Unit) {}
}
这种方法消除了构建不正确的DSL的可能性。 但是,SchedulingContext中的大量方法会使我们在避免任何上下文控件的情况下进行许多常规工作。
Kotlin 1.1提供了一个新的控制工具-@DslMarker批注。 它应用于您自己的注释,这些注释又用于标记您的上下文。 让我们创建一个注释,并使用我们工具箱中的新工具对其进行标记:
@DslMarker
annotation class MyCustomDslMarker
现在我们需要标记上下文。 在主要示例中,这些是SchedulingContext和DataContext。 就我们用通用的DSL标记注释两个类而言,将发生以下情况:
@MyCustomDslMarker
class SchedulingContext { ... }
@MyCustomDslMarker
class DataContext { ... }
fun demo() {
schedule { //context SchedulingContext
data { //context DataContext + external context SchedulingContext is forbidden
// data { } //will not compile, as contexts are annotated with the same DSL marker
}
}
}
利用这种很酷的方法的所有优点,可以节省大量的时间和精力,仍然存在一个问题。 看一下主要示例,或更准确地说,看一下代码的这一部分:
schedule {
data {
student {
name = "Petrov"
}
...
}
}
在这种情况下,在第三个嵌套级别上,我们获得了新的上下文Student,它实际上是一个实体类,因此我们希望使用@MyCustomDslMarker注释数据模型的一部分,我认为这是错误的。 在Student上下文中,仍然禁止数据{}调用,因为外部DataContext仍在它的位置,但是以下构造仍然有效:
schedule {
data {
student {
student { }
}
}
}
尝试解决带有注释的问题将导致混合业务逻辑和测试代码,这当然不是最好的主意。 这里有三种解决方案:
-
- 使用额外的上下文创建学生,例如StudentContext。 这闻起来像疯了似的,胜过@DslMarker的好处。
- 为所有实体(例如,IStudent)(无论名称如何)创建接口,而不是创建实现这些接口的存根上下文,最后将实现委派给学生对象,这也使他们陷入疯狂。
@MyCustomDslMarker
class StudentContext(val owner: Student = Student()): IStudent by owner
- 如上例所示,使用@Deprecated批注。 在这种情况下,它似乎是最佳的解决方案:我们只为所有可识别对象添加一个不赞成使用的扩展方法。
@Deprecated("Incorrect context", level = DeprecationLevel.ERROR) fun Identifiable.student(init: () -> Unit) {}
综上所述,结合各种工具使您能够为实际目的构建非常方便的DSL。
DSL使用的缺点
让我们尝试在Kotlin中使用DSL时更加客观,并找出在项目中使用DSL的弊端。
重复使用DSL部分
假设您必须重用DSL的一部分。 您想要使用一段代码并能够轻松地对其进行复制。 当然,在具有单个上下文的最简单情况下,我们可以在扩展功能中隐藏DSL的可重复部分,但这在大多数情况下不起作用。
也许您可以指出我在注释中有更好的选择,因为目前我只有两种解决方案:将“命名回调”作为DSL的一部分添加或生成lambda。 第二个比较容易,但是当您尝试了解呼叫顺序时可能会导致麻烦。 问题在于,我们拥有的命令性行为越多,DSL方法带来的好处就越少。
这个!!
在使用DSL时,失去当前“ this”和“ it”的含义没有比这容易的了。 如果将“ it”用作默认参数名称,并且可以用有意义的名称替换,则最好这样做。 最好有一些明显的代码,而不是非明显的错误。
上下文的概念可能会使从未接触过它的人感到困惑。 现在,由于您的工具包中有“带有处理程序的lambda”,因此DSL内部意外的方法不太可能出现。 请记住,在最坏的情况下,您可以将上下文设置为变量,例如,val mainContext = this
套料
此问题与该列表中的第一个缺点紧密相关。 使用嵌套结构中的嵌套结构可将所有有意义的代码向右移动。 达到一定限制后,此偏移是可以接受的,但是如果偏移太大,则使用lambda是合理的。 当然,这不会降低DSL的可读性,但是如果您的DSL不仅包含紧凑的结构而且还包含一些逻辑,则可能会有所折衷。 当您使用DSL创建测试时(本文涵盖的情况),由于数据是用紧凑的结构描述的,因此这个问题并不严重。
Lebowski的文档在哪里?
当您第一次尝试处理某人的DSL时,几乎可以肯定会想知道文档在哪里。 在这一点上,我相信如果您的DSL被其他人使用,用法示例将是最好的文档。 文档本身很重要,可以作为其他参考,但是对读者来说不是很友好。 特定领域的从业人员通常会以“我该如何称呼才能得到结果?”这个问题开头,因此,以我的经验,类似案例的例子将更好地说明自己。
结论
我们对工具进行了概述,使您可以轻松设计自己的自定义域特定语言。 希望您现在看到它的工作原理。 随时在评论中建议更多工具。
重要的是要记住,DSL不是灵丹妙药。 当然,当您获得如此强大的锤子时,一切看上去都像钉子,但事实并非如此。 从小处开始,创建DSL进行测试,从错误中吸取教训,然后经验丰富的考虑其他使用领域。
翻译自: https://www.javacodegeeks.com/2018/07/kotlin-dsl-theory-practice.html