Hi, new to Kotlin here! I am wondering if Kotlin provides a type-safe way to construct an object whose properties are val
s by providing only a dynamically loaded list of parameters to override, while leaving the others as their defaults. I provide one possible solution below, but I would love to hear improvements or alternative approaches.
To demonstrate what I am talking about, consider the following class (ignoring data classes to simplify the discussion):
class Settings(
text1:String = "aaaa"
text2:String = "bbbb"
text3:String = "cccc"
num1:Int = 1,
num2:Int = 2,
num3:Int = 3) {
val text1:String = text1
val text2:String = text2
val text3:String = text3
val num1:Int = num1
val num2:Int = num2
val num3:Int = num3
}
In an application, we might want to populate such an object from a variety of sources: configuration files, user input, an API call, etc. So, we want to keep the default values in one place (presumably the constructor arguments, where the caller can see them) rather than spreading them throughout the application. So far, so good.
However, say that there are many properties, and that typically only a few overrides to the defaults are provided dynamically at runtime. The question is, what is the best way to construct an instance of this class that satisfies the following:
- maintain strict type safety
- use
val
s for the properties to make the settings immutable - keep the default property values all in one place
- require the caller to provide only the properties that they want to override
Kotlin provides a multitude of ways to satisfy three of the above conditions, but covering all four seems a bit difficult. Here is the best approach that I have come up with so far:
import kotlin.reflect.KProperty1
class SetterPair<TClass, Data>(val prop:KProperty1<TClass, TData>, val value:TData)
fun <TClass, TData, TProp:KProperty1<TClass, TData>> propSetterFromValue(prop:TProp)
: (TData) -> PropValue<TClass, TData>
= { value:TData -> SetterPair<TClass, TData>(prop, value) }
class Settings(
vararg setters:SetterPair<Settings, *>,
text1:String = "aaaa"
text2:String = "bbbb"
text3:String = "cccc"
num1:Int = 1
num2:Int = 2
num3:Int = 3) {
val text1:String
val text2:String
val text3:String
val num1:Int
val num2:Int
val num3:Int
init {
fun <TData, TProp:KProperty1<Settings, TData>>
setterOrDefault(prop:TProp, default:TData)
: TData {
return (setters.firstOrNull{ it.prop == prop }?.value as TData?) ?: defaultValue
}
this.text1 = setterOrDefault(Settings::text1, text1)
this.text2 = setterOrDefault(Settings::text2, text2)
this.text3 = setterOrDefault(Settings::text3, text3)
this.num1 = setterOrDefault(Settings::num1, num1)
this.num2 = setterOrDefault(Settings::num2, num2)
this.num3 = setterOrDefault(Settings::num3, num3)
}
override fun toString(): String = "{text1:${text1}; text2:${text2}; text3:${text3}; num1:${num1}; num2:${num2}; num3:${num3}}"
}
fun main() {
val defaultSettings = Settings()
val setter1 = propSetterFromValue(Settings::text2)("zzzz")
val setter2 = propSetterFromValue(Settings::num3)(-1)
val customSettings = Settings(setter1, setter2)
print("$defaultSettings\n")
print("$customSettings\n")
}
This approach satisfies three of the four conditions that I mentioned above and partially satisfies the fourth. It stores the properties as val
s, it keeps the property defaults in the constructor arguments, and it requires the caller to specify only the properties that they want to change from their defaults.
On the matter of type-safety, this implementation does prevent the following mistakes from compiling:
val badSetter = propSetterFromValue(Settings::text2)(-1)
this.text2 = setterOrDefault(Settings::text2, num3)
The curried form for building SetterPair
s is done to prevent the typechecker from seeing a mismatched KProperty1
and TData
and inferring TData
as Any
/Any?
: by inferring the type from the KProperty1
alone first, it will infer the specific datatype that we want to assign. However, if the caller intentionally specifies the type argments, they could short-circuit this logic:
val badSetter = propSetterFromValue<Settings, Any?, KProperty1<Settings, Any?>>(Settings::text2)(-1)
The preceding line will compile, but will result in a runtime error. I don’t see an obvious way to prevent this issue. I understand the difficulty to be that the second type argument to KProperty
is an out
type, so a KProperty1<SomeClass, Any?>
is fundamentally assignable to a KProperty1<SomeClass, SomeData>
for any value of SomeData
. Any ideas on how to close this loophole?
That failing aside, this implementation isn’t perfect. I was able to keep the caller side fairly simple, but it could be simpler. Also, it would be nice if it wasn’t possible for the caller to accidentally specify the same property twice. Finally, if two properties have the same type, then the implementer of the Settings.init
method could mistakenly assign the value or default of one parameter to the other; it would be nice if they were more strongly bound.
Any ideas for alternative solutions or feedback on mine are welcome. Thanks!