如何用Kotlin契约强制Builder初始化所有必填字段?求更优实现
Great question! The two implementations you’ve shared each have clear tradeoffs, but we can combine their strengths and fix most of their pain points with a stage-based contract builder that leverages Kotlin’s contracts, inline functions, and constrained generics. This approach addresses private property access, DSL support, duplicate assignment prevention, and generic bloat while keeping compile-time safety.
Improved Implementation: Stage-Based Contract Builder
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract // Define build stage markers as sealed interfaces for strict hierarchy sealed interface PersonBuildStage interface Unnamed : PersonBuildStage interface Named : PersonBuildStage interface NamedAndAged : PersonBuildStage @OptIn(ExperimentalContracts::class) class PersonBuilder<out T : PersonBuildStage> private constructor( private var _name: String? = null, private var _age: Int? = null ) { // Start in the Unnamed stage by default companion object { operator fun invoke(): PersonBuilder<Unnamed> = PersonBuilder() // DSL support with inline function and contract for type inference @OptIn(ExperimentalContracts::class) inline fun build(block: PersonBuilder<Unnamed>.() -> PersonBuilder<NamedAndAged>): Person { contract { callsInPlace(block, kotlin.contracts.InvocationKind.EXACTLY_ONCE) } return invoke().run(block).build() } } // Only callable from Unnamed stage; transitions to Named stage fun name(name: String): PersonBuilder<Named> { contract { returns() implies (this@name is PersonBuilder<Named>) } require(_name == null) { "Name cannot be set more than once" } _name = name @Suppress("UNCHECKED_CAST") // Safe due to stage transition logic return this as PersonBuilder<Named> } // Only callable from Named stage; transitions to completed stage fun age(age: Int): PersonBuilder<NamedAndAged> { contract { returns() implies (this@age is PersonBuilder<NamedAndAged>) } require(_age == null) { "Age cannot be set more than once" } _age = age @Suppress("UNCHECKED_CAST") return this as PersonBuilder<NamedAndAged> } // Only callable from the fully completed stage fun build(): Person { contract { returns() implies (this@build is PersonBuilder<NamedAndAged>) } return Person( name = _name ?: error("Name must be set before building"), age = _age ?: error("Age must be set before building") ) } } data class Person(val name: String, val age: Int)
How This Fixes Your Original Pain Points
Let’s break down how this implementation addresses the flaws in your two approaches:
- Private properties:
_nameand_ageare now private, eliminating external direct access (no more public backing fields) - DSL compatibility: The inline
buildfunction with a contract enables type-safe, receiver-based DSLs—no more issues withthistype inference - No duplicate assignments: Compile-time stage constraints prevent re-calling
name/ageon already set stages, and runtimerequireadds an extra safety net - Controlled generic bloat: Stages use a sealed interface hierarchy instead of separate generic parameters. Adding a new field (like
email) only requires a new stage interface (e.g.,NamedAndAgedAndEmailed) instead of adding another generic type - Compile-time safety: Generic stage constraints + contracts ensure
buildcan only be called after all required fields are set - Minimal boilerplate: No messy
whereclauses—stage constraints are handled directly via generic type bounds
Minor Remaining Tradeoffs
- We still use
@Suppress("UNCHECKED_CAST")for stage transitions, but this is a standard pattern for this kind of builder and doesn’t compromise safety (the contract and runtime checks enforce valid state) - Runtime
requirechecks are redundant for most cases (since the compiler blocks invalid calls) but serve as a safety net for edge cases (like reflection-based access)
Usage Examples
Chained Calls
// Valid: All required fields set before build val bob = PersonBuilder().name("Bob").age(21).build() // Compile error: Cannot call build without setting age // PersonBuilder().name("Bob").build() // Compile error: Cannot re-set name // PersonBuilder().name("Bob").name("Ann")
DSL Calls
// Valid DSL usage val alice = PersonBuilder.build { name("Alice") age(25) } // Compile error: Missing age field // val invalidPerson = PersonBuilder.build { // name("Invalid") // }
Passing Partial Builders
// Function only accepts builders that have already set a name fun finalizeNamedBuilder(builder: PersonBuilder<Named>) { builder.age(30).build() } val namedBuilder = PersonBuilder().name("Charlie") finalizeNamedBuilder(namedBuilder) // Compile error: Cannot pass an Unnamed builder // finalizeNamedBuilder(PersonBuilder())
Bonus: Kotlin 1.7+ Context Receivers
If you’re using Kotlin 1.7 or later, you can simplify stage constraints even further with context receivers, making the code more readable and eliminating explicit generic bounds in function signatures:
@OptIn(ExperimentalContracts::class) context(Unnamed) fun PersonBuilder<*>.name(name: String): PersonBuilder<Named> { contract { returns() implies (this@name is PersonBuilder<Named>) } require(_name == null) { "Name already set" } _name = name @Suppress("UNCHECKED_CAST") return this as PersonBuilder<Named> } context(Named) fun PersonBuilder<*>.age(age: Int): PersonBuilder<NamedAndAged> { contract { returns() implies (this@age is PersonBuilder<NamedAndAged>) } require(_age == null) { "Age already set" } _age = age @Suppress("UNCHECKED_CAST") return this as PersonBuilder<NamedAndAged> } context(NamedAndAged) fun PersonBuilder<*>.build(): Person { return Person(_name!!, _age!!) }
Context receivers make the stage requirements for each function explicit at the declaration site, leading to cleaner, more intuitive code.
内容的提问来源于stack exchange,提问作者IlyaMuravjov

