Lesson 01 / 23
Kotlin's Place
Introduces Kotlin's place on the JVM and its design goals; measures how the same `==` operator gives equality under four different criteria across eight values.
Published Updated
In this lesson
- States Kotlin's position on the JVM and its purpose as a statically typed language
- Distinguishes what the `==` and `===` operators compare
- Classifies whether a value's equality rests on the singleton, identity, content, or partial content criterion
Contents
This course teaches Kotlin through a question the language itself answers: when are two values the same? Kotlin is a statically typed, compiled programming language; it compiles to the JVM (Java Virtual Machine), Android, JavaScript, Wasm, and Native targets. This course takes its measurements on the JVM target: the source code is translated to bytecode and lives alongside the JVM’s own class library. The design goals fit into two sentences: write readable code with little ceremony, and move type checking to compile time. What the two goals share is that the language sets exact rules about values. The first rule comes right away: in Kotlin, the == operator asks for equality, while the === operator asks whether two expressions point to the same object.
The next seven lessons in this topic ask the same question about different constructs. Each lesson has one criterion, and one bounding measurement shows the limit of that criterion. The criterion is one of four names. The singleton criterion is the case where a value has a single instance in memory and every access reaches the same object. The identity criterion is the case where equality depends on the object itself: two objects built the same way are not considered equal. The content criterion is the case where equality depends on the values the object carries. The partial content criterion is the case where equality looks at only some of the values and ignores the rest.
Eight Values, Four Criteria
The measurement is made with the same helper file in every lesson. The Criterion class takes a value, its twin built the same way, and one other value, and asks three questions. Is the twin the same object? Is the twin equal under ==? Is the other value equal under ==? These three answers decide one of the four criteria.
// 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 program below passes eight values through this class: an enum entry, an object declaration, a plain class, an array, a data class, a list, a string, and a data class that carries an extra property in its body. A data class is a class that derives its equality and its text representation from its constructor properties; its details are not the subject of this lesson and are covered in the Data Classes lesson; here we look only at its equality.
// Main.kt — eight values, four criteria
enum class Color { RED, BLUE }
object Registry
object Archive
class Box(val size: Int)
data class Point(val x: Int, val y: Int)
data class Tag(val name: String) {
var note: String = ""
}
fun main() {
val word = listOf("kot", "lin").joinToString("")
val checks = listOf(
Criterion("enum-entry", Color.RED, Color.valueOf("RED"), Color.BLUE),
Criterion("object-declaration", Registry, Registry, Archive),
Criterion("plain-class", Box(1), Box(1), Box(2)),
Criterion("array", intArrayOf(1, 2), intArrayOf(1, 2), intArrayOf(1, 3)),
Criterion("data-class", Point(1, 2), Point(1, 2), Point(1, 3)),
Criterion("list", listOf(1, 2), listOf(1, 2), listOf(1, 3)),
Criterion("string", "kotlin", word, "kotlim"),
Criterion(
"body-property",
Tag("a").apply { note = "first" },
Tag("a").apply { note = "first" },
Tag("a").apply { note = "last" },
),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
enum-entry: same-object=true, twin=true, other=false -> singleton object-declaration: same-object=true, twin=true, other=false -> singleton plain-class: same-object=false, twin=false, other=false -> identity array: same-object=false, twin=false, other=false -> identity data-class: same-object=false, twin=true, other=false -> content list: same-object=false, twin=true, other=false -> content string: same-object=false, twin=true, other=false -> content body-property: same-object=false, twin=true, other=true -> partial-content singleton: 2 identity: 2 content: 3 partial-content: 1
The output reads as follows. enum-entry and object-declaration are singletons: an enum entry and an object declaration live in memory as a single instance, and the call Color.valueOf("RED") returns the same instance as well. plain-class and array are identity: two Box(1) objects are not considered equal even though they carry the same constructor argument, and neither are two intArrayOf(1, 2) arrays. On an array, == does not look at the elements; it compares the object. data-class, list, and string are content: the twin is equal, the other value is not equal, and the twin is not the same object. The last line, body-property, is partial content. Because the note property is defined in the body of the data class and not in its constructor, it does not take part in equality; two objects whose note values are first and last come out equal under ==.
The summary lines confirm this distribution: two singleton, two identity, three content, one partial content. All eight values were classified and none fell into a fifth criterion.
LB1. Each of the eight values was measured with a single set of constructor arguments; the classification of instances of the same class built with other arguments is outside this lesson.
LB2. Only intArrayOf was measured for arrays; that the other array types behave the same way is not a claim measured in this lesson.
Kotlin on the JVM
Because Kotlin runs on the JVM, it uses the JVM’s own types directly. java.util.ArrayList is such a type. The list that Kotlin builds with listOf is also a JVM list at run time; both are bound by the same equality rule. The block below measures three things: two JVM lists, a Kotlin list against a JVM list, and the equality of a data class when the value passed to the constructor changes.
// Main.kt — JVM list and the same data class
import java.util.ArrayList
data class Tag(val name: String) {
var note: String = ""
}
fun main() {
val jvmList = ArrayList<Int>()
jvmList.add(1)
jvmList.add(2)
val jvmTwin = ArrayList<Int>()
jvmTwin.add(1)
jvmTwin.add(2)
println(jvmList.javaClass.name)
val checks = listOf(
Criterion("jvm-list", jvmList, jvmTwin, listOf(1, 3)),
Criterion("kotlin-and-jvm-list", listOf(1, 2), jvmList, listOf(1, 3)),
Criterion("data-class-constructor", Tag("a"), Tag("a"), Tag("b")),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
java.util.ArrayList jvm-list: same-object=false, twin=true, other=false -> content kotlin-and-jvm-list: same-object=false, twin=true, other=false -> content data-class-constructor: same-object=false, twin=true, other=false -> content singleton: 0 identity: 0 content: 3 partial-content: 0
The first line prints the JVM’s class name: java.util.ArrayList. jvm-list is content, because two lists that carry the same elements are equal and are separate objects. kotlin-and-jvm-list is content as well: a list that Kotlin builds with the expression listOf(1, 2) comes out equal under == to a JVM list with the same elements. A Kotlin type and a JVM type do not form separate worlds as far as equality goes. data-class-constructor is the constructor-bound form of the Tag class we saw earlier in this lesson; when the name in the constructor differs, equality breaks, which means equality looks at the constructor properties.
LB3. In this section listOf and java.util.ArrayList were compared only for equality; the run-time class of the listOf expression is printed separately in a later section.
LB4. The JVM list was measured with java.util.ArrayList; the JVM’s other collection classes are outside this lesson.
What a Data Class Derives
A data class derives the members equals, hashCode, toString, componentN, and copy from the properties in its primary constructor; this section measures the first three. The previous measurement showed only the == side. The block below asks all three at once of a Tag class that carries a note property in its body. The note value of the second object is changed afterward; the constructor argument is the same in both objects.
// Main.kt — data class: equals, hashCode, and toString
data class Tag(val name: String) {
var note: String = ""
}
fun main() {
val first = Tag("a")
val second = Tag("a")
second.note = "second"
println(first == second)
println(first.hashCode() == second.hashCode())
println(first)
println(second)
println(first.note.isEmpty())
}
true true Tag(name=a) Tag(name=a) true
The first two lines give true: == and hashCode do not see the difference. The next two lines show that the representation printed by println does not carry the note value. Although second.note is now "second", the screen shows Tag(name=a). The last line confirms that first.note is still empty; so the two objects really are in different states, and the three derived behaviors ignore this difference together.
The common mistake appears exactly here. In a debugging session, someone prints two objects with println, sees the same text, and concludes “these are the same”; yet the property in the body stands apart. For this reason every value that should take part in equality must be a constructor property. In Java, equals and hashCode are written by hand, and keeping the two consistent with each other is the developer’s job; in Kotlin, both are derived together from the same constructor properties, so they do not fall out of step unless one of them is overridden by hand.
LB5. The Java comparison was not measured in this section; writing equals and hashCode on the Java side was mentioned as known behavior, and it is not a claim executed in this lesson.
Run-Time Classes
In Kotlin source there is a single name Int; on the JVM, the int primitive and the java.lang.Integer box are two separate things. We can read which class the same value lives in at run time with javaClass.name. The block prints the class names of five values and three lists.
// Main.kt — JVM classes of Kotlin values
fun main() {
val number: Any = 1
val text: Any = "kotlin"
println(number.javaClass.name)
println(text.javaClass.name)
println(listOf(1, 2).javaClass.name)
println(listOf(1).javaClass.name)
println(emptyList<Int>().javaClass.name)
println(mutableListOf(1).javaClass.name)
println(intArrayOf(1).javaClass.name)
}
java.lang.Integer java.lang.String java.util.Arrays$ArrayList java.util.Collections$SingletonList kotlin.collections.EmptyList java.util.ArrayList [I
The 1 held with the type Any comes out as java.lang.Integer: when a value enters an Any variable, it is boxed. The string appears as java.lang.String; Kotlin has no separate string class. For intArrayOf(1) the class name is [I, which is the JVM’s notation for int[].
The lists are more instructive. The expression listOf(1, 2) gives java.util.Arrays$ArrayList, while listOf(1) gives java.util.Collections$SingletonList. The empty list gives kotlin.collections.EmptyList. mutableListOf(1) returns java.util.ArrayList. The reason for the mechanism is this: listOf does not build a single class, it chooses the cheapest implementation according to the number of elements. Code that relies on the run-time class of a List value is therefore fragile: four separate calls gave four separate classes. Equality, on the other hand, rests on the elements and not on the class name; in the previous section, listOf and java.util.ArrayList came out equal under the content criterion.
Java comparison: in Java, int and Integer are written separately in source code, and the developer chooses the boxing. In Kotlin, the compiler makes the choice. This observation was measured with a variable of type Any; that boxing would also happen with the type Int? was not measured in this lesson.
LB6. The class names were measured with Kotlin 2.4.21 and the JVM in the sandbox image; on another JVM version, listOf may not choose the same internal classes.
Comparing with Null
The == operator also works on nullable types. The block below makes five comparisons between an empty Tag? and a filled Tag?.
// Main.kt — comparing with null
data class Tag(val name: String)
fun main() {
val missing: Tag? = null
val present: Tag? = Tag("a")
println(missing == null)
println(missing == present)
println(present == Tag("a"))
println(missing === missing)
val none: String? = null
println(none == none)
}
true false true true true
The five lines say the following in order. A null value is equal to null. A null value is not equal to a filled object, and this comparison does not throw an error. A filled object is equal to another Tag built with the same constructor argument. === works on null too. Two null strings are equal to each other.
The mechanism is this: the expression a == b asks whether b is also null when a is null; when a is filled, a.equals(b) is called. The compiler adds this null check itself. The common mistake is to write this check by hand: an expression of the form a != null && a.equals(b) carries extra words. In Java, the call a.equals(b) throws a NullPointerException when a is null; for a null-safe comparison in Java, Objects.equals is used. In Kotlin, the same guarantee lies in the == operator itself.
LB7. These five comparisons were measured on Tag and String; the NullPointerException behavior on the Java side and Objects.equals were written as a reminder and were not executed in this lesson.
Habits That Come from the Measurements
The measurements in this lesson can be tied to three habits. The first is to measure the equality behavior of a class instead of guessing it. Because Tag is a data class, its two separate instances came out equal under == but not under ===; in a plain class both would give false. Anyone who will use a class as a key in a collection or compare it with assertEquals in a test must first know the difference between these two operators. In Java, == looks at identity and equals looks at content; in Kotlin, == looks at content and === looks at identity. A person coming from Java may think that writing == compares identity, and this is a point that misleads code reading on the first pass between the two languages.
The second is not to rely on the run-time class. The listOf calls in this lesson produced different JVM classes: the single-element list, the empty list, and the multi-element list came with separate implementations. These class names are not a language contract; they may change when the Kotlin version or the target changes. A check such as list is ArrayList is fragile for this reason. The contract is the List interface itself.
The third is to choose the readable form when comparing with null. A null check with == handles the null case separately before the object’s equals method is called; so null == null is true, and writing x == null does not throw a NullPointerException. In Java, writing x.equals(y) raises an exception if x is null. Kotlin closes this off through the definition of the operator, and the measurement also showed that none of the five comparisons raised an exception.
What the three habits share is that nothing is claimed without being measured. The rest of the lesson proceeds by this principle: every claim either rests on an output or is marked “not measured” in an LB line. The practical consequence for the reader is this: if you are unsure about a behavior, writing and running a small single-file program takes less time than searching the documentation. This is also why each lesson in this course has a criterion: the criterion fixes in advance which question you ask with which operator and makes the output easier to interpret. For a data class the content criterion applies, for a plain class identity, and for a singleton object singleton. To know the criterion of a type is to know what every comparison written with that type means. Code that assumes the wrong criterion usually compiles and runs; the error shows only when two objects come out unexpectedly equal or different.
The Bounding Measurement
The limit of the criterion runs through the inside of the data class. The same Tag class carries two criteria together: the name in the constructor takes part in equality, and the note in the body does not. Tag("a") and Tag("a") are content, and Tag("a") and Tag("b") are not equal; but when name stays the same and only note changes, == still gives true, so we land in partial content. The criterion of a class therefore comes not from the name of the class but from which properties it builds its equals behavior on.
We saw this claim in two measurements: data-class-constructor is content, and body-property is partial content.
Summary
- Kotlin is a statically typed, compiled language; it compiles to more than one target, and on the JVM target measured in this course it works directly with JVM types.
==asks for equality, while===asks for object identity.- Equality rests on one of four criteria: singleton, identity, content, or partial content.
- The equality of a data class looks at its constructor properties; a property in the body does not take part in equality.
- This lesson’s criterion: content; bounding measurement: when the
namein the constructor differs, the same data class is not equal.
Next Step
We have seen the four criteria of equality. In the next lesson we start from the smallest construct: a variable’s name, its type, and whether it can be reassigned. We will measure whether the difference between val and var also changes how the same value behaves in two variables.
To keep your progress and take notes, Log in
My notes
Log in to take notes.