Reputation: 5356
Im investigating Kotlin DSLs following these examples:-
https://github.com/zsmb13/VillageDSL
Im am interested in how to enforce usage rules on all attributes exposed by the DSL.
Taking the following example:-
val v = village {
house {
person {
name = "Emily"
age = 31
}
person {
name = "Jane"
age = 19
}
}
}
I would like to enforce a rule which stops users of the DSL being able to enter duplicate attributes as shown below
val v = village {
house {
person {
name = "Emily"
name = "Elizabeth"
age = 31
}
person {
name = "Jane"
age = 19
age = 56
}
}
}
I've tried with Kotlin contracts e.g.
contract { callsInPlace(block, EXACTLY_ONCE) }
However these are only allowed in top level functions and I could not see how to employ a contract when using following the Builder pattern in DSLs, e.g.
@SimpleDsl1
class PersonBuilder(initialName: String, initialAge: Int) {
var name: String = initialName
var age: Int = initialAge
fun build(): Person {
return Person(name, age)
}
}
Is it possible to achieve my desired effect of enforcing the setting of each attribute only one per person?
Upvotes: 11
Views: 1017
Reputation: 1368
While writing this, I noticed that the Bug I referenced in my comments has been fixed, so I continued down that code path only to realize, there is a language limitation, at the bottom I will include an example of what I meant in the comments.
Both of these examples would be failures at runtime.
fun test_person() {
val village = village {
house {
person {
name = "Emily"
// ::name setTo "Emily" // Commented for 2nd example of Person
age = 31
}
person {
name = "Jane"
age = 19
}
}
house {
person {
name = "Tim"
// name = "Tom" // Will break with exception
age = 20
}
}
}
println("What is our village: \n$village")
}
The runtime breaking example that works with exceptions:
class Village {
val houses = mutableListOf<House>()
fun house(people: House.() -> Unit) {
val house = House()
house.people()
houses.add(house)
}
override fun toString(): String {
val strB = StringBuilder("Village:\n")
houses.forEach { strB.append(" $it \n") }
return strB.toString()
}
}
fun village(houses: Village.() -> Unit): Village {
val village = Village()
village.houses()
return village
}
class House {
val people = mutableListOf<Person>()
fun person(qualities: Person.() -> Unit) {
val person = Person()
person.qualities()
people.add(person)
}
override fun toString(): String {
val strB = StringBuilder("House:\n")
people.forEach{ strB.append(" $it \n")}
return strB.toString()
}
}
class Person {
var age by SetOnce<Int>()
var name by SetOnce<String>()
override fun toString(): String {
return "Person: { Name: $name, Age: $age }"
}
}
class SetOnce <T> : ReadWriteProperty<Any?, T?> {
private var default: T? = null
override fun getValue(thisRef: Any?, property: KProperty<*>): T? = default
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) {
if (default != null) throw Exception("Duplicate set for ${property.name} on $thisRef")
else default = value
}
}
The Non-working example that was intended to use lateinit properties to control the setting of a value once, but you cannot use a reference, it must be the literal ::foo
syntax. Like I said, I didn't realize that bug was fixed, and did not know this was a limitation in the language
class Person {
lateinit var name: String
lateinit var age: Number // Number because primitives can't be lateinit
/** @throws Exception when setting a property a second time */
infix fun <T> KMutableProperty0<T>.setTo(value: T) {
val prop = getProp<T>(this.name)
if (prop.isInitialized.not()) this.set(value)
else throw Exception("Duplicate set for ${this.name}")
}
private fun <T> getProp(name: String): KMutableProperty0<T> {
return when(name) {
"name" -> ::name
"age" -> ::age
else -> throw Exception("Non-existent property: $name")
} as KMutableProperty0<T>
}
}
As contracts mature and the rules are relaxed, we could potentially write something like:
@OptIn(ExperimentalContracts::class)
infix fun <T> KMutableProperty0<T>.setTo(value: T) {
contract { returns() implies [email protected] }
this.set(value)
}
Which would give us the capability to move this all to IDE errors, but we're not there yet sadly.
Upvotes: 2
Reputation: 7604
I found a hacky way to do something similar, but then it turned out infix functions won't work here because of this bug. When it does get fixed, this solution should be okay.
You could make your DSL look like this, but unfortunately, your first set
call can't be infix :( because then name
cannot be smartcasted to SetProperty<*>
(see the bug report above).
val emily = person {
name.set("Emily")
name.set("Elizabeth") //Error here
age.set(31)
age set 90 //Won't work either
}
The error that pops up (for name.set("Elizabeth")
) is:
Type inference failed: Cannot infer type parameter T in inline infix fun <reified T> Property<T>.set(t: T): Unit
None of the following substitutions
receiver: Property<CapturedTypeConstructor(out Any?)> arguments: (CapturedTypeConstructor(out Any?))
receiver: Property<String> arguments: (String)
can be applied to
receiver: UnsetProperty<String> arguments: (String)
The code behind it:
@OptIn(ExperimentalContracts::class)
infix fun <T> Property<T>.set(t: T){
contract { returns() implies (this@set is Prop<*>) }
this.setData(t)
}
interface Property<T> {
fun data(): T
fun setData(t: T)
}
interface UnsetProperty<T> : Property<T>
open class SetProperty<T>(val name: String) : Property<T> {
private var _data: T? = null
override fun data(): T { return _data ?: throw Error("$name not defined") }
override fun setData(t: T) {
if (_data == null) _data = t
else throw Error("$name already defined")
}
}
class Prop<T>(name: String = "<unnamed property>") : SetProperty<T>(name), UnsetProperty<T>
class PersonBuilder {
val name: Property<String> = Prop("name")
val age: Property<Int> = Prop("age")
fun build(): Person = Person(name.data(), age.data())
}
fun person(f: PersonBuilder.() -> Unit): Person {
val builder = PersonBuilder()
builder.f()
return builder.build()
}
data class Person(val name: String, val age: Int)
I'm not sure exactly why this works/doesn't work, but it seems that because T
is invariant in Property
, it can't determine what exactly it is.
However, it would be much easier and safer to just use named arguments for your person
function and make house
, village
, etc. have variadic parameters.
Upvotes: 2
Reputation: 1676
Unfortonate that you cannot use contracts to get the compilation error you are looking for. I do not think they are intended for the purpose you are tying here... but I might be wrong. To me they are hints to the compiler about things like nullability and immutability. Even if you were able to use them as you wished, I do not think you would get the compilation error you are looking for.
But a second place solution would be to have an Exception at runtime. Property delegates could provide you with a nice reusable solution for this. Here it is with some modification to your example.
class PersonBuilder {
var name: String? by OnlyOnce(null)
var age: Int? by OnlyOnce(null)
fun build(): Person {
name?.let { name ->
age?.let { age ->
return Person(name, age)
}
}
throw Exception("Values not set")
}
}
class OnlyOnce<V>(initialValue: V) {
private var internalValue: V = initialValue
private var set: Boolean = false
operator fun getValue(thisRef: Any?, property: KProperty<*>): V {
return internalValue
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: V) {
if (set) {
throw Exception("Value set already")
}
this.internalValue = value
this.set = true
}
}
fun person(body: PersonBuilder.() -> Unit) {
//do what you want with result
val builder = PersonBuilder()
builder.body()
}
fun main() {
person {
name = "Emily"
age = 21
age = 21 // Exception thrown here
}
}
Upvotes: 7