Monday 19 September 2022

Kotlin: investigating classes

G'day:

In my other random explorations of the Kotlin language, I've already used simple classes to facilitate testing other language features, but my next koans exercise (man I am progressing slowly with those!) links to the docs page for classes, so I'm gonna have a breeze through that, so I can extend my understanding of how they operate beyond the superficial level I currently have. Anything that intrigues me or is non-obvious, I'll write a test and sling it in here.


Primary and secondary constructors

I touched on this in "Kotlin: another Friday afternoon, another round of random investigation › Primary and secondary constructors", but only in as much as I messed up my syntax for my constructor and IntelliJ fixed it for me.

Primary constructor

This is baked-into the class declaration:

class Suffragist(firstName:String, lastName:String) {
    val fullName = "$firstName $lastName"
}

class ClassesTest : DescribeSpec({
    describe("Constructor tests") {
        it("takes values that can be used in initialisation code") {
            val suffragist = Suffragist("Kate", "Sheppard")

            suffragist.fullName shouldBe "Kate Sheppard"
        }
    }
})

It's important to understand that the primary constructor here is just the bit in parentheses. The braces are not the constructor implementation, that is the class implementation, for example in Java that constructor (in context) would be:

class Suffragist {

    String firstName;
    String lastName;

    public Suffragist(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

So the primary constructor identifies the properties needed for initialisation of the object.

In my class above, firstName and lastName are not properties of the Suffragist object. In my test I could not do this:

suffragist.firstName shouldBe "Kate"
suffragist.lastName shouldBe "Sheppard"

This gives a compile error:

Unresolved reference: firstName

To make them properties, I need to put a val or var qualifier in the primary constructor:

class Scientist(val firstName:String, val lastName:String)

class ClassesTest : DescribeSpec({
    describe("Constructor tests") {
        // ...
        it("needs to qualify params to make them properties") {
            val scientist = Scientist("Siouxsie", "Wiles")

            scientist.firstName shouldBe "Siouxsie"
            scientist.lastName shouldBe "Wiles"
        }
    }
})

Back to the first example:

class Suffragist(firstName:String, lastName:String) {
    val fullName = "$firstName $lastName"
}

That line is not part of the constructor, it's initialising the fullName property. It just happens to have access to the firstName and lastName values from the constructor when it's executed.


Initializer blocks

As the primary constructor can't have any code in it, any code that needs to run when the object is created needs to be in initializer blocks in the class body:

class Politician(firstName:String, lastName:String) {
    init {
        println("First name: $firstName")
    }
    init {
        println("Last name: $lastName")
    }
}

class ClassesTest : DescribeSpec({
    describe("Constructor tests") {
        
        // ...
        
        it("can have code in initializer blocks") {
            val EOL = System.lineSeparator()
            val output = SystemLambda.tapSystemOut {
                Politician("Jacinda", "Ardern")
            }

            output shouldBe "First name: Jacinda${EOL}Last name: Ardern${EOL}"
        }
    }
})

Unsurprisingly they are executed in the order they are in the file.


Secondary constructors

Kotlin can have secondary constructors if one needs to do more than take some arguments and pass them to initializer blocks. Secondary constructors use the constructor keyword to define their behaviour.

Here I have a secondary constructor that takes a map of firstName / lastName pairs, and this extracts the values from the map for each of the firstName / lastName parameters of the primary constructor.

class FilmMaker(firstName:String, lastName:String) {
    val fullName = "$firstName $lastName"
    constructor(names: Map<String, String>) : this(names["firstName"]!!, names["lastName"]!!)
}

class ClassesTest : DescribeSpec({
    describe("Constructor tests") {

        // ...
        
        it("can have a secondary constructor") {
            val names = mapOf(Pair("firstName", "Jane"), "lastName" to "Campion")
            val filmMaker = FilmMaker(names)

            filmMaker.fullName shouldBe "Jane Campion"
        }
    }
})

It's important to note that because I have a primary constructor defined, then ultimately one of the secondary constructors must call it, even if there's a sequence of secondary constructors calling one another in a chain, ultimately the primary constructor must be called.

I did wonder why it's not possible to do this sort of thing:

constructor(names: Map<String, String>) {
    val firstName = names["firstName"]
    val lastName = names["lastName"]

    this(firstName!!, lastName!!)
}

IE: to have the secondary constructor do some work and then call the primary constructor. I could not find an answer for this.

BTW, that !! operator is a not-null assertion operator. In this usage it tells the compiler to error-out if the map doesn't have the key/value pair I'm using there. Otherwise I'd need to make the following code handle nulls for those values, and that is incorrect.


Here's an example where there's no primary constructor, which enables a secondary constructor to do more than simply invoke the primary one, inline:

class ChiefJustice {
    var firstName = ""
    var lastName = ""
    var fullName = ""

    constructor(firstName:String, lastName:String) {
        this.firstName = firstName
        this.lastName = lastName

        fullName = "$firstName $lastName"
    }

    constructor(names: List<String>) : this(names.first(), names.last())
}

class ClassesTest : DescribeSpec({
    describe("Constructor tests") {

        // ...

        it("can call another secondary constructor") {
            val names = listOf("Helen", "Winkelmann")
            val chiefJustice = ChiefJustice(names)

            chiefJustice.fullName shouldBe "Helen Winkelmann"
        }
    }
})

Here we have two secondary constructors. One takes separate String values for the properties, and the second takes a list of names, and extracts first and second to pass to the previous secondary constructor to actually then do the initialisation work.


Companion objects and factory methods

I touched on companion objects before in my "Kotlin: there's no such thing as static, apparently" article. One thing I read a few times when reading about secondary constructors, is that they're a bit frowned-up for situations where there's constructors taking different repesentations of the property data values (like all the examples I offer here!). Instead of having a constructor that takes a map of name/value pairs, one would instead have a factory method, using a companion object. So instead of this example:

class FilmMaker(val firstName:String, val lastName:String) {
    val fullName = "$firstName $lastName"
    constructor(names: Map<String, String>) : this(names["firstName"]!!, names["lastName"]!!)
}

One might better have:

class FilmMaker(firstName:String, lastName:String) {
    val fullName = "$firstName $lastName"

    companion object Factory {
        fun createFromMap(names: Map<String, String>) = FilmMaker(names["firstName"]!!, names["lastName"]!!)
    }
}

class ClassesTest : DescribeSpec({
    describe("Constructor tests") {
        it("can have a secondary constructor") {
            val names = mapOf(Pair("firstName", "Jane"), "lastName" to "Campion")
            val filmMaker = FilmMaker.createFromMap(names)

            filmMaker.fullName shouldBe "Jane Campion"
        }
    }
})

Now I have a factory method createFromMap which is a bit more clear in its intent / purpose than having multiple vague constructors.


I'm gonna leave it there today. There's some more sections on that docs page regarding inheritance and abstract classes and stuff, but I'm not in the mood for that right now. Am struggling a bit with focus just now: not quite sure why.

The code for today is on GitHub as ClassesTest.kt (1.5 tag) except the last example which was a refactor of the earlier code, and is tagged as 1.5.1: ClassesTest.kt.

Righto.

--
Adam