65.9K
CodeProject 正在变化。 阅读更多。
Home

Kotlin DSL:从理论到实践

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2017年12月26日

CPOL

20分钟阅读

viewsIcon

11895

我们将讨论为什么 Kotlin 是构建领域特定语言的绝佳工具。

引言

SQL、RegExp、Gradle——它们有什么共同点?它们都代表了领域特定语言(DSL)的应用。这类语言旨在解决特定问题,例如数据库查询、文本匹配或构建过程描述。Kotlin提供了大量特性来构建自己的领域特定语言。在本文中,我们将探索开发者的工具集,并为现实世界的领域实现一个DSL。

我将尽量用最简单的方式解释语言语法,然而,本文仍然面向那些将Kotlin视为构建自定义DSL语言的开发人员。在文章结尾,我将提及值得考虑的Kotlin的缺点。文中提供的代码片段与Kotlin 1.2.0版本相关,并可在GitHub上找到。

什么是DSL?

所有编程语言都可以分为通用语言和领域特定语言。SQL、正则表达式和build.gradle经常被引用为DSL的例子。这些语言功能有限,但它们能够有效地解决特定问题。它们允许我们以一种或多或少声明式的方式(我们只声明任务)来编写命令式代码(我们不需要解释如何解决问题),以便根据给定的数据获得解决方案。

假设你有一个标准的流程定义,它可以最终被改变和增强,但总的来说,你想用不同的数据和结果格式来使用它。通过创建DSL,你创建了一个灵活的工具来解决一个主题领域内的各种问题,而不管解决方案是如何获得的。因此,你创建了一种API,一旦掌握,就可以简化你的生活,并使系统在长期内更容易保持最新。

本文将介绍在Kotlin中构建“嵌入式”DSL,即一种基于通用语言语法的语言。你可以在这里阅读更多相关信息。

实现领域

在我看来,使用和演示Kotlin DSL的最佳方式之一是测试。

假设你来自Java世界。你多久会遇到声明一个拥有庞大数据模型的实体实例?你很可能使用了一些构建器,甚至更糟的是,使用特殊的工具类在后台填充默认值。你覆盖了多少方法?你需要对默认值进行多少小的更改,这需要付出多大的努力?

如果这些问题只会让你产生负面情绪,那么本文就是为你准备的。

这就是我们在教育领域的一个项目中长期以来一直在做的方式:我们使用构建器和工具类来覆盖我们最重要的模块之一(学校时间表调度)的测试。现在,这种方法已经被Kotlin语言和DSL所取代,后者用于描述测试场景和检查结果。在本文中,你将看到我们如何利用Kotlin,使调度子系统的测试不再是一种折磨。

在本文中,我们将深入探讨构建一个DSL的细节,该DSL有助于测试一个构建教师和学生日程的算法。

关键工具

以下是你可以用Kotlin编写更简洁的代码并创建自己的DSL的基本语言特性。下表展示了值得使用的主要语法增强。请仔细查看。如果你对大多数这些工具都不熟悉,你可能需要阅读整篇文章。如果你只对其中一两个不熟悉,可以随意跳到相应的部分。如果你没有发现任何新东西,只需跳到文章末尾的DSL缺点回顾。欢迎在评论区提出更多工具。

工具 DSL语法 通用语法
运算符重载 collection += element collection.add(element)
类型别名 typealias Point = Pair 创建空继承类和其他“胶水”
get/set方法约定 map["key"] = "value" map.put("key", "value")
解构声明 val (x, y) = Point(0, 0) val p = Point(0, 0)
val x = p.first
val y = p.second
括号外的Lambda list.forEach { ... } list.forEach({...})
扩展函数 mylist.first(); // mylist 集合中没有 first() 方法 实用函数
中缀函数 1 to "one" 1.to("one")
带接收者的Lambda Person().apply { name = "John" } N/A
上下文控制 @DslMarker N/A

发现什么新东西了吗?如果发现了,我们继续。

我故意省略了委托属性,因为在我看来,它们对构建DSL来说毫无用处,至少在我们的情况下是这样。使用以上特性,我们可以编写更简洁的代码,并摆脱冗长的“嘈杂”语法,使开发更加愉快(可能吗?)。

我喜欢《Kotlin in Action》一书中遇到的类比:在自然语言(如英语)中,句子由单词组成,语法规则定义了组合这些单词的方式。

类似地,在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及其一元运算符invoke(稍后讨论)
  • 函数x,它接受一个Lambda

在这两种情况下,我们都使用Lambda。假设有一个函数x()。在Kotlin中,如果Lambda是函数的最后一个参数,它可以放在括号外面。此外,如果Lambda是函数的唯一参数,则可以省略括号。结果,构造x({...})可以转换为x() {},然后通过省略括号,我们得到x {}。这样就声明了这样的函数

fun x( lambda: () -> Unit ) { lambda() }

以简洁的形式,单行函数也可以这样写

fun x( lambda: () -> Unit ) = lambda()

但是,如果x是一个类实例或对象而不是函数呢?下面是另一个基于基本领域特定概念的有趣解决方案:运算符重载。

运算符重载

文档

Kotlin提供了广泛但有些有限的运算符。operator修饰符允许通过约定定义在特定条件下调用的函数。一个明显的例子是,当你使用对象之间的“+”运算符时,会执行加法函数。完整的运算符列表可以在上面链接的文档中找到。

让我们考虑一个不那么平凡的运算符:invoke。本文的主要示例从schedule { }构造开始,该构造定义了负责测试计划的代码块。此构造的构建方式略有不同于上述方法:我们使用invoke运算符+“括号外的Lambda”。

定义了invoke运算符后,我们就可以使用schedule(...)构造了,尽管schedule是一个对象。事实上,当你调用schedule(...)时,编译器将其解释为schedule.invoke(...)。让我们看看schedule是如何声明的

object schedule {
    operator fun invoke(init: SchedulingContext.() -> Unit)  { 
        SchedulingContext().init()
    }
}

schedule标识符指向由特殊关键字object标记的唯一schedule类实例(单例)(你可以在这里找到更多关于此类对象的信息)。因此,我们调用schedule实例的invoke方法,将Lambda作为单个参数接收,并将其放在括号外面。结果,schedule {... }构造匹配以下内容

schedule.invoke( { code inside lambda } )

然而,如果你仔细查看invoke方法,你会发现它不是一个普通的Lambda,而是一个“带接收者的Lambda”或“带上下文的Lambda”,其类型定义为

SchedulingContext.() -> Unit

让我们详细研究一下。

带接收者的Lambda

文档

Kotlin允许我们为Lambda表达式设置上下文(上下文和接收者在此处表示相同的意思)。Context只是一个对象。上下文类型与Lambda表达式类型一起定义。这样的Lambda获得了context类中非静态方法的属性,但它只能访问该类的public方法。

虽然普通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 { 
        //... 
    } 
}

你可能已经猜到了,data方法也接受一个带上下文的Lambda。但是,它的上下文不同。因此,我们得到具有多个上下文的嵌套结构。为了理解它的工作原理,让我们从示例中移除所有语法糖

schedule.invoke({ 
    this.data({ }) 
})

正如你所见,这相当简单。让我们看看invoke运算符的实现。

operator fun invoke(init: SchedulingContext.() -> Unit)  { 
    SchedulingContext().init()
}

我们调用上下文SchedulingContext()的构造函数,然后使用创建的对象(context)调用传递给参数的名为init的Lambda。这很像一个通用的函数调用。

结果,在一行代码SchedulingContext().init()中,我们创建了上下文并调用了传递给运算符的Lambda。有关更多示例,请参阅Kotlin标准库中的applywith方法。

在最后几个示例中,我们探索了invoke运算符及其与其他工具的组合。接下来,我们将重点介绍一个形式上是运算符并且使代码更简洁的工具——get/set方法约定

get/set 方法约定

文档

创建DSL时,我们可以实现通过一个或多个键来访问映射的方法。让我们看下面的例子

availabilityTable[DayOfWeek.MONDAY, 0] = true 
println(availabilityTable[DayOfWeek.MONDAY, 0]) //output: true

为了使用方括号,我们需要实现getset方法(取决于我们需要——读取还是更新),并带有operator修饰符。你可以在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 }
}

你可以使用任何getset参数类型,唯一的限制是你的想象力。你可以自由地为get/set函数使用一个或多个参数,以提供方便的数据访问语法。Kotlin中的运算符提供了许多有趣的功能,这些功能在文档中有描述。

令人惊讶的是,Kotlin标准库中有一个Pair类。大部分开发社区认为Pair有害:当你使用Pair时,连接两个对象的逻辑就丢失了,因此不清楚它们为什么被配对。接下来我将展示的两个工具将演示如何在不创建额外类的情况下使配对有意义。

类型别名

文档

假设我们需要一个带有整数坐标的地理点包装类。实际上,我们可以使用Pair类,但拥有这样一个变量,我们可能会失去对为什么配对这些值的理解。

一个简单的解决方案是创建一个自定义类,或者更糟的东西。Kotlin通过类型别名丰富了开发者的工具集,使用以下表示法

typealias Point = Pair

事实上,这只不过是对构造的重命名。通过这种方法,我们不再需要创建Point类,因为它只会复制Pair。现在我们可以这样创建一个point

val point = Point(0, 0)

然而,Pair类有两个属性,firstsecond,我们需要重命名它们,以模糊所需Point和初始Pair类之间的任何差异。当然,我们无法重命名属性本身(尽管你可以创建扩展属性),但我们的工具集中还有一个名为destructuring declarations的值得注意的功能。

解构声明

文档

让我们考虑一个简单的情况:假设我们有一个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

其中firstsecond是Point对象的属性。

Kotlin中的for循环如下所示,其中x的值为123

for(x in listOf(1, 2, 3)) { ... }

请注意主示例中DSL的断言块。为了方便起见,我将重复其中一部分

for ((day, lesson, student, teacher) in scheduledEvents) { ... }

这行代码应该很明显。我们遍历scheduledEvents集合,其中每个元素被分解为四个属性。

扩展函数

文档

为第三方库的对象或Java集合框架添加新方法是许多开发人员梦寐以求的。现在我们有了这样的机会。这是声明扩展函数的方法

fun AvailabilityTable.monday(from: String, to: String? = null)

与标准方法相比,我们添加类名作为前缀来定义我们要扩展的类。在上面的例子中,AvailabilityTableMatrix类型的别名,并且由于Kotlin中的别名仅仅是重命名,因此此声明等同于以下声明,但这并不总是方便的

fun Matrix.monday(from: String, to: String? = null)

不幸的是,我们对此无能为力,只能选择不使用此工具,或者仅将方法添加到特定上下文类。在这种情况下,魔力只在你需要它的时候出现。此外,你甚至可以使用它们来扩展接口。一个很好的例子是,first方法扩展了任何iterable object

fun Iterable.first(): T

本质上,任何基于Iterable接口的集合,无论元素类型如何,都具有first方法。值得一提的是,我们可以将扩展方法放在上下文类中,从而只在该上下文中有权访问扩展方法(类似于带上下文的Lambda)。此外,我们可以为Nullable类型创建扩展函数(Nullable类型的解释超出了范围,但更多细节请参见此链接)。例如,这就是我们如何使用标准Kotlin库中的isNullOrEmpty函数,该函数扩展了CharSequence类型

val s: String? = null
s.isNullOrEmpty() //true

下面是此函数签名

fun CharSequence?.isNullOrEmpty(): Boolean

从Java中使用这些Kotlin扩展函数时,它们可以作为static函数访问。

中缀函数

文档

使我们的语法更美化的另一种方法是使用中缀函数。简单来说,这个工具可以帮助我们在简单的情况下消除过多的代码。主代码段的断言块演示了此工具的用例

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的继承者(非可空)。然而,在这种情况下,我们不能使用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

现在我们需要标记上下文。在主示例中,这些是SchedulingContextDataContext。由于我们将它们都用共同的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上下文中,data {}调用仍然被禁止,因为外部DataContext仍然在那里,但是以下构造仍然有效

schedule {
    data {
        student {
            student { }
        }
    }
}

尝试用注解解决问题会导致业务逻辑和测试代码的混合,这当然不是最佳选择。这里有三种可能的解决方案

@MyCustomDslMarker
class StudentContext(val owner: Student = Student()): IStudent by owner
  1. 为创建学生使用额外的上下文——例如,StudentContext。这听起来很疯狂,并且超过了@DslMarker的好处。
  2. 为所有实体创建接口——例如,IStudent(无论名称如何),然后创建实现这些接口的存根上下文,最后将实现委托给student对象。这也很疯狂。
  3. 使用@Deprecated注解,如前面的示例。在这种情况下,使用它似乎是最佳解决方案:我们只为所有Identifiable对象添加一个已弃用的扩展方法。
    @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,从错误中学习,然后,一旦你更有经验,再考虑其他使用领域。

关于作者

我是Haulmont的一名软件开发人员。去年,我的职责范围是教育领域的表格调度。我在开发基于CUBA Platform Java框架的应用程序时应用了上述技术。我业余时间接触Spring、用于Telegram机器人开发的Kotlin DSL,当然还有我的妻子。

历史

  • 2017年12月26日:初始版本
© . All rights reserved.