Skip to content
academia.sh

Lesson 12 / 23

Data Classes

A measurement of the methods a data class generates from the properties in its primary constructor, of the separate object built with copy, and of how the type of a field affects the criterion.

Published Updated

In this lesson

  • Measures that a data class generates equals, hashCode, toString, and copy from the primary constructor
  • Shows that a separate object built with copy falls to the content criterion
  • Measures that a destructuring declaration corresponds to the componentN methods
  • Verifies with the bounding measurement that the type of a field, whether an array or a list, determines the criterion
Contents

The previous lesson showed that instances with the same values of a class without equals fall to the identity criterion, and that they move to content once equals and hashCode are written by hand. Writing those two methods by hand in every class is tedious and error-prone. Kotlin leaves this job to the compiler with the data class keyword: it generates the equals, hashCode, toString, copy, and componentN methods from the properties in the primary constructor. This lesson measures what the generated methods do and how they build the criterion.

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 content slice.

// 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 Generated Methods

data class Ticket(val id: Int, val owner: String) is a single line and has no body. The block below tries the generated methods in turn. println(ticket) shows the generated toString. The two comparisons look at equals: against a separate Ticket with the same values, and against a Ticket with a different owner. The fourth line asks whether the hashCode values of two equal objects are equal; the value itself is not printed. The last two lines try copy: the copy built by changing owner, and the first object itself.

// Main.kt — generated methods
data class Ticket(val id: Int, val owner: String)

fun main() {
    val ticket = Ticket(7, "ada")
    println(ticket)
    println(ticket == Ticket(7, "ada"))
    println(ticket == Ticket(7, "can"))
    println(ticket.hashCode() == Ticket(7, "ada").hashCode())
    val moved = ticket.copy(owner = "can")
    println(moved)
    println(ticket)
}
Ticket(id=7, owner=ada)
true
false
true
Ticket(id=7, owner=can)
Ticket(id=7, owner=ada)

toString prints the fields with their names. equals finds the separate object with the same values equal and the one with a different owner not equal, and hashCode comes out equal for equal objects. copy(owner = "can") builds a separate object; the first object stays unchanged.

TN15. The measurement is for a small data class with two fields. Whether the generated methods behave the same in a class with many fields, or when one of the fields is another data class, does not follow from this block.

Copy and the Criterion

Now the same methods are tied to the criterion. There are three lines. In the first, the first two of the three values are the same object. In the second, the twin is built with ticket.copy(), that is, with no field changed. In the third, the twin is built by calling the primary constructor directly. In every line, the third value is a Ticket whose id or owner differs.

// Main.kt — copy and the criterion
data class Ticket(val id: Int, val owner: String)

fun main() {
    val ticket = Ticket(7, "ada")
    val checks = listOf(
        Criterion("same-object", ticket, ticket, ticket.copy(id = 8)),
        Criterion("copy", ticket, ticket.copy(), ticket.copy(id = 8)),
        Criterion("constructor", ticket, Ticket(7, "ada"), Ticket(7, "can")),
    )
    for (check in checks) check.report()
    summarize(checks)
}
same-object: same-object=true, twin=true, other=false -> singleton
copy: same-object=false, twin=true, other=false -> content
constructor: same-object=false, twin=true, other=false -> content
singleton: 1
identity: 0
content: 2
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 the twin is a separate object, twin=true comes out, and because the third value is not equal, the criterion is content. There is no difference in criterion between the object built with copy() and the object built with the constructor. Coordinate in the previous lesson, by contrast, gave identity in the same two lines. The difference is not in the construction route but in who wrote equals.

This lesson’s distinction is here too: what is measured is not that copy exists as a method, but which criterion gives the equality. copy builds a separate object; that object being counted as equal comes from the generated equals.

TN16. The measurement is only for Ticket. copy() builds a shallow copy; whether two copies share a field when the field is a mutable object was not measured in this block.

The Destructuring Declaration and componentN

The methods a data class generates, such as component1 and component2, make the destructuring declaration possible: the line val (id, owner) = ticket assigns a componentN call to each name in order. The order is the parameter order in the primary constructor. The block below tries the declaration and two componentN calls. Then it asks which fields the generated methods look at: in the Stamped class, the note field is defined in the body, not in the primary constructor. Two Stamped objects are built with the same constructor values but different note values and compared.

// Main.kt — destructuring and componentN
data class Ticket(val id: Int, val owner: String)

data class Stamped(val id: Int, val owner: String) {
    var note: String = ""
}

fun main() {
    val ticket = Ticket(7, "ada")
    val (id, owner) = ticket
    println("$id $owner")
    println(ticket.component1())
    println(ticket.component2())
    val first = Stamped(1, "ada")
    first.note = "a"
    val second = Stamped(1, "ada")
    second.note = "b"
    println(first == second)
    println(first)
}
7 ada
7
ada
true
Stamped(id=1, owner=ada)

The declaration gives 7 ada. component1() and component2() return the same values directly. first == second comes out true, even though the note fields are a and b. toString does not print note either. The generated methods look only at the properties in the primary constructor; a property in the body takes no part in equality or in the output.

TN17. This was measured only for a property defined with var in the body. Whether a computed property defined in the body, or a property inherited from a superclass, has a separate behavior was not measured.

Copy Is Shallow

copy copies the fields, but if a field is a mutable collection, what gets copied is not the collection itself but the reference to it. In the block, Basket carries a MutableList. When an item is added to the first copy, is the first object affected? In the second attempt the list is copied explicitly and the same question is asked again.

// Main.kt — copy is shallow
data class Basket(val owner: String, val items: MutableList<String>)

fun main() {
    val first = Basket("ada", mutableListOf("apple"))
    val second = first.copy(owner = "can")
    second.items.add("pear")
    println(first.items)
    println(second.items)
    println(first.items === second.items)
    val deep = first.copy(items = first.items.toMutableList())
    deep.items.add("cherry")
    println(first.items)
    println(deep.items)
}
[apple, pear]
[apple, pear]
true
[apple, pear]
[apple, pear, cherry]

The first two lines of the output are the same, [apple, pear]. The pear added through second is visible inside first too. The third line says why: first.items === second.items gives true, so the two objects go to the same list. copy copies the fields one by one, but for the list field, what is copied is not the list itself but the reference to it. The copying is shallow.

In the last two lines the list is copied explicitly with toMutableList(). This time the cherry added to deep is not visible in first. A common mistake is to take copy for a fully independent copy. In a data class that carries a mutable collection, a copy call preserves the equality semantics on the one hand, and on the other makes the two objects owners of the same state. The fix is either to make the field a read-only List or to copy the collection explicitly in the copy call. The content criterion holds here too: the two objects are equal as far as equals is concerned, but when one changes, the other changes as well.

Use as a Key and a Common Mistake

The generated hashCode makes a data class usable as a map key and a set element. A lookup with a separate object with the same values finds it. But if a data class with a var field is modified after it enters a set, its hash code changes and the object is separated from the place where it sits in the set. The block first reads from a map with a key with the same values, then changes the field of an object placed in a set and repeats the lookup.

// Main.kt — a data class as a key
data class Ticket(val id: Int, var owner: String)

fun main() {
    val prices = hashMapOf(Ticket(1, "ada") to 10)
    println(prices[Ticket(1, "ada")])
    val key = Ticket(2, "can")
    val set = hashSetOf(key)
    println(set.contains(key))
    key.owner = "eda"
    println(set.contains(key))
    println(set.size)
    println(set.contains(Ticket(2, "can")))
    println(set.any { it == key })
}
10
true
false
1
false
true

Reading from the map gives 10: a separate Ticket with the same values is found as a key, because equality and the hash code come from the field values. For the object placed in the set, contains first returns true. After key.owner is changed, a lookup with the same object becomes false, but size is still 1: the object is in the set, it just cannot be found. The Ticket(2, "can") built with the old value cannot be found either, because the element in the set now carries the owner eda and is not equal. A linear any scan, on the other hand, finds the object.

The reason for the mechanism is this: a hash-based collection places an element according to the hash code it had at the moment it was added. When the field changes, the code changes and the lookup looks somewhere else. A common mistake is to leave a var field in a data class used as a key or a set element. The rule: the fields that enter equality for an object placed in a collection are not changed while that object is in the collection. Data classes that will be keys are written with val fields, and if a change is needed, a separate object is produced with copy and the old one is removed from the collection.

Destructuring in Loops

The destructuring declaration works not only in val lines but also in for loops and lambda parameters. An unused position is skipped with _. Map entries are opened the same way, because they provide component1 and component2. The block tries all of these forms.

// Main.kt — destructuring in a loop
data class Ticket(val id: Int, val owner: String)

fun main() {
    val tickets = listOf(Ticket(1, "ada"), Ticket(2, "can"))
    for ((id, owner) in tickets) println("$id=$owner")
    for ((_, owner) in tickets) println(owner)
    val (first) = tickets[1]
    println(first)
    val prices = mapOf("apple" to 3, "pear" to 5)
    for ((name, price) in prices) println("$name:$price")
    println(tickets.map { (id, _) -> id })
}
1=ada
2=can
ada
can
2
apple:3
pear:5
[1, 2]

The first two loops print 1=ada, 2=can, and then only the owner names; _ skips the first position. The line val (first) = tickets[1] calls for attention: first is the value in the first position (component1), that is, the id, and the output is 2, not the first ticket. In the map loop, (name, price) opens the entries with component1 and component2. The lambda parameter in the last line is opened the same way and gives [1, 2].

The defining trait of destructuring is that it depends on order, not on field names. The componentN functions are numbered by the parameter order in the primary constructor. A common mistake is to break the ordering when adding a field to a class; if a field is inserted between id and owner in the constructor, the old destructuring lines still compile but read a different field. For this reason fields are added only at the end, and destructuring is used only in small, stable data classes. For the rest, the ticket.owner form is more durable, because it relies on the meaning of the name, not on the order.

Nested Data Classes

If a field of a data class is a data class too, the generated methods work in a nested way. toString opens the inner object as well, equals descends into the inner object’s equals, and copy carries the inner object as it is. The block tries this with an inner field that can be null.

// Main.kt — nested data classes
data class Address(val city: String)
data class Person(val name: String, val address: Address?)

fun main() {
    val person = Person("ada", Address("izmir"))
    println(person)
    println(person == Person("ada", Address("izmir")))
    println(person.copy(address = null))
    println(person.copy(address = Address("ankara")) == person)
    println(Person("ada", null) == Person("ada", null))
}
Person(name=ada, address=Address(city=izmir))
true
Person(name=ada, address=null)
false
true

The generated toString opens the inner object as well: Person(name=ada, address=Address(city=izmir)). equals descends into the inner object’s equals, so two separate Person objects with the same values come out equal (true). copy(address = null) changes only the specified field, and null is carried as a field without trouble; toString shows it as address=null. A copy built with a different city is not equal to the original object (false), while two people with a null address are equal (true).

This chained behavior holds as long as the inner object is a data class too. If the inner field is an ordinary class without equals, the equality of the outer object falls to the identity of that field: two outer objects with the same values again do not come out equal. A common mistake is to make only the outer class a data class and assume that this is enough for the whole tree. Equality is provided separately at every node of the tree. The content criterion of this lesson is carried to the outermost level only if the fields inside have the content criterion as well.

The Bounding Measurement

The generated equals compares the fields with ==. It follows that the type of a field should affect the criterion, according to the field’s own equals. The bounding measurement compares two data classes with the same shape: Packet carries an IntArray, and Message carries the same numbers in a List<Int>. In both lines the twin is built separately with the same values, and the third value differs only in its id. The last line of the block asks something different: if two Packet objects share the same IntArray instance, are they equal?

// Main.kt — by field type
data class Packet(val id: Int, val bytes: IntArray)
data class Message(val id: Int, val bytes: List<Int>)

fun main() {
    val checks = listOf(
        Criterion("array", Packet(1, intArrayOf(1, 2)), Packet(1, intArrayOf(1, 2)), Packet(2, intArrayOf(1, 2))),
        Criterion("list", Message(1, listOf(1, 2)), Message(1, listOf(1, 2)), Message(2, listOf(1, 2))),
    )
    for (check in checks) check.report()
    summarize(checks)
    val shared = intArrayOf(1, 2)
    println(Packet(1, shared) == Packet(1, shared))
}
array: same-object=false, twin=false, other=false -> identity
list: same-object=false, twin=true, other=false -> content
singleton: 0
identity: 1
content: 1
partial-content: 0
true

Two classes with the same shape give two separate criteria. Packet does not come out equal when it is built with two separate arrays that have the same content: the array’s equals looks at identity, and the equals the data class generates compares the field with this equals. Message, on the other hand, gives content, because the list’s equals looks at the content. The last line confirms this: when the same array instance is shared, Packet comes out equal too. The generated method inherits the equality of the field type; the criterion is determined not by the data class but by the types of the fields.

TN18. The measurement is only for IntArray and List<Int>. That other array or collection types follow the same rule was not measured.

Summary

  • A data class generates equals, hashCode, toString, copy, and componentN from the properties in the primary constructor.
  • copy builds a separate object; that object is counted as equal to the first one by the generated equals.
  • The destructuring declaration corresponds to the component1 and component2 calls, and its order is the constructor order.
  • Properties in the body do not enter the generated methods; the field type determines equality, with IntArray giving identity and List<Int> giving content.
  • This lesson’s criterion: content. In the bounding measurement, two data classes with the same shape fell to content and to identity depending on the field type.

Next Step

Classes alone are not enough to express that a value belongs to only one of a few closed forms. The next lesson measures enumerations with enum class and sealed hierarchies with sealed interface: it looks at how an exhaustive when rejects a missing branch and at which criterion an enum entry falls to.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close