如何强制客户端代码使用合约初始化 Kotlin 中所有必需的构建器字段?

Posted

技术标签:

【中文标题】如何强制客户端代码使用合约初始化 Kotlin 中所有必需的构建器字段?【英文标题】:How to force client code to initialize all required builder fields in Kotlin with contracts? 【发布时间】:2019-09-28 23:09:03 【问题描述】:

在 JetBrains 2019 年开放日,据说 Kotlin 团队研究了合约并尝试实现 context 合约,该合约只允许在某些上下文中调用函数,例如,函数 build 被允许仅当 setName 方法在它之前被调用一次时才被调用。 Here 是谈话录音。

我已尝试使用当前可用的 Kotlin 功能来模拟此类合同,以为data class Person(val name: String, val age: Int) 创建空安全构建器。

注意:当然,在这种情况下,使用命名参数而不是构建器模式要容易得多,但是命名参数不允许将未完全构建的对象解析为其他函数,并且当您使用它们时很难使用它们想要创建一个由其他复杂对象等组成的复杂对象。

所以这里是我的 null-safe builder 实现:

基于通用标志的构建器

sealed class Flag 
    object ON : Flag()
    object OFF : Flag()


class PersonBuilder<NAME : Flag, AGE : Flag> private constructor() 
    var _name: String? = null
    var _age: Int? = null

    companion object 
        operator fun invoke() = PersonBuilder<OFF, OFF>()
    


val PersonBuilder<ON, *>.name get() = _name!!
val PersonBuilder<*, ON>.age get() = _age!!

fun <AGE : Flag> PersonBuilder<OFF, AGE>.name(name: String): PersonBuilder<ON, AGE> 
    _name = name
    @Suppress("UNCHECKED_CAST")
    return this as PersonBuilder<ON, AGE>


fun <NAME : Flag> PersonBuilder<NAME, OFF>.age(age: Int): PersonBuilder<NAME, ON> 
    _age = age
    @Suppress("UNCHECKED_CAST")
    return this as PersonBuilder<NAME, ON>


fun PersonBuilder<ON, ON>.build() = Person(name, age)

优点:

    在同时指定 nameage 之前,无法构建人。 无法重新分配属性。 部分构建的对象可以安全地保存到变量并传递给函数。 函数可以指定生成器所需的状态和将返回的状态。 属性可以在赋值后使用。 流畅的界面。

缺点:

    此构建器不能与 DSL 一起使用。 如果不添加类型参数并破坏所有现有代码,则无法添加新属性。 每次都必须指定所有泛型(即使函数不关心age,它也必须声明它接受具有任何AGE 类型参数的构建器并返回具有相同类型参数的构建器。) _name_age 属性不能是私有的,因为它们应该可以从扩展函数中访问。

这是这个构建器的用法示例:

PersonBuilder().name("Bob").age(21).build()
PersonBuilder().age(21).name("Bob").build()
PersonBuilder().name("Bob").name("Ann") // doesn't compile
PersonBuilder().age(21).age(21) // doesn't compile
PersonBuilder().name("Bob").build() // doesn't compile
PersonBuilder().age(21).build() // doesn't compile

val newbornBuilder = PersonBuilder().newborn() // builder with age but without name
newbornBuilder.build() // doesn't compile
newbornBuilder.age(21) // doesn't compile
val age = newbornBuilder.age
val name = newbornBuilder.name // doesn't compile
val bob = newbornBuilder.name("Bob").build()
val person2019 = newbornBuilder.nameByAge().build()
PersonBuilder().nameByAge().age(21).build() // doesn't compile

fun PersonBuilder<OFF, ON>.nameByAge() = name("Person #$Year.now().value - age")
fun <NAME : Flag> PersonBuilder<NAME, OFF>.newborn() = age(0)

基于合同的构建器

sealed class PersonBuilder 
    var _name: String? = null
    var _age: Int? = null

    interface Named
    interface Aged

    private class Impl : PersonBuilder(), Named, Aged

    companion object 
        operator fun invoke(): PersonBuilder = Impl()
    


val <S> S.name where S : PersonBuilder, S : Named get() = _name!!
val <S> S.age where S : PersonBuilder, S : Aged get() = _age!!

fun PersonBuilder.name(name: String) 
    contract 
        returns() implies (this@name is Named)
    
    _name = name


fun PersonBuilder.age(age: Int) 
    contract 
        returns() implies (this@age is Aged)
    
    _age = age


fun <S> S.build(): Person
        where S : Named,
              S : Aged,
              S : PersonBuilder =
    Person(name, age)

fun <R> newPerson(init: PersonBuilder.() -> R): Person
        where R : Named,
              R : Aged,
              R : PersonBuilder =
    PersonBuilder().run(init).build()

fun <R> itPerson(init: (PersonBuilder) -> R): Person
        where R : Named,
              R : Aged,
              R : PersonBuilder =
    newPerson(init)

优点:

    与 DSL 兼容。 在指定姓名和年龄之前,无法构建一个人。 必须只指定已更改和必需的接口。 (name 函数中没有提到 Aged。) 可以轻松添加新属性。 部分构建的对象可以安全地保存到变量并传递给函数。 属性可以在赋值后使用。

缺点:

    不能在 DSL 中使用带有接收器的 Lambda,因为 Kotlin 不会推断 this 引用的类型。 可以重新分配属性。 where 子句中的样板代码。 无法明确指定变量类型(PersonBuilder &amp; Named 不是有效的 Kotlin 语法)。 _name_age 属性不能是私有的,因为它们应该可以从扩展函数中访问。

这是这个构建器的用法示例:

newPerson 
    age(21)
    name("Bob")
    this // doesn't compile (this type isn't inferred)

itPerson 
    it.age(21)
    it.name("Ann")
    it

itPerson 
    it.age(21)
    it // doesn't compile

val builder = PersonBuilder()
builder.name("Bob")
builder.build() // doesn't compile
builder.age(21)
builder.build()

有没有更好的 null-safe builder 实现,有没有办法摆脱我的实现缺点?

【问题讨论】:

您希望进行编译时检查还是运行时检查? 【参考方案1】:

我认为合同不适合您的问题,而构建器“组合”可能适合。

我的建议:

class PersonBuilder(private val name: String, private val age: Int) 
    fun build() = Person(name, age)


class PersonNameBuilder(private val name: String) 

    fun withAge(age: Int) = PersonBuilder(name, age)


class PersonAgeBuilder(private val age: Int) 

    fun withName(name: String) = PersonBuilder(name, age)


data class Person(val name: String, val age: Int)

用例:

PersonNameBuilder("Bob").withAge(13).build() 
PersonAgeBuilder(25).withName("Claire").build()

PersonNameBuilder("Bob") // can't build(). Forced to add age!
PersonAgeBuilder(25) // can't build(). Forced to add name!

优点:

    在指定姓名和年龄之前,无法构建一个人 无法重新分配属性。 部分构建的对象可以安全地保存到变量并传递给函数 流畅的界面 非常容易扩展、更改、重构,例如使用 labdas 和惰性执行 DSL 可以轻松搞定 如果丰富了 labdas 以在后台调用或执行某些东西 - 非常容易测试,因为它在自己的单个类中 如果需要,可以添加泛型

缺点:

    只有一个属性/字段的样板代码/类 接收器类必须知道一个特定的(不同的)类而不是一个。

【讨论】:

在您添加更多字段之前,您的解决方案非常棒。例如,如果添加 gender 字段,则不仅要添加 PersonGenderBuilder,还要添加 PersonNameAgeBuilderPersonNameGenderBuilderPersonAgeGenderBuildern 字段需要 2^n - 1 个类。这正是我在设计中试图避免的。 True - 有很多代码。我想到的另一个解决方法是定义一个 Map 来存储字段“设置”状态。然后,每个 setter 都会首先询问地图是否已经设置了该值。这会产生很多 setter,我们在运行时看不到它,但我们避免使用新类 我不太明白你的意思。无论如何,通过使用MutableMap&lt;Any?, Boolean&gt;(或MutableSet&lt;Any?&gt;,您只能实现运行时验证,并且我更愿意在构建器使用不正确的情况下获得编译时错误。注意:最好使用属性委托而不是地图。

以上是关于如何强制客户端代码使用合约初始化 Kotlin 中所有必需的构建器字段?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 kotlin 在 android 中初始化小部件

第一行代码:以太坊-使用Solidity语言开发和测试智能合约

如何在 Kotlin 类中声明一个公共值 (val) 以便稍后对其进行初始化。在这种特殊情况下不能使用 var

强制使用以太币发送交易

如何使用 Kotlin 同时定位 JVM/Native 和 Android

如何在伴随对象中覆盖 kotlin 接口