On JetBrains open day 2019 it was said that the Kotlin team researched contracts and tried to implement context contracts which allow calling a function only in some context, for example, a function build
is allowed to be called only if setName
method was called exactly one time before it. Here is a talk recording.
I've tried to emulate such contracts with currently available Kotlin features to create null-safe builder for data class Person(val name: String, val age: Int)
.
Note: of course, in this case, it's a lot easier to use named arguments instead of a builder pattern, but named arguments don't allow to parse not fully built object to other functions and it's hard to use them when you want to create a complex object which consists of other complex objects and so on.
So here are my null-safe builder implementations:
Generic flags based 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)
Pros:
- A person can't be built until both
name
andage
are specified. - Properties can't be reassigned.
- A partly built object can be safely saved to a variable and passed to a function.
- Functions can specify a required state of the builder and state which will be returned.
- Properties can be used after assignment.
- Fluent interface.
Cons:
- This builder can't be used with DSL.
- New property can't be added without adding type parameter and breaking all existing code.
- All generics must be specified each time (even if a function doesn't care about
age
, it must declare that it accepts a builder with anyAGE
type parameter and return a builder with the same type parameter.) _name
and_age
properties can't be private because they should be accessible from extension functions.
Here is this builder usage example:
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)
Contracts based builder
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)
Pros:
- Compatible with DSL.
- A person can't be built until both name and age are specified.
- Only changed and required interfaces must be specified. (there is no
Aged
mention inname
function.) - New properties can be added easily.
- A partly built object can be safely saved to a variable and passed to a function.
- Properties can be used after assignment.
Cons:
- Lambdas with receivers can't be used in DSL because Kotlin doesn't infer the type of
this
reference. - Properties can be reassigned.
- Boilerplate code in the
where
clause. - Variables type can't be specified explicitly (
PersonBuilder & Named
isn't a valid Kotlin syntax). _name
and_age
properties can't be private because they should be accessible from extension functions.
Here is this builder usage example:
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()
Is there any better implementation of a null-safe builder and is there any way to get rid of my implementations cons?