1. Two kinds of equality: “same meaning” vs “it’s the very same object”
When you write a program, you constantly compare things. Sometimes you compare “by meaning”: for example, two strings "cat" and "cat" are the same word. And sometimes you care about a different question: “is this literally the same thing, or two different things that just look alike?”.
It’s like with keys: two keys can be cut the same way (==), but one is the original and the other is a copy, and that matters in some situations (===).
In Kotlin, these two comparison tasks are separated at the language level so you can explicitly say what exactly you want to check. This is especially useful with collections, because collections are “container-like”: they can be equal in content but be different containers, or the other way around—one container can have “two names” in code.
== — structural equality
The == operator in Kotlin means structural equality. In normal human terms, this is “equal by value/content”, i.e., “what matters for meaning matches”.
Technically, Kotlin reduces a == b to a call to equals, but it does so carefully: comparison with null is safe, and separate checks like “what if a is null” are usually not needed.
It’s important to remember this right away: == is your “workhorse” comparison operator that you’ll use almost all the time—when you check a computation result, search for an element in a collection, compare strings, numbers, etc.
Mini table: what == usually means for different types
| Type | What == means (intuitively) |
|---|---|
|
are the values equal (number, character, fact) |
|
is the text the same character-by-character |
|
is the size the same and are all elements equal in the same order (order matters) |
|
are all components equal |
With lists this is especially important: two lists with the same elements but in different order are usually considered not equal (because order is part of the list’s meaning).
Example: == with strings and numbers
fun main() {
val a = "hi"
val b = "hi"
val x = 10
val y = 10
println(a == b) // true
println(x == y) // true
}
This is exactly the behavior you expect.
Example: == is safe with null
Kotlin has a nice feature: == “understands” null and does not crash.
fun main() {
val x: String? = null
val y: String? = "hi"
println(x == null) // true
println(y == null) // false
println(x == y) // false
}
Notice the last println(x == y): there is no risk of “bringing the program down” by dereferencing null. == is safe, and that makes the code simpler and cleaner.
2. === and shared references (aliasing)
=== — referential equality
The === operator answers a different question: “do these two variables point to the exact same object?”. In everyday terms: “is it one container or two?”. It’s neither “better” nor “worse” than ==—it’s just a different meaning. If == is about value, then === is about identity.
In everyday application code, === is rare. But for debugging, for checking “did we accidentally copy a reference”, for diagnosing weird collection behavior—it’s useful.
Example: two equal collections are not the same object
fun main() {
val a = mutableListOf(1, 2, 3)
val b = mutableListOf(1, 2, 3)
println(a == b) // true
println(a === b) // false
}
The contents are the same, so a == b yields true. But these are two different lists, so a === b yields false.
Example: a “second name” for the same list
fun main() {
val a = mutableListOf("x")
val b = a
b.add("y")
println(a) // [x, y]
println(b) // [x, y]
println(a === b) // true
}
This is the main source of “magic” in a beginner’s eyes: you added an element via b, and a changed. Because a and b are two names for the same container.
What aliasing is and why it’s confusing
A situation where two variables refer to the same object is called aliasing (you can think of it as “a pseudonym” or “a second name”). For beginners, aliasing can feel like the language “has a mind of its own”, but in fact everything is logical: you’re simply holding one container, not two, via two variables.
It’s very useful to visualize this in your head as arrows: a variable is a “label” that points to a real object. Two labels can point to one object, and then changes via one label are visible via the other.
Let’s draw a simple diagram:
flowchart LR
A[a: MutableList] --> L[(List)]
B[b: MutableList] --> L
That’s why in Kotlin it’s so important to distinguish “value” and “reference”, even if we don’t dive into how JVM memory works. At the level of everyday logic it’s enough to remember: a mutable collection can have multiple “names”, and then changes will be shared.
Mini example from the course topic: a simple CLI app
Imagine we are building a very simple expense tracker (no classes, just Pair for now), and we store operations as pairs "category" to amount.
fun main() {
val expenses = mutableListOf("food" to 250, "taxi" to 120)
val visibleExpenses = expenses // "pretended this is a copy"
visibleExpenses.add("books" to 90)
println(expenses) // [(food, 250), (taxi, 120), (books, 90)]
println(visibleExpenses) // [(food, 250), (taxi, 120), (books, 90)]
}
If you expected visibleExpenses to be “a separate list for display”, you just introduced a bug: you changed the “display”, and the “data” changed.
3. val does not make an object immutable
A very common confusion looks like this: “But I used val, so it doesn’t change”. But val means only one thing: the variable cannot point to a different object. However, if the object itself is mutable (for example, MutableList), you can still change its contents.
Example: val + MutableList still changes
fun main() {
val numbers = mutableListOf("one", "two")
numbers.add("three")
println(numbers) // [one, two, three]
}
And that’s fine. val here protects you from accidentally reassigning the variable (i.e., you won’t be able to do numbers = mutableListOf(...)), but it does not forbid changing the list’s contents.
Why this is good
If val forbade changing the internals of a mutable object, you’d need a separate language construct for “hard immutability”. Kotlin keeps it simpler: val is about the variable; mutability is about the object/type itself.
So a good habit in a project is: use val by default, and enable mutability only where it’s actually needed. This reduces the number of unexpected “reference swapping” situations in code.
4. A trap: === with nullable numbers
Sometimes a beginner, after learning about ===, starts using it “for reliability”. And that’s where a small tragicomedy happens. With types like String, List, Map, === can sometimes be useful, but with nullable numbers (Int?, Double?) you will almost certainly get behavior that looks like a bug.
We won’t go deep into “how this is stored in memory”, but the practical rule is simple: if you have Int?, then === checks not the value, but “is this exactly the same wrapper object or not”, and the answer will often be false, even when the numbers are the same.
fun main() {
val a: Int? = 1000
val b: Int? = 1000
println(a == b) // true
println(a === b) // often false (and this is normal)
}
Takeaway for real life: compare numbers with ==. === does not add “reliability”; it changes the meaning of the check, and you dig a hole for yourself, then heroically climb out of it.
5. Practical rules and safe techniques
When you read or write Kotlin code, it’s helpful to keep a simple heuristic in mind. In typical business logic you care about values: “is the command the same?”, “does the category match?”, “is the amount equal?”. That’s all about ==.
The === operator is a tool that is more often needed not for application logic, but for understanding “is this actually the same container?” or “why are changes leaking here?”.
If you want it really short: == is “equal by meaning”, === is “it’s the same object”.
How not to catch a “shared reference” bug in a practical app
In our simple CLI expense tracker we might run into a situation: we want to “prepare a list for output”, but accidentally take the very same list and start mutating it (for example, temporarily filtering, temporarily removing, temporarily adding test items).
To avoid sabotaging yourself, it’s convenient to adopt a rule: if you do temporary processing, work with a copy.
A function to “snapshot” the expense list
fun snapshotExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> {
// toList() creates a new list that can be safely displayed/processed separately
return expenses.toList()
}
And use it:
fun main() {
val expenses = mutableListOf("food" to 250, "taxi" to 120)
val snapshot = snapshotExpenses(expenses)
expenses.add("books" to 90)
println(snapshot) // [(food, 250), (taxi, 120)]
println(expenses) // [(food, 250), (taxi, 120), (books, 90)]
}
What matters here is that snapshot is a separate list. Even if the original list changes, the “snapshot” stays the same. This is useful when you want to show something stable to the user while the data continues to evolve.
Debugging technique: checking === to catch aliasing
Sometimes you suspect that “somewhere I ended up with one collection having two names”. In that case, === is your flashlight.
fun main() {
val expenses = mutableListOf("food" to 250)
val sameRef = expenses
val copy = expenses.toMutableList()
println(expenses === sameRef) // true
println(expenses === copy) // false
}
This check should almost never be part of business logic, but as diagnostics it’s excellent.
6. Common mistakes
Mistake #1: using === instead of == “just in case”.
It’s tempting to do this with good intentions: “well, === is stricter, so it must be more reliable”. But === is not “stricter”; it’s about something else. You start comparing not values, but object identity, and suddenly “equal” strings/numbers/results become “not equal”. A better habit: default to ==, and use === only when you truly want to check “the same object”.
Mistake #2: being surprised that mutating a list via one variable is visible via another.
This is not mysticism and not “Kotlin broke”. This is aliasing: you assigned b = a, and now b is the second name of the same collection. The remedy is not “forbid yourself assignments”, but to consciously distinguish between “I want a reference to the same container” and “I want a copy of the container”.
Mistake #3: thinking val makes a collection immutable.
val forbids reassigning the variable, but it does not forbid changing the contents of a mutable collection. This is a normal and useful design: you protect the reference, but you don’t prevent working with the data structure.
Mistake #4: checking null via complex constructs instead of simple x == null.
Sometimes after learning about null-safety, you want to write “three-story” checks: if (...) { ... } else { ... }, even when you just need to know whether null is there or not. == in Kotlin is safe for null, so x == null is readable and fine.
Mistake #5: dragging === into application logic (command conditions, filters, “already has such an element” checks).
Even if the code “works on your tests”, it will often be logically wrong: you’re comparing not meaning, but container identity. As soon as the data starts coming from somewhere else (a new list, a new object, a different string build), the behavior becomes weird. Meaningful checks should use ==.
GO TO FULL VERSION