Friday 14 October 2022

Kotlin: looking at delegated properties

G'day:

In my "Kotlin/Gradle: abstracting versions into a config file, and wondering what delegated properties are" article from a few days ago, I landed upon some syntax in my build.gradle.kts file that I didn't quite get:

val kotestVersion: String by project
val systemLambdaVersion: String by project
val commonsLang3Version: String by project
// …

I wasn't so sure what that by project jazz was all about. project is just an object as it turns out - the properties of which are defined in gradle.properties - and this by thing is something to do with "Delegated properties. Let's have a look at those.

The intro in the docs there say this:

With some common kinds of properties, even though you can implement them manually every time you need them, it is more helpful to implement them once, add them to a library, and reuse them later.

Erm: fine. But I am none the wiser. I'll try some code. Well: I'll get Copilot to try some code, as it turns out.


Property delegates

Here's a test

fun `It can delegate the Maori translation of a word`() {
    val one = Number(1)
    one.mi shouldBe "tahi"
    one.en shouldBe "one"
}

Different from my usual test Number classes, this one only sets the digit. The rest… gets done for me. How?

class Number(val id: Int) {
    val mi: String by MaoriNumber()
    val en: String by EnglishNumber()
}
class MaoriNumber {
    operator fun getValue(thisRef: Number, property: kotlin.reflect.KProperty<*>): String {
        return when (thisRef.id) {
            1 -> "tahi"
            2 -> "rua"
            3 -> "toru"
            4 -> "wha"
            5 -> "rima"
            6 -> "ono"
            7 -> "whitu"
            8 -> "waru"
            9 -> "iwa"
            10 -> "tekau"
            else -> "unknown"
        }
    }
}

(EnglishNumber is the same, just in English).

I've specified the getValue function as an operator because the docs told me to, btw. Well in fact Copilot wrote 95% of that for me, and then I checked back with the docs to make sure it nailed it. Copilot wanted MaoriNumber to extend Number, for some reason, which is unnecessary so I removed that. It did everything else. It makes sense: it receives the object it's a delegate for, and the property of the object it's a delegate for, looks up the id value in its internal map and returns the value.

Next I want to throw an exception for unsupported values, instead of returning "unknown". I have NFI how to do this, so I am gonna encourage Copilot to help out.

fun `It throws an exception when the Maori translation is not found`() {
    val eleven = Number(11)
    shouldThrow<IllegalArgumentException> {
        eleven.mi
    }.message shouldBe "No Maori translation for 11"
}

I had to type as far as "It throws and exception", then Copilot did the rest. Except it did it for "2" not "11", which was a slight disappointment. The code supports "2".

The key thing to notice here is that the exception doesn't get thrown until the relevant property is accessed; not when the object is first created. mi is not initialised until it's needed.

The update for the delegate is reasonably obvious:

7 -> "whitu"
8 -> "waru"
9 -> "iwa"
10 -> "tekau"
else -> throw IllegalArgumentException("No Maori translation for ${thisRef.id}")

Copilot handled it after "throw", but did not work out to correlate the message back to the test. This is fine. Let's see if it nails the English version now that I've explained stuff better:

7 -> "seven"
8 -> "eight"
9 -> "nine"
10 -> "ten"
else -> throw IllegalArgumentException("No English translation for ${thisRef.id}")

Nailed it. It could not work out that I wanted to include an expectation for this in the test, so I hand-cranked that.

That's quite cool.

Seems there are some more specialised delegate types as well…


Lazy properties

A property that delegates to lazy will use lazy's lambda to initialise the property first time round,

@Test
fun `It is only initialise when accessed`() {
    class TestClass {
        val lazyProperty: String by lazy {
            telemetry.add("lazyProperty initialised")
            "lazyProperty"
        }
        val telemetry: MutableList<String> = mutableListOf("initialising telemetry")
    }

    val testClass = TestClass()
    testClass.telemetry.first() shouldBe "initialising telemetry"
    testClass.lazyProperty shouldBe "lazyProperty"
    testClass.telemetry.last() shouldBe "lazyProperty initialised"
    testClass.telemetry.size shouldBe 2
    testClass.lazyProperty shouldBe "lazyProperty"
    testClass.telemetry.size shouldBe 2
}

Note for this:

  • Lazy properties cannot be mutable, so have to be vals, and obviously can't have a setter. If I try I get this error in IntelliJ:

    Type 'Lazy<TypeVariable(T)>' has no method 'setValue(DelegatedPropertiesTest.LazyProperties.TestClass, KProperty<*>, String)' and thus it cannot serve as a delegate
  • The telemetry tells the story here. Only the first access to lazyProperty's getter actually executes the code in the lazy lambda: there is no second telemetry entry for the second access to it.

I've augmented this test in the version of this code in source control, but whilst it demonstrates some stuff, this first iteration is clearer, hence using this one.

This next test shows that if the lazy initialisation code fails, it will re-try subsequent times:

@Test
fun `It can throw an exception on initialisation but will continue to attempt to initialise the property on subsequent calls`() {
    class TestClass {
        private var initAttempts = 0

        val lazyProperty: String by lazy {
            initAttempts++
            telemetry.add("lazyProperty initialisation called ($initAttempts)")
            if (initAttempts <= 2) {
                throw IllegalStateException("lazyProperty initialisation failed ($initAttempts)")
            }
            telemetry.add("lazyProperty initialisation succeeded ($initAttempts)")
            "lazyProperty"
        }
        val telemetry: MutableList<String> = mutableListOf("initialising telemetry")
    }

    val testClass = TestClass()
    testClass.telemetry[0] shouldBe "initialising telemetry"

    shouldThrow<RuntimeException> {
        testClass.lazyProperty
    }.message shouldBe "lazyProperty initialisation failed (1)"

    testClass.telemetry.size shouldBe 2
    testClass.telemetry[1] shouldBe "lazyProperty initialisation called (1)"

    shouldThrow<RuntimeException> {
        testClass.lazyProperty
    }.message shouldBe "lazyProperty initialisation failed (2)"
    testClass.telemetry.size shouldBe 3
    testClass.telemetry[2] shouldBe "lazyProperty initialisation called (2)"

    shouldNotThrow<RuntimeException> {
        testClass.lazyProperty
    }
    testClass.telemetry.size shouldBe 5
    testClass.telemetry[3] shouldBe "lazyProperty initialisation called (3)"
    testClass.telemetry[4] shouldBe "lazyProperty initialisation succeeded (3)"

    shouldNotThrow<RuntimeException> {
        testClass.lazyProperty
    }
    testClass.telemetry.size shouldNotBeGreaterThan 5
    testClass.telemetry.last() shouldBe "lazyProperty initialisation succeeded (3)"
}

This looks like a lot of code, but there's not much to it.

  • The lazy initialisation lambda throws an exception the first two times it's called.
  • After that, it initialises the value successfully.
  • The test calls the getter 1, 2, 3, 4 times, and checks what telemetry has been logged each time.
  • After the third call (which was successful), the lambda is not called again, ie: there is no further telemetry since the first successful initialisation.

Observable properties

This sounded interesting to me when I saw the heading ("Ooh! Baked-in Observer Pattern!"), but I was underwhelmed by the implementation.

inner class TestClass {
    var observableProperty: String by Delegates.observable("initial value") {
        property, oldValue, newValue ->
        telemetry.add("${property.name} changed from $oldValue to $newValue")
    }
    val telemetry: MutableList<String> = mutableListOf("initialising telemetry")
}

Test:

@Test
fun `It shows that observable properties can be used to track changes to a property`() {
    val testClass = TestClass()

    testClass.telemetry.first() shouldBe "initialising telemetry"
    testClass.observableProperty shouldBe "initial value"
    testClass.telemetry.size shouldBe 1

    testClass.observableProperty = "new value"
    testClass.telemetry.size shouldBe 2
    testClass.telemetry[1] shouldBe "observableProperty changed from initial value to new value"
    testClass.observableProperty shouldBe "new value"

    testClass.observableProperty = "newer value"
    testClass.telemetry.size shouldBe 3
    testClass.telemetry[2] shouldBe "observableProperty changed from new value to newer value"
    testClass.observableProperty shouldBe "newer value"

    testClass.observableProperty = "newer value"
    testClass.telemetry.size shouldBe 4
    testClass.telemetry[3] shouldBe "observableProperty changed from newer value to newer value"
    testClass.observableProperty shouldBe "newer value"
}

So… you can have a setter method. I… already knew that. How does that observableProperty property differ from this:

var standardProperty : String = "initial value"
    set(value) {
        telemetry.add("standardProperty changed from $field to $value")
        field = value
    }

I got Copilot to write the test for me, and it's the same as the observableProperty one :-(

To be clear, that seems all there is to it. One can only "attach" that single listener to the "observable" property. There's no mechanism to go observableProperty.attach({property, oldValue, newValue -> /* stuff here */}) to attach more handlers later, which would be handy.

Hopefully I'm missing something, but googling about, I get the same vibe from other people too.


Vetoable properties

These were only mentioned in passing on that previous docs page I had been looking at. I only found out about them because I asked Copilot "anything else about delegated properties I should know about?", and it autofilled something about vetoable properties, so did a google.

class TestClass {
    var vetoableProperty: String by Delegates.vetoable("initial value") {
        property, oldValue, newValue ->
        telemetry.add("${property.name} changed from $oldValue to $newValue")
        newValue != "veto value"
    }
    val telemetry: MutableList<String> = mutableListOf("initialising telemetry")
}

Here if the lambda returns false, the property doesn't actually get updated:

val testClass = TestClass()
testClass.telemetry.first() shouldBe "initialising telemetry"
testClass.vetoableProperty shouldBe "initial value"
testClass.telemetry.size shouldBe 1

testClass.vetoableProperty = "new value"
testClass.telemetry.size shouldBe 2
testClass.telemetry[1] shouldBe "vetoableProperty changed from initial value to new value"
testClass.vetoableProperty shouldBe "new value"

testClass.vetoableProperty = "veto value"
testClass.telemetry.size shouldBe 3
testClass.telemetry[2] shouldBe "vetoableProperty changed from new value to veto value"
testClass.vetoableProperty shouldBe "new value"

testClass.vetoableProperty = "newer value"
testClass.telemetry.size shouldBe 4
testClass.telemetry[3] shouldBe "vetoableProperty changed from new value to newer value"
testClass.vetoableProperty shouldBe "newer value"

See how after trying to set it to "veto value", the value was not updated from the previous one.


Setting properties from a map

Probably just the tests speak for themselves here, as I think it's pretty obvious what's going on, and why/how:

fun `It shows that map properties can be used to access a map by key`() {
    class Number(val map: Map<String, Any?>) {
        val id: Int by map
        val en: String by map
        val mi: String by map
    }

    val number = Number(
        mapOf(
            "id" to 1,
            "en" to "one",
            "mi" to "tahi"
        )
    )
    number.id shouldBe 1
    number.en shouldBe "one"
    number.mi shouldBe "tahi"
}
@Test
fun `It shows that map properties can be used to access a map by key with a default value from a function`() {
    class Number(val map: Map<String, Any?>) {
        val id: Int by map
        val en: String by map
        val mi: String by map
        val ga: String by map.withDefault { key -> "unknown $key" }
    }

    val number = Number(
        mapOf(
            "id" to 1,
            "en" to "one",
            "mi" to "tahi"
        )
    )
    number.id shouldBe 1
    number.en shouldBe "one"
    number.mi shouldBe "tahi"
    number.ga shouldBe "unknown ga"
}
@Test
fun `It shows that map properties can be used to access a map by key with a default value from a function that throws an exception`() {
    class Number(val map: Map<String, Any?>) {
        val id: Int by map
        val en: String by map
        val mi: String by map
        val ga: String by map.withDefault { key -> throw IllegalArgumentException("unknown key $key") }
    }

    val number = Number(
        mapOf(
            "id" to 1,
            "en" to "one",
            "mi" to "tahi"
        )
    )
    number.id shouldBe 1
    number.en shouldBe "one"
    number.mi shouldBe "tahi"
    shouldThrow<IllegalArgumentException> {
        number.ga
    }.message shouldBe "unknown key ga"
}

Copilot wrote all that code for me btw. I changed the third language from the French it suggested to Irish.


OK, so that's that. The concept of property delegation is interesting / useful, But I didn't quite "get" why they have bothered with the pre-packaged ones (lazy, observation, vetoable),if I'm honest. I suspect I am missing something still…

The code is on GitHub, here: DelegatedPropertiesTest.kt.

Righto.

--
Adam