В день открытия 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)
Плюсы:
- Человек не может быть построен, пока не указаны оба значения
name
и age
. - Свойства не могут быть переназначены.
- Частично построенный объект может быть безопасно сохранен в переменной и передан в функцию.
- Функции могут определять требуемое состояние построителя и состояние, которое будет возвращено.
- Могут использоваться свойствапосле назначения.
- Свободный интерфейс.
Минусы:
- Этот построитель нельзя использовать с DSL.
- Новое свойство не может бытьдобавляется без добавления параметра типа и прерывания всего существующего кода.
- Все шаблоны должны указываться каждый раз (даже если функция не заботится о
age
, она должна объявить, что она принимает компоновщик с любым AGE
введите параметр и верните конструктор с тем же параметром типа.) * Свойства 1048 * _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, потому что Котлин не делает выводтип
this
reference. - Свойства могут быть переназначены.
- Код шаблона в предложении
where
. - Тип переменных не может быть указан явно (
PersonBuilder & Named
не является допустимым синтаксисом Котлина). _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()
Есть ли лучшая реализация нуль-безопасного компоновщика и есть ли способ избавиться от минусов моих реализаций?