15

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:

  1. A person can't be built until both name and age are specified.
  2. Properties can't be reassigned.
  3. A partly built object can be safely saved to a variable and passed to a function.
  4. Functions can specify a required state of the builder and state which will be returned.
  5. Properties can be used after assignment.
  6. Fluent interface.

Cons:

  1. This builder can't be used with DSL.
  2. New property can't be added without adding type parameter and breaking all existing code.
  3. 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 any AGE type parameter and return a builder with the same type parameter.)
  4. _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:

  1. Compatible with DSL.
  2. A person can't be built until both name and age are specified.
  3. Only changed and required interfaces must be specified. (there is no Aged mention in name function.)
  4. New properties can be added easily.
  5. A partly built object can be safely saved to a variable and passed to a function.
  6. Properties can be used after assignment.

Cons:

  1. Lambdas with receivers can't be used in DSL because Kotlin doesn't infer the type of this reference.
  2. Properties can be reassigned.
  3. Boilerplate code in the where clause.
  4. Variables type can't be specified explicitly (PersonBuilder & Named isn't a valid Kotlin syntax).
  5. _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?

IlyaMuravjov
  • 2,352
  • 1
  • 9
  • 27

2 Answers2

0

I dont think that contracts are suitable for your problem, whereas a builder "combination" could fit.

My proposal:

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)

The use cases:

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!

Pros:

  1. A person can't be built until both name and age are specified
  2. Properties can't be reassigned.
  3. A partly built object can be safely saved to a variable and passed to a function
  4. Fluent interface
  5. Very easy to extend, change, refactor, f.e. to use labdas and lazy executions
  6. DSL can be done easily
  7. If enriched with labdas to call or execute something in the background - very easy to test since it's in its own single class
  8. Generics can be added if there is a need

Cons:

  1. Boilerplate code / classes for just one propery / field
  2. Receiver classes have to know a specific (different) class instead of one.
Neo
  • 1,869
  • 1
  • 7
  • 20
  • 1
    Your solution is great until you add a few more fields. For example, if you add `gender` field, you will have to add not only `PersonGenderBuilder` but `PersonNameAgeBuilder`, `PersonNameGenderBuilder` and `PersonAgeGenderBuilder` as well. You need **2^n - 1** classes for **n** fields. This is exactly what I've tried to avoid with my designs. – IlyaMuravjov Sep 30 '19 at 11:42
  • True - to much code. Another workaround I have in mind would be defining a Map<*,Boolean> which would store the field 'set' status. Every setter would then ask first the map if the value was set already. This would produce many setters and we can't see it on runtime, but we avoid new classes – Neo Sep 30 '19 at 11:52
  • 2
    I don't quite get you. Anyway, by using `MutableMap` _(or `MutableSet`)_ you can only achieve runtime validation, and I'd prefer to get a compile-time error in case of incorrect usage of a builder. Note: it's better to use property delegates instead of the map. – IlyaMuravjov Sep 30 '19 at 12:34
  • Sorry, but how can you have (`private`) `val` properties in `PersonBuilder` with DSL? The lambda with `PersonBuilder` receiver has to have an instance of it, which requires `name` and `age` in the first place.. – elect May 31 '22 at 16:36
0

I've been trying to use KSP and KotlinPoet to create a dynamic builder in quite a similar way. The idea is to turn your builder into a state machine in which you have to follow some mandatory steps when creating your object, so as not to skip any important fields. You can have a look at the article and the code I wrote if you're interested.

uzilan
  • 2,554
  • 2
  • 31
  • 46