Kotlin DSL – 类型安全构建器

(D)omain (S)pecific (L)anguage语言是一种用于描述特定域中事物的格式。一个非常基本的例子是购物清单:包含项目和可选项目计数的列表。对购物非常有用。但在这种情况下,DSL的目标是帮助开发人员。众所周知的例子是HTML和CSS,用于描述网页和样式的格式。对于我们人类来说,阅读和编写HTML和CSS比通过常规编程获得相同的结果更容易。但是您确实需要一个专用的DSL解析器。如果有一点编辑器支持,可以在您在DSL中编写时为您提供帮助,那就太好了。

在 Kotlin 中,您可以使用类型安全生成器来实现此目的。它们用于诸如格拉德DSL和设置Ktor服务器之类的事情。但是开发人员也可以使用它们为其特定域编写DSL。它们提供类型安全和编辑器支持(代码完成),因此使用它们大多是无痛的。

这很好,但是作为开发人员,您希望了解自己在做什么以及为什么有效。Kotlin DSL并不直观,它们依赖于一些先进的Kotlin概念。不幸的是,标准文档(https://kotlinlang.org/docs/reference/type-safe-builders.html)相当快地讨论了这些概念。

本教程旨在全面解释类型安全生成器背后的基本概念,然后说明如何使用它来创建此 DSL:

data class Person(val age: Int, val greeting: Greeting)
data class Greeting(val specific: Map<String, String>, val default: String)

fun main() {
    val person = buildPerson {
        age = 42
        greeting {
            "Hello" to "father"
            "Hi" to "mother"
            default = "What's up"
        }
    }
    println(person)
}

哪些输出:

Person(age=42, greeting=Greeting(
  specific={mother=Hi, father=Hello}, default=What's up))

在解释基本概念时,我们将采取初学者步骤,以确保您可以遵循。然后,我们将加快速度来解释如何制作我们的DSL。在本教程结束时,您将了解其工作原理和原因,以及如何自己编写必要的构建器。

提供了指向Kotlin游乐场代码的链接。自己玩这些概念真的很有帮助,所以如果你感到困惑,只需玩一下代码。

第 1 部分 – 基础知识

扩展函数

Kotlin DSL 是使用扩展函数构建的,因此让我们先来看看普通扩展函数的基础知识。有关详细信息,请参阅 https://kotlinlang.org/docs/reference/extensions.html

data class Person ( var age: Int = 0 )

fun Person.defineAge() { // this: Person
    this.age = 42
}

fun main() {
    val person = Person()
    person.defineAge()
    println(person)
}

此代码输出:

Person(age=42)

尝试使用此代码:https://pl.kotl.in/91ERUDeaK

在扩展函数中,是 Person 对象。我们可以使用它来访问 person 对象的任何公共函数和属性。虽然我们似乎以某种方式在 Person 类中添加了功能,但我们不是。我们只能访问公共函数和属性,因为我们被交给的对象(在名称下),就像驻留在 Person 类之外的任何其他代码段一样。fun Person.setAge()thisthisthis

这一切都只是编译器提供的语法糖。编写相同功能的另一种方法是:

fun normalDefineAge(person: Person) {
    person.age = 42
}

normalDefineAge(person)

扩展功能更易于阅读,因此这是它们的一大优势。另一个是,从某种意义上说,这是特殊的,因为它可以省略。所以我们也可以这样写:this

fun Person.defineAge() { // this: Person
    age = 42
}

将一个扩展函数传递给另一个函数

函数是 Kotlin 中的一等公民,扩展函数也是如此。因此,我们可以将它们作为参数传递给其他函数。对于扩展函数来说,那会是什么样子?

data class Person ( var age: Int = 0 )

fun Person.defineAge() { // this: Person
    this.age = 42
}

fun buildPerson(init: Person.() -> Unit): Person {
    val person = Person()
    person.init()
    return person
}

fun main() {
    val person = buildPerson(Person::defineAge)
    println(person)
}

在构建人中,您会看到扩展函数的函数类型为 。这是有道理的:它是一个扩展 Person 的函数,没有参数,也不返回任何内容。init 函数不能由自身调用,它必须在 Person 上调用。Person.() -> Unit

虽然它可能看起来有点奇怪,但它仍然只是能够将一个(扩展)函数传递给另一个函数的自然结果。结果非常棒:通过调用,我们可以更改 的内容,如在构建器函数外部所定义的那样。这是Kotlin DSL的基础。person.init()person

使用 lambda 作为扩展函数

上一节中的扩展函数可以通过将其编写为匿名函数来分配给值:fun Person.defineAge() {}

val defineAgeFunction: Person.() -> Unit = fun Person.() { this.age = 42 }

使用 Kotlin 类型推断,我们可以将其缩写为:

val defineAgeFunction = fun Person.() { this.age = 42 } 

如果我们稍微重写它,我们得到lambda语法:

val defineAgeLambda: Person.() -> Unit = { this.age = 42 }

对我来说,最后一行看起来很奇怪:我认为类型推断是从右到左工作的。如:setAge 的类型派生自我们等号(我们的 lambda 函数)右侧的内容。如果这就是它的全部内容,我们将得到一个编译错误。右边的东西没有意义。

对于 lambdas,它从左到右工作:我们在等号的左侧指定我们需要的类型,这会影响其右侧写入的内容。借用鸭子打字的比喻,编译器告诉我们:你是一只鸭子,所以可以像一只鸭子一样随意走路和嘎嘎叫。

也许你可以看到我们要去哪里。我们可以将值传递给 ,但我们也可以内联 lambda 表达式,以避免有一个单独的定义AgeLambda。如果函数的最后一个参数是 lambda,我们可以将表达式移到括号后面。如果没有其他参数,我们可以完全省略括号。defineAgeLambdabuildPerson()

data class Person ( var age: Int = 0 )

val defineAgeLambda: Person.() -> Unit = { this.age = 1 }

fun main() {
    // pass the extension function as a lambda value
    val person1 = buildPerson(defineAgeLambda)

    // write the argument as an inline lambda
    val person2 = buildPerson({ this.age = 2 })

    // move the lambda expression outside the buildPerson parenthesis  
    val person3 = buildPerson() { this.age = 3 }

    // omit the empty parenthesis
    val person4 = buildPerson { this.age = 4 }  

    // omit `this`
    val person5 = buildPerson {  
        age = 5
    }

    println("$person1, $person2, $person3, $person4, $person5")
}

fun buildPerson(init: Person.() -> Unit): Person {
    val person = Person()
    person.init()
    return person
}

哪些输出:

Person(age=1), Person(age=2), Person(age=3), Person(age=4), Person(age=5)

我们有它:DSL的第一部分。不再可见的是,通过使用编写为内联 lambda 的扩展函数来实际设置 Person 对象的年龄。age = 5this

如果你理解了这一点,那么你就理解了DSL是如何工作的。我们可以使DSL更有用,其实现更干净,但基础知识保持不变。所以玩这个代码!

第 2 部分 – 制作我们的 DSL

现在我们已经掌握了基本概念,我们可以研究如何创建开头所示的完整DSL。

易变性

第 1 部分中的示例依赖于 .这通常不是我们想要的,但我们需要它才能设置其值。解决方案是拥有人员的构建器版本(带有)和人员的最终版本,如下所示:agevarvar ageval age

data class Person(val age: Int)

fun main() {
    val person: Person = buildPerson { // this: PersonBuilder
        age = 42
    }
    println(person)
}


class PersonBuilder {
    var age = 0

    fun build(): Person { return Person(this.age) }
}

fun buildPerson(init: PersonBuilder.() -> Unit): Person {
    val builder = PersonBuilder()
    builder.init()
    return builder.build()
}

嵌 套

虽然具有不可变的值是我们想要的,但构建器类使DSL代码更难阅读。我们的lambda不再扩展(我们知道和理解的类),但它扩展了隐藏在DSL代码中的中间类。Person

但是构建器类确实有另一个存在的理由。它们允许我们添加其他函数,我们需要这些函数来创建嵌套属性。让我们看一下向某人添加问候语的代码,现在只将自己限制为默认问候语。

data class Person(val age: Int, val greeting: Greeting)
data class Greeting(val default: String)

fun main() {
    val person1: Person = buildPerson { // this: PersonBuilder
        this.age = 1
        this.greeting({ // this: GreetingBuilder
            this.default = "How are you"
        })
    }
    val person2: Person = buildPerson {
        age = 2
        greeting {
            default = "What's up"
        }
    }

    println("$person1, $person2")
}

class GreetingBuilder {
    var default = ""
    fun build(): Greeting { return Greeting(default) }
}

class PersonBuilder {
    var age = 0
    private val greetingBuilder = GreetingBuilder()
    
    fun greeting(init: GreetingBuilder.() -> Unit) {
        greetingBuilder.init()
    }

    fun build(): Person { return Person(this.age, this.greetingBuilder.build()) }
}

fun buildPerson(init: PersonBuilder.() -> Unit): Person {
    val builder = PersonBuilder()
    builder.init()
    return builder.build()
}

正在重用我们的基本 DSL 构建块 (扩展函数 lambda) 来创建嵌套的问候属性。PersonBuilder

扩展函数的有用技巧

为了使我们的DSL看起来不错,我们可以用另一种方式使用扩展函数。例如,我们可以将类的扩展函数声明为构建器类中的成员函数。这与仅创建 String 的扩展函数非常相似,但我们将范围限制为生成器类。String

class GreetingBuilder {
    var default = ""
    
    fun String.useAsDefault () { // this: String
        default = this
    }
    
    fun useFormal() {
        "How do you do?".useAsDefault()
    }
    
    fun build(): Greeting { return Greeting( default) }
}

该函数不能在全局范围内使用,但必须在问候生成器的上下文中使用,就像在 中一样。useAsDefault()fun useFormal()

但是,它仍然是问候生成器类的公共部分,因此它也可用于问候生成器类的扩展函数。这意味着我们可以在DSL lambda中使用它。例如:

data class Greeting(val default: String)

fun main() {
    val greeting: Greeting = buildGreeting { // this: GreetingBuilder
        "What's up".useAsDefault()
    }
    println(greeting)
}

fun buildGreeting(init: GreetingBuilder.() -> Unit): Greeting {
    val builder = GreetingBuilder()
    builder.init()
    return builder.build()
}

使用此构造,我们可以创建 DSL 的最后一部分。我们使用哈希图来存储特定的问候语,并使用字符串扩展函数使填充地图看起来不错。为了抛光,我们使用中缀符号来使它看起来更好。

data class Greeting(val specific: Map<String, String>, val default: String)

fun main() {
    
    val greeting: Greeting = buildGreeting { // this: GreetingBuilder
        
        "Hello".to("father")  // using String extension member function
        "Hi" to "mother"      // also making use of infix notation
        
        default = "What's up"
    }

    println(greeting)
}

class GreetingBuilder {
    var default = ""
    private val specific = HashMap<String, String>()
    
    infix fun String.to (target: String) {
        specific[target] = this
    }
    
    fun build(): Greeting { return Greeting(specific, default) }
}

fun buildGreeting(init: GreetingBuilder.() -> Unit): Greeting {
    val builder = GreetingBuilder()
    builder.init()
    return builder.build()
}

@DslMarker

看起来我们都完成了,我们实现了我们的目标。但是,这个难题还有一块。再考虑嵌套构建器的情况:

fun main() {
    val person: Person = buildPerson {  // this: PersonBuilder
        age = 2
        greeting { // this: GreetingBuilder
            default = "What's up"
        }
    }
    println(person)
}

如果我们在 Kotlin Playground 中玩一会儿,我们可以看到代码完成告诉我们,我们可以访问年龄作为问候语的一部分。

事实上,这是真的。我们可以访问 中的 的属性。原因是我们仍然在人员构建器的范围内。因此,从编译器的角度来看,我们可以访问其属性是有道理的。PersonBuilderGreetingBuilder

在一个作用域中有多个函数可能会感到奇怪,但这是使用扩展函数的结果。在将扩展函数编写为成员函数时,我们已经使用了它。this

class GreetingBuilder {
    var default = ""
    
    fun String.useAsDefault () { // this: String
        // access to GreetingBuilder.default via implicit this
        default = this
                          
        // access to GreetingBuilder.default via explicit this
        this@GreetingBuilder.default = this     
    }
}

如您所见,我们还可以显式访问外部。this

幸运的是,Kotlin 中存在一种机制,用于告诉编译器阻止隐式可用:。有了它,我们定义了一个注释,用于将我们的代码标记为我们的DSL语言。this@DslMarker

@DslMarker
annotation class PersonDsl

现在,我们可以注释我们的人员生成器和 HelloBuilder 类,使其成为 的一部分。PersonDSL

data class Person(val age: Int, val greeting: Greeting)
data class Greeting(val specific: Map<String, String>, val default: String)

@DslMarker
annotation class PersonDsl

fun main() {
    val person = buildPerson {
        age = 42
        greeting {
            "Hello" to "father"
            "Hi" to "mother"
            default = "What's up"

            // age = 41
            // Uncommenting the line above will yield a compile error.  
			
			this@buildPerson.age = 43  // But we can still be explicit
        }
    }
    println(person)
}

@PersonDsl
class GreetingBuilder {
    var default = ""
    private val specific = HashMap<String, String>()
    
    infix fun String.to (target: String) { specific[target] = this }
   
    fun build(): Greeting { return Greeting(specific, default) }
}

@PersonDsl
class PersonBuilder {
    var age = 0
    private val greetingBuilder = GreetingBuilder()
    
    fun greeting(init: GreetingBuilder.() -> Unit) { greetingBuilder.init() }

    fun build(): Person { return Person(this.age, this.greetingBuilder.build()) }
}

fun buildPerson(init: PersonBuilder.() -> Unit): Person {
    val builder = PersonBuilder()
    builder.init()
    return builder.build()
}

我们有了它:我们开始编写的DSL。我们正在防止我们在块内意外引用。@PersonDslagegreeting {}

您可能想知道为什么我们必须定义我们的DSL语言注释,以及为什么我们不能在我们的构建器类上打上标签。原因是通过这种方式,多个DSL语言可以共存。但是,不要让我举一个例子,;)PersonDsl@DslMarker

结论

DSL语言是帮助您编写正确代码或配置的好工具。希望本指南有助于揭开它在引擎盖下的工作原理。这将帮助您在使用 DSL 时解决问题,也许它会激发您编写自己的 Kotlin DSL!