Skip to content
academia.sh

Lesson 11 / 23

Classes and Constructors

A measurement of the execution order of the primary constructor, the init block, and the secondary constructor, and of why instances of a class without equals fall to the identity criterion.

Published Updated

In this lesson

  • Measures the execution order of property initializers, the init block, and the secondary constructor body
  • Shows the behavior of parameters with default values and of a custom accessor definition
  • Measures that instances with the same values, built by two routes from a class without equals, fall to the identity criterion
  • Verifies with the bounding measurement that equality is determined by the equals methods that are written, not by the class
Contents

The previous lesson showed that a class without equals on the right side of the Elvis operator turned the criterion into identity. This lesson looks at how those classes are written: the primary constructor, the init block, the secondary constructor, and properties. First it measures which part runs when, then it explains through the criterion why two instances with the same values are not counted as equal.

The measurement axis stays the same: the relationship between a value and another value that counts as equal to it, the criterion. The Criterion class is given again below, as it was in the opening lesson. The rule works in order: if the first value and its twin are the same object, the criterion is singleton; if not, and the twin is not equal, it is identity; if the twin is equal but the third value is not, it is content; if the third value is equal too, it is partial content. This lesson measures the identity slice and moves on to content and partial content in the bounding measurement.

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

The Execution Order of Constructors

In Kotlin, the primary constructor of a class is written in the class header: class Member(val name: String, val level: Int). The val in front of a parameter defines both the parameter and a property of the same name. The body can contain properties with initializers and init blocks; these behave like the body of the primary constructor and run in the order they are written in the file. A secondary constructor is written with the constructor keyword and has to delegate to the primary constructor with : this(...). The constructor it delegates to runs first, and the secondary constructor’s own body runs last.

The block below makes this order readable from the output. The trace function prints a line every time it is called. The initializer of the title property calls trace, the init block prints a line, and the body of the secondary constructor prints a line. Member("ada") calls the secondary constructor, and Member("can", 2) calls the primary constructor directly.

// Main.kt — constructor order
fun trace(label: String): String {
    println("step: $label")
    return label
}

class Member(val name: String, val level: Int) {
    val title = trace("property")

    init {
        println("init: $name")
    }

    constructor(name: String) : this(name, 1) {
        println("secondary: $name")
    }
}

fun main() {
    val first = Member("ada")
    println(first.level)
    val second = Member("can", 2)
    println(second.level)
}
step: property
init: ada
secondary: ada
1
step: property
init: can
2

In both constructions, the step: property line comes before the init: ... line, because title is written before the block. The secondary constructor runs only in the first construction, and the secondary: ada line is printed after the init: ada line: the primary constructor and its initializers are completed before the body of the secondary constructor. There is no secondary line in the second construction, because the primary constructor was called directly.

TN11. This measurement shows the order only for this class. Swapping the initializer and the init block changes the output; the order is the order of writing. Which initializer comes before which init block depends on how the measured class is written.

Properties, Default Values, and Accessors

The val and var parameters in the primary constructor become properties. Properties also have a computed kind: val area: Int get() = width * height is not stored in a field and is computed every time it is read. Parameters can be given default values, and the caller can also pass parameters by name, out of order. If a custom set is written for a var property, the assigned value can be processed before it goes into the field; the field itself is reached by the name field.

The block below measures three things. Rectangle(3) leaves the height at the default value 1. Rectangle(height = 2, width = 5) passes the parameters by name, out of order. Finally, a string with spaces at the start and end is assigned to the label property; set strips those spaces.

// Main.kt — properties
class Rectangle(val width: Int, val height: Int = 1) {
    val area: Int get() = width * height

    var label: String = "unnamed"
        set(value) {
            field = value.trim()
        }
}

fun main() {
    val narrow = Rectangle(3)
    println(narrow.area)
    val wide = Rectangle(height = 2, width = 5)
    println(wide.area)
    wide.label = "  box  "
    println("[" + wide.label + "]")
}
3
10
[box]

The areas were computed as 3 times 1 and 5 times 2. The label was stored not as it was assigned but after passing through set: the square brackets show that no spaces remain.

TN12. area is not stored in a field. The measured block shows only the result of reading; that area would change if width and height were mutable does not follow from this block. This was measured only for val values that come from the constructor.

A Class Without Equals

Now back to the criterion. The Coordinate class has two constructors: the primary constructor takes two numbers, and the secondary constructor splits a string of the form "3,4" and delegates to the same primary constructor. The class has no equals. In Kotlin every class derives from Any, and Any’s equals method looks at whether two references point to the same object. Unless the class overrides it, the == operator looks at identity too.

// Coordinate.kt — a class without equals
class Coordinate(val x: Int, val y: Int) {
    constructor(text: String) : this(text.substringBefore(",").toInt(), text.substringAfter(",").toInt())
}

The measurement consists of three lines. In the first, the first two of the three values are the same object. In the second, two separate Coordinate(3, 4) are built from the primary constructor. In the third, the twin is built from the secondary constructor, that is, from the string; the values are 3 and 4 again.

// Main.kt — instances built by two routes
fun main() {
    val same = Coordinate(1, 2)
    val checks = listOf(
        Criterion("same-object", same, same, Coordinate(1, 3)),
        Criterion("same-route", Coordinate(3, 4), Coordinate(3, 4), Coordinate(3, 5)),
        Criterion("two-routes", Coordinate(3, 4), Coordinate("3,4"), Coordinate(3, 5)),
    )
    for (check in checks) check.report()
    summarize(checks)
}
same-object: same-object=true, twin=true, other=false -> singleton
same-route: same-object=false, twin=false, other=false -> identity
two-routes: same-object=false, twin=false, other=false -> identity
singleton: 1
identity: 2
content: 0
partial-content: 0

In the first line the rule stops at the first question: because it is the same object, the criterion is singleton. In the second and third lines, twin=false comes out even though the fields are exactly the same, and the criterion is identity. The construction route, whether primary or secondary, does not change the result. What makes two instances with the same values equal is not the class’s fields; it is what equals does.

This lesson’s measurement has to be completed with a distinction. What is measured here is whether two instances are counted as equal to each other. Whether an instance is shared from somewhere else, or whether a change is visible through another reference, is a separate question and was not measured in this lesson. The identity criterion says only this: == gives false for two separate objects.

TN13. The measurement is only for Coordinate and the two constructor routes. The same result was not measured for a class whose fields are of other types, or for a class derived from a superclass other than Any. If the superclass has overridden equals, the picture may be different.

Constructor Order in Inheritance

When a class derives from another class, one more link is added to the constructor order. Kotlin classes are closed by default; the class to be derived from is marked open, and the subclass calls the superclass’s constructor in the header line. In the block below, both the superclass and the subclass have an init block. Which one runs first is read from the output.

// Main.kt — superclass constructor
open class Base(val name: String) {
    init {
        println("Base init: $name")
    }
}

class Derived(name: String, val extra: Int) : Base(name) {
    init {
        println("Derived init: $extra")
    }
}

fun main() {
    val item = Derived("ada", 3)
    println(item.name + item.extra)
}
Base init: ada
Derived init: 3
ada3

The output shows the order clearly: the Base init: ada line comes before the Derived init: 3 line. The Base(name) call in the subclass’s header line builds the superclass before the subclass’s own initializers run. The reason is simple: the subclass’s state can depend on the superclass’s state, so the side being depended on has to be ready first. The last line shows that the name value comes from the superclass and the extra value from the subclass, and that both are reached through a single object.

This order extends the execution order in the previous section. In a single class, the order is property initializers, init blocks, and the secondary constructor body; in inheritance, the whole of the superclass is added in front of this. If a property of the subclass is touched inside the superclass’s constructor, that property has not been set up yet; for this reason superclass constructors do not read state that belongs to the subclass. The limit of responsibility is visible in this output: each class sets up its own fields only in its own init block.

Computed on Every Read and a One-Time Initializer

It was said earlier that the area property is not stored in a field and is computed every time it is read. This has a measurable consequence: how many times is the computing function called? In the block, two properties call the same function. One is defined with an initializer, the other with get(). Both are read three times; the counters keep how many times the function ran.

// Main.kt — computed on every read
var storedCalls = 0
var computedCalls = 0

fun compute(kind: String): Int {
    if (kind == "stored") storedCalls++ else computedCalls++
    return 10
}

class Holder {
    val stored: Int = compute("stored")
    val computed: Int get() = compute("computed")
}

fun main() {
    val holder = Holder()
    println(holder.stored + holder.stored + holder.stored)
    println(holder.computed + holder.computed + holder.computed)
    println("stored=$storedCalls computed=$computedCalls")
}
30
30
stored=1 computed=3

The last line of the output gives the counters: stored=1 computed=3. Both properties were read three times and both gave the total 30, but the computing function ran a different number of times. The property defined with an initializer computes the value once, while the object is being built, and stores it in a field. The property defined with get() carries no field; the function runs again on every read.

Since both results return the same value, the difference is visible only from the counter, not from the output. For this reason, a property defined with get() suits values that are cheap and have to be up to date every time: a field derived from other properties, or a total. If an expensive computation or a function with a side effect is put into get(), every read pays that cost and the side effect is repeated as many times as there are reads. If the computation has to be done once, the initializer is chosen; if it has to be up to date every time, the accessor is chosen.

A Written Equals and Other Types

A hand-written equals does not meet only values of the same type. Because its parameter is Any?, null and unrelated types can come in as well. The other is ValueCoordinate check we write filters out these cases. The block calls equals four times: with null, with a string, with another instance with the same values, and with the object itself.

// Main.kt — equals and other types
class ValueCoordinate(val x: Int, val y: Int) {
    override fun equals(other: Any?): Boolean =
        other is ValueCoordinate && x == other.x && y == other.y

    override fun hashCode(): Int = 31 * x + y
}

fun main() {
    val point = ValueCoordinate(1, 2)
    val anything: Any = "1,2"
    println(point.equals(null))
    println(point.equals(anything))
    println(point == ValueCoordinate(1, 2))
    println(point.equals(point))
}
false
false
true
true

All four calls return a Boolean, and the output is false, false, true, true. equals(null) gives false, because the is check fails for null. When the string is passed as an Any, false comes out too; because the types do not match, the fields are not even looked at. On a separate instance with the same values true returns, and on the object itself true returns again.

The parameter being Any? is a contract: equals can meet any kind of value and must say false without throwing an exception. A common mistake is to assume the parameter is ValueCoordinate and convert it directly, or to skip the type check and reach for the fields. The correct pattern is the is check first; thanks to the && chain, the field comparison is made only after the check passes, and the compiler sees the other on the right side as a ValueCoordinate. hashCode is computed from the same fields; equal objects must produce equal hash codes, otherwise hash-based collections work inconsistently.

The Bounding Measurement

If the written equals determines equality and not the class’s fields, classes with the same fields should fall to different criteria depending on equals. The bounding measurement tests this with three lines. ValueCoordinate writes an equals that looks at both fields and a hashCode consistent with it. LeftCoordinate looks only at the x field. Of the three lines, the first tries full equality, the second the case where x is the same and y differs, and the third the case where x differs. The two lines at the end of the block put two instances with the same values into a set with hashSetOf and print the size of the set; this is how it observes that hashCode is written along with equals. The hashCode values themselves are not printed.

// Main.kt — once equals is written
class ValueCoordinate(val x: Int, val y: Int) {
    override fun equals(other: Any?): Boolean =
        other is ValueCoordinate && x == other.x && y == other.y

    override fun hashCode(): Int = 31 * x + y
}

class LeftCoordinate(val x: Int, val y: Int) {
    override fun equals(other: Any?): Boolean = other is LeftCoordinate && x == other.x

    override fun hashCode(): Int = x
}

fun main() {
    val checks = listOf(
        Criterion("full", ValueCoordinate(3, 4), ValueCoordinate(3, 4), ValueCoordinate(3, 5)),
        Criterion("x-only-y-differs", LeftCoordinate(3, 4), LeftCoordinate(3, 4), LeftCoordinate(3, 5)),
        Criterion("x-only-x-differs", LeftCoordinate(3, 4), LeftCoordinate(3, 4), LeftCoordinate(2, 4)),
    )
    for (check in checks) check.report()
    summarize(checks)
    println(hashSetOf(Coordinate(3, 4), Coordinate(3, 4)).size)
    println(hashSetOf(ValueCoordinate(3, 4), ValueCoordinate(3, 4)).size)
}
full: same-object=false, twin=true, other=false -> content
x-only-y-differs: same-object=false, twin=true, other=true -> partial-content
x-only-x-differs: same-object=false, twin=true, other=false -> content
singleton: 0
identity: 0
content: 2
partial-content: 1
2
1

The same class pattern gives two criteria across three lines. Because ValueCoordinate compares both fields, the twin and the other value separate: content. LeftCoordinate comes out equal even when y differs, so the third value gives the same result as the twin and the criterion falls to partial content; when x differs, content comes out. Two separate lines of the same class give two separate criteria; so the criterion is determined not by a class but by the fields equals looks at and the pair of values being compared, together. In the sets, Coordinate stays as two separate elements, and ValueCoordinate comes down to a single element.

TN14. This measurement is for a small class with two fields. In a class with more fields, which fields enter equals is specific to each class; the same behavior cannot be assumed without measuring.

Summary

  • The primary constructor is written in the class header; initializers and init blocks run in the order they are written, and the secondary constructor’s body runs last.
  • A secondary constructor has to delegate to the primary constructor with : this(...), and the constructor it delegates to completes first.
  • Properties can be given behavior with default values, named arguments, a computed read, and a custom set.
  • Instances with the same values of a class without equals fall to the identity criterion, whichever constructor they were built with.
  • This lesson’s criterion: identity. In the bounding measurement the same class pattern fell to content and to partial content as well, depending on equals.

Next Step

To make a class behave like a value, equals, hashCode, and toString had to be written by hand. The next lesson measures data classes, which generate these three from the properties in the primary constructor: it looks at whether a separate object built with copy is counted as equal, and at what a destructuring declaration does.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close