Thursday, 6 October 2022

Kotlin: Data classes & componentN methods

G'day:

I needed to use a "data class" in my article last night ("Data-driven tests in JUnit and Kotest (and starting with TestBox & PHPUnit)" (see the TestCase class in the Kotest example), and I had no idea what one was, so I figured I should check this out today. TBH there's not much to these so I'm gonna quote a bit from the docs and show the tests that GitHub Copilot wrote for me, and… erm probably offer a bit of editorial comment I guess.

Oh, hey: I only mention Copilot because it did an amazing job of working out what I wanted to do with very little input, so I thought I'd use its code this evening. This is not good for me learning as I need to write the code to absorb whatever it is I am writing the code to do.

So what's a data class. I'll pinch some ofthe docs ("[cough]fair use[cough]"):

It is not unusual to create classes whose main purpose is to hold data. In such classes, some standard functionality and some utility functions are often mechanically derivable from the data.

I guess they're just second-guessing common usage patterns, and helping out. A data class has a bunch of method implicitly created, and that's best seen in some tests. These are the tests Copilot wrote for me:

class DataClassTest : DescribeSpec({
    describe("Data classes") {
        data class Person(val name: String, val age: Int)
        
        val person = Person("John", 30)

        it("should have a toString() method") {
            person.toString() shouldBe  "Person(name=John, age=30)"
        }

        it("should have a copy() method") {
            person.copy() shouldBe person
            person.copy(name = "Jane") shouldBe Person("Jane", 30)
            person.copy(age = 31) shouldBe Person("John", 31)
        }

        it("should have a componentN() method") {
            val (name, age) = person
            name shouldBe "John"
            age shouldBe 30
        }

        it("should have an equals() method") {
            person shouldBe Person("John", 30)
            person shouldNotBe  Person("Jane", 30)
            person shouldNotBe Person("John", 31)
        }

        it("should have a hashCode() method") {
            person.hashCode() shouldBe  -1781121024
        }
    }
})

IntelliJ created the class DataClassTest {} bits, and I typed the bits indicated, and Copilot did everything else. Yeah. I typed in "Desc" and Copilot nailed the toString, copy and componentN method tests, and then I needed to prompt it with "it("should have an ", and it went "oh you want tests for these other two as well: okeydoke". Obviously it's not noticed my penchant for using Maori words or notable NZ women as my test data, but it's done a good job. Well: I dunno about the hashCode test - it's a bit "hard-code-y" - but I can't come up with a better one (update: and indeed by the time I had finished writing all this, the test was failing cos the hashcode had changed, so I ditched that test).

The only interesting method in this lot is the componentN one. Kotlin allows destructuring declarations as per the test, ie: val (name, age) = person sets both variables name and age from the person object. To do this the object needs componentN methods. Note that this doesn't mean there's a method called componentN. It means there are methods component1, component2 (etc). In my case just those two. Here's another test to demonstrate that:

it("should have discrete componentN() methods") {
    person.component1() shouldBe "John"
    person.component2() shouldBe 30
}

If I try to write component3, I get told off by IntelliJ and the compiler:

Note also though that Copilot did its best to come up with an expectation for me. Also Copilot wrote the rest of the test for me:

I wanted to check if I could implement concrete componentN methods, so started typing again: "it("is possible to hand-crank one's own componentN methods?"), and Copilot did the rest:

it("is possible to hand-crank one's own componentN methods?") {
    class Person(val name: String, val age: Int) {
        operator fun component1() = name
        operator fun component2() = age
    }
    val person = Person("John", 30)
    val (name, age) = person
    name shouldBe "John"
    age shouldBe 30
}

Not that this time it's just a normal class, not a data class specifically. For completeness I want to make sure this isn't all smoke and mirrors, and actually have my own bespoke logic in the componentN methods. Copilot helps again, understanding 50% of what I'm asking for:

It's showing the ordering test working: the properties of the class are defined as name, age, and the component1 and component2 methods are the other way around. Cool. But it didn't get what I meant about customised logic. But fair-dos: it can't know what logic I want. I'll tweak it:

it("can have customised ordering and logic in the componentN methods") {
    class Person(val name: String, val age: Int) {
        operator fun component1() = "Age: $age"
        operator fun component2() = "Name: $name"
    }
    val person = Person("John", 30)
    val (age, name) = person
    name shouldBe "Name: John"
    age shouldBe "Age: 30"
}

All good. OK, so can I have more componentN methods than I have properties?

it("can have customised ordering and logic in the componentN methods") {
    class Person(val name: String, val age: Int) {
        operator fun component1() = "Age: $age"
        operator fun component2() = "Name: $name"
        operator fun component3() = "Name: $name, Age: $age"
    }
    val person = Person("John", 30)
    val (age, name, both) = person
    name shouldBe "Name: John"
    age shouldBe "Age: 30"
    both shouldBe "Name: John, Age: 30"
}

Yes. Yes it can. Copilot came up with the suggested implementation of component3 btw. And if I started typing in a component4 (well: I only needed to type "operator "), it also had a suggestion for that (operator fun component4() = "Age: $age, Name: $name"). In the context it's in, that's a sensible suggestion I reckon.

Right that's another short one. And a bit more about Copilot than I expected it to be, but hey. I think the componentN method stuff was interesting.

The code for this is @ /src/test/kotlin/kotest/language/classes/DataClassTest.kt.


Update on 2022-10-07 (the following day). I remembered I was supposed to be practising JUnit tests not Kotest ones, so I sat down to reimplement these same tests using JUnit-style instead:

@DisplayName("Tests of data classes")
internal class DataClassTest {

    @Test
    @DisplayName("should have a toString() method")
    fun testToString() {
        data class Person(val name: String, val age: Int)
        val person = Person("John", 30)
        assertEquals("Person(name=John, age=30)", person.toString())
    }

    @Test
    @DisplayName("should have a copy() method")
    fun testCopy() {
        data class Person(val name: String, val age: Int)
        val person = Person("John", 30)
        assertEquals(person, person.copy())
        assertEquals(Person("Jane", 30), person.copy(name = "Jane"))
        assertEquals(Person("John", 31), person.copy(age = 31))
    }

    @Test
    @DisplayName("should have a componentN() method")
    fun testComponentN() {
        data class Person(val name: String, val age: Int)
        val person = Person("John", 30)
        val (name, age) = person
        assertEquals("John", name)
        assertEquals(30, age)
        assertEquals("John", person.component1())
    }

    @Test
    @DisplayName("should have discrete componentN() methods")
    fun testDiscreteComponentN() {
        data class Person(val name: String, val age: Int)
        val person = Person("John", 30)
        assertEquals("John", person.component1())
        assertEquals(30, person.component2())
    }

    @Test
    @DisplayName("should have an equals() method")
    fun testEquals() {
        data class Person(val name: String, val age: Int)
        val person = Person("John", 30)
        assertEquals(Person("John", 30), person)
        assertEquals(Person("Jane", 30), person.copy(name = "Jane"))
        assertEquals(Person("John", 31), person.copy(age = 31))
    }

    @Test
    @DisplayName("is possible to hand-crank one's own componentN methods?")
    fun testHandCrankedComponentN() {
        class Person(val name: String, val age: Int) {
            operator fun component1() = name
            operator fun component2() = age
        }
        val person = Person("John", 30)
        val (name, age) = person
        assertEquals("John", name)
        assertEquals(30, age)
    }

    @Test
    @DisplayName("can have customised ordering and logic in the componentN methods")
    fun testCustomisedComponentN() {
        class Person(val name: String, val age: Int) {
            operator fun component1() = "Age: $age"
            operator fun component2() = "Name: $name"
            operator fun component3() = "Name: $name, Age: $age"
        }
        val person = Person("John", 30)
        val (age, name, nameAge) = person
        assertEquals("Age: 30", age)
        assertEquals("Name: John", name)
        assertEquals("Name: John, Age: 30", nameAge)
    }
}

I have indicated the bits of code that I wrote (IntelliJ populated the baseline class skeleton for me). Copilot did the rest. It worked out I was doing a JUnit version of the existing Kotest tests! Amazing.

This file is on GitHub @ /src/test/kotlin/junit/language/classes/DataClassTest.kt.


Righto.

--
Adam