Как заставить клиентский код инициализировать все необходимые поля компоновщика в Kotlin с контрактами? - PullRequest
4 голосов
/ 29 сентября 2019

В день открытия JetBrains 2019 было сказано, что команда Kotlin исследовала контракты и пыталась реализовать контекст контракты, которые позволяют вызывать функцию только в некотором контексте, например, функция build можетвызываться, только если setName метод был вызван ровно за один раз до него. Здесь - запись разговора.

Я пытался эмулировать такие контракты с доступными в настоящее время функциями Kotlin для создания нуль-безопасного компоновщика для data class Person(val name: String, val age: Int).

Примечание:конечно, в этом случае намного проще использовать именованные аргументы вместо шаблона построителя, но именованные аргументы не позволяют анализировать не полностью построенный объект с другими функциями, и их трудно использовать, когда вы хотите создать сложныйобъект, который состоит из других сложных объектов и т. д.

Итак, вот мои реализации нуль-безопасного компоновщика:

Универсальный компоновщик на основе флагов

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)

Плюсы:

  1. Человек не может быть построен, пока не указаны оба значения name и age.
  2. Свойства не могут быть переназначены.
  3. Частично построенный объект может быть безопасно сохранен в переменной и передан в функцию.
  4. Функции могут определять требуемое состояние построителя и состояние, которое будет возвращено.
  5. Могут использоваться свойствапосле назначения.
  6. Свободный интерфейс.

Минусы:

  1. Этот построитель нельзя использовать с DSL.
  2. Новое свойство не может бытьдобавляется без добавления параметра типа и прерывания всего существующего кода.
  3. Все шаблоны должны указываться каждый раз (даже если функция не заботится о age, она должна объявить, что она принимает компоновщик с любым AGE введите параметр и верните конструктор с тем же параметром типа.) * Свойства 1048 *
  4. _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)

Плюсы:

  1. Совместимо с DSL.
  2. Человек не может быть построен, пока не указаны имя и возраст.
  3. Должны быть указаны только измененные и требуемые интерфейсы.(в функции name нет упоминания Aged.)
  4. Новые свойства могут быть легко добавлены.
  5. Частично построенный объект может быть безопасно сохранен в переменной и передан в функцию.
  6. Свойства могут использоваться после назначения.

Минусы:

  1. Лямбды с приемниками нельзя использовать в DSL, потому что Котлин не делает выводтип this reference.
  2. Свойства могут быть переназначены.
  3. Код шаблона в предложении where.
  4. Тип переменных не может быть указан явно (PersonBuilder & Named не является допустимым синтаксисом Котлина).
  5. _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()

Есть ли лучшая реализация нуль-безопасного компоновщика и есть ли способ избавиться от минусов моих реализаций?

1 Ответ

0 голосов
/ 30 сентября 2019

Я не думаю, что контракты подходят для вашей проблемы, тогда как «комбинация» строителей может подойти.

Мое предложение:

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!

Плюсы:

  1. Человекневозможно построить, пока не указаны имя и возраст
  2. Свойства не могут быть переназначены.
  3. Частично построенный объект может быть безопасно сохранен в переменной и передан в функцию
  4. Свободный интерфейс
  5. Очень прост в расширении, изменении, рефакторинге, например, для использования labdas и ленивых казней
  6. DSL может быть легко выполнен
  7. Если обогащен labdas для вызоваили выполнить что-либо в фоновом режиме - это очень легко проверить, поскольку он находится в своем собственном отдельном классе
  8. Дженерики могут быть добавлены, если есть необходимость

Минусы:

  1. Код / классы Boilerplate только для одного свойства / поля
  2. Классы получателя должны знать определенный (другой) класс вместо одного.
Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...