1. Why arrays suddenly stopped being enough
If you’re thinking right now: “Why do I need some other collections when I already have arrays?” — that’s a normal reaction. Arrays really are great for tasks where the number of elements is known in advance and barely changes. But as soon as you start writing a program “for real life,” not “for a lab assignment,” something unpleasant becomes clear: real life doesn’t like fixed sizes.
Imagine you’re making a console mini expense tracker (we’ll keep developing this app later in the course). How many entries will there be? 5? 50? 500? You don’t know. And if you choose an array, you either set some huge size “just in case,” or you keep recreating the array all the time. Both options are so-so: one wastes memory and looks weird, the other turns your code into a sport of carrying boxes around.
An array as a box with numbered cells
It’s convenient to think of an array as a box with cells, where each cell has a number (an index). The box is created at once “for N cells,” and you can’t enlarge it: you can only make a new, bigger box and move all the contents there. In Kotlin, arrays are always mutable: you can replace an element via arr[i] = ..., but the size stays the same. This is important to remember, because a “mutable array” is not a “growing array.”
Here’s a minimal example that shows an array “keeps its size”:
fun main() {
val arr = arrayOf(1, 2, 3)
println(arr.size) // 3
arr[0] = 10
println(arr[0]) // 10
}
Here we changed the value, but the size was 3 and stayed 3.
How we used to “expand” an array and why it’s annoying
When you need to “add an element” to an array, the typical path is to create a new array of a larger size and copy the old one into it. Even if you do it carefully, your brain is constantly busy not with the program’s task but with memory bookkeeping: “how many slots,” “where to copy,” “don’t forget the index.”
An example in the “added one element” style (the idea, not the final app style):
fun main() {
var names = arrayOf("Ann", "Bob")
println(names.contentToString()) // [Ann, Bob]
names = names.copyOf(names.size + 1)
names[names.lastIndex] = "Cara"
println(names.contentToString()) // [Ann, Bob, Cara]
}
Does it work? Yes. Is it convenient? Not really. And most importantly: as soon as there are many such “additions,” the code starts to look like you’re writing not an expense tracker, but an array relocation manager.
2. Collections: variable-size containers
Collections are the next logical step: containers that can hold a variable number of elements (zero, a thousand, whatever), and at the same time provide convenient operations for adding, removing, and searching.
With collections, you more often think “what do I want to do with the data,” not “how do I manually move the data into a new box.” This is one of those moments when a programming language really makes life easier, rather than just adding new words to your vocabulary.
Three basic forms: List, Set, Map
When people say “collections,” they usually mean three big families: list, set, and map.
To make it easier to choose “by meaning,” keep this table in view:
| Type | What it stores | What matters | Duplicates | Typical use |
|---|---|---|---|---|
|
elements | order and index | allowed | expense list, task list, event history |
|
elements | uniqueness | not stored separately | set of commands, set of categories, “already seen” |
|
key → value pairs | access by key | keys are unique | settings, dictionary, “id → object,” counters |
Right now we are not going deep into traversal, sorting, and “nice collection operations” — that will come later. Today’s task is simpler: understand why collections are not the same as arrays, how List/Set/Map differ, and learn how to create them.
An important nuance: arrays are not collections
This is a subtlety that unexpectedly saves you from confusion: in Kotlin, arrays are considered a separate entity. The documentation even has a direct note that arrays are not a collection type.
Practically, this means two things. First, arrays have their own set of capabilities and functions, and collections have theirs. Second, when you read code, it’s important not to think “an array and a list are the same thing, just written differently.” They look similar on the surface (both have elements), but the usage logic often differs.
3. Read-only vs mutable: why Kotlin separates “read” and “change”
Kotlin has an important idea: “the type tells you what you can do with it.” That’s why collections are split into read-only interfaces and mutable interfaces. And this isn’t just “a love of making a student’s life harder,” but a practical protection against accidental data changes.
In the standard library, for each basic collection kind there is a pair of interfaces: read-only (read only) and mutable (read + change). That’s exactly why these pairs exist: List/MutableList, Set/MutableSet, Map/MutableMap. It looks like “two almost identical words,” but their meaning is different.
A read-only collection means “you can’t change it through this reference”
A read-only type (List, Set, Map) means: through this variable you don’t have mutation operations (add, remove, assignment by index, etc.). You can read the size, get elements, check containment, but you can’t “rebuild the container.”
This is very useful when you want to explicitly say: “here we only read.” For example, a function receives a list of expenses and should only compute the sum (not “accidentally delete half the expenses during the calculation,” because someone mixed up methods).
A “read-only” example looks like this:
fun main() {
val names: List<String> = listOf("Ann", "Bob", "Ann")
println(names.size) // 3
println(names) // [Ann, Bob, Ann]
}
Here names is a read-only list. It can contain duplicates, and the element order is preserved.
A mutable collection means “you can change the contents”
A mutable type (MutableList, MutableSet, MutableMap) means: you can add, remove, and update elements.
The most basic example:
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers.add(4)
println(numbers) // [1, 2, 3, 4]
}
This is where the magic happens, which is why we came here: the list grew without recreating the box.
val and “mutability inside”: a common source of confusion
A very important rule that breaks your brain exactly once (and then becomes obvious): val prevents you from reassigning the variable, but it does not prevent you from changing the object the variable references.
Look at the example and feel the difference:
fun main() {
val xs = mutableListOf("one", "two", "three")
xs.add("four") // allowed
println(xs) // [one, two, three, four]
// xs = mutableListOf("X") // not allowed: val cannot be reassigned
}
This is a super-popular style: “the collection is mutable, but the variable is val.” This way you say: “the container can change, but the container itself (as an object) won’t be accidentally replaced with another one.”
Read-only is not “absolutely immutable in the entire universe”
One more subtle point: a read-only type does not promise that the data never changes. It only promises that via this particular reference you can’t call mutation methods.
If somewhere there is another reference to the same mutable collection, changes can happen “from the other side.” At this stage of the course we don’t need to dive into complex topics about “shared references,” but it’s useful to remember the intuition: read-only is an API restriction, not a magical dome of immutability.
4. Creating collections: listOf, mutableListOf, setOf, mapOf
Up to this point, we created arrays via arrayOf(...) or IntArray(...). With collections it’s even easier: Kotlin has factory functions that create the desired type from values. It reads almost like plain text: “create a list from these elements.”
And yes, these functions are used constantly: you will see them in examples, in documentation, and in real code. So it’s important to get used to them now, before we start building logic around collections.
listOf(...) and mutableListOf(...)
listOf(...) creates a read-only List. This is convenient when you have a “set of values” you don’t plan to change.
mutableListOf(...) creates a MutableList that you can grow and shrink.
An example side by side for comparison:
fun main() {
val readOnly = listOf("A", "B", "C")
println(readOnly) // [A, B, C]
val mutable = mutableListOf("A", "B", "C")
mutable.add("D")
println(mutable) // [A, B, C, D]
}
A practical tip for the future (without fanaticism): if you’re not sure you need to change it, start with read-only. Use mutable when changes are actually needed.
setOf(...) and mutableSetOf(...)
setOf(...) creates a set of unique elements. If you accidentally repeat an element, the set won’t become “longer,” because it’s about uniqueness.
A basic example:
fun main() {
val tags = setOf("kotlin", "kotlin", "jvm")
println(tags) // for example: [kotlin, jvm]
println(tags.size) // 2
}
Notice: we don’t use the output order as part of the logic. We treat a Set as “a bag of unique values.”
And here is the mutable variant:
fun main() {
val commands = mutableSetOf("add", "list")
commands.add("remove")
commands.add("add") // duplicate
println(commands) // [add, list, remove] (duplicate wasn't added)
}
mapOf(...), mutableMapOf(...) and the infix function to
A map (Map) stores “key → value” pairs. Keys are unique.
Creating a map is convenient via to:
fun main() {
val prices = mapOf(
"tea" to 90,
"coffee" to 150
)
println(prices) // {tea=90, coffee=150}
}
With mutableMapOf you can change and add values:
fun main() {
val stock = mutableMapOf("apple" to 3)
stock["apple"] = 4
stock["banana"] = 1
println(stock) // {apple=4, banana=1}
}
A small heads-up in advance: reading map[key] may return null if the key is missing. That’s a normal safety model. We’ll cover this in detail, calmly, in a separate lecture about Map; for now just keep it in mind as a fact so it doesn’t surprise you.
5. Empty collections and type inference: why the compiler asks for a hint
When you create a non-empty collection, Kotlin usually infers the element type automatically. But if the collection is empty, the compiler has “nothing to grab onto”: it doesn’t see a single element and can’t know what type it should be. That’s why empty collections are often created with an explicit type.
This is an important practical point: you will create empty collections all the time, especially in apps where data accumulates as the program runs.
Non-empty collection: the type is inferred automatically
An example where Kotlin understands the type on its own:
fun main() {
val a = listOf(1, 2, 3) // List<Int>
val b = setOf("kotlin", "jvm") // Set<String>
val c = mapOf("a" to 1) // Map<String, Int>
println(a) // [1, 2, 3]
println(b) // [kotlin, jvm]
println(c) // {a=1}
}
Everything is simple here: the elements provided the type.
Empty collection: you need to specify the type
Now let’s create empty collections correctly:
fun main() {
val items = mutableListOf<String>()
val categories = mutableSetOf<String>()
val counts = mutableMapOf<String, Int>()
items.add("milk")
categories.add("food")
counts["milk"] = 1
println(items) // [milk]
println(categories) // [food]
println(counts) // {milk=1}
}
Notice: in each line, <String> or <String, Int> is not “extra noise,” but a hint to the compiler, without which it isn’t required to guess.
6. A mini sketch of an app: why we need collections in real life
From this day in the course, we start assembling a practical console application (no classes and no OOP at this stage). So it doesn’t feel like “collections for the sake of collections,” let’s tie everything to a simple real-life task: storing expense entries entered by the user. Today we’ll do only the very beginning: in-memory storage and a couple of actions. Commands, input normalization, and full logic will be covered later — for now it’s important to see that collections solve the size problem.
For now, let’s represent an expense entry as just a string (like "food coffee 300"). Yes, this isn’t an ideal data model yet — but at the current knowledge level it’s an honest and workable temporary format.
Array version: inconvenient and noisy
Here’s what “collecting expenses” would look like with an array (simplified):
fun main() {
var expenses = emptyArray<String>()
println("Enter an expense (or an empty line to exit):")
while (true) {
val line = readln().trim()
if (line.isEmpty()) break
expenses = expenses.copyOf(expenses.size + 1)
expenses[expenses.lastIndex] = line
}
println("Saved entries: ${expenses.size}") // e.g.: 2
}
It works, but it feels like we’re not dealing with expenses, but repackaging data.
Version using MutableList: shorter, cleaner, closer to the intent
And now the same thing with collections:
fun main() {
val expenses = mutableListOf<String>()
println("Enter an expense (or an empty line to exit):")
while (true) {
val line = readln().trim()
if (line.isEmpty()) break
expenses.add(line)
}
println("Saved entries: ${expenses.size}") // e.g.: 2
}
The difference is not only in the number of lines. The difference is what you’re thinking about while writing the code. Here, in your head it’s “add an entry,” not “recreate an array and copy.” And that’s exactly why collections are loved in applied code.
How to quickly choose a container: a simple scheme for beginners
At the beginning, it’s easy to get stuck: “so what do I choose? a list? a set? a map?” That’s normal. Let’s lock in a very simple choice model, without diving into performance details and internal implementation.
If the order of elements matters and duplicates are allowed, it’s almost always a List. If you need uniqueness of values, use a Set. If you want to store a “key → value” mapping and retrieve by key, use a Map.
You can even picture it as a mini flowchart:
flowchart TD
A["Need to store multiple values"] --> B{"Need access by key?"}
B -->|Yes| M["Map⟨K, V⟩"]
B -->|No| C{"Need uniqueness?"}
C -->|Yes| S["Set⟨T⟩"]
C -->|No| L["List⟨T⟩"]
This scheme isn’t “higher math,” but it helps you make a quick decision in most everyday cases.
7. Typical mistakes when first getting to know collections
Mistake #1: confusing read-only and immutable (“if you can’t change it through List, then nobody changes it at all”).
Read-only types restrict operations through a specific reference, but they don’t guarantee that somewhere else there isn’t a mutable reference to the same data. At the start of the course, it’s enough to remember a simple rule: read-only is about the API and available methods, not about magical immutability of the world.
Mistake #2: keeping a mutable collection in a var just “in case.”
Beginners often write var list = mutableListOf(...), because “well, if it changes, it must be var.” In reality, changes happen through the collection’s methods (add, remove), not by reassigning the variable. So a convenient and safe style is val list = mutableListOf(...): this way you won’t accidentally replace the collection with another one.
Mistake #3: creating an empty collection without a type and being surprised by a compilation error.
When you write mutableListOf() with no elements, the compiler is not required to guess what MutableList of what exactly you want. The remedy is simple: specify the type explicitly (mutableListOf<String>(), mutableMapOf<String, Int>()) or set the variable’s expected type.
Mistake #4: treating Set as “a list without indexes, but otherwise the same.”
A set has a different meaning: uniqueness. If you start expecting from a Set “the first element,” “the second element,” “insertion order,” you’re building fragile logic. At today’s topic level, we treat a Set as “a set of unique values,” and that’s enough.
Mistake #5: trying to “update” listOf/setOf/mapOf as if they were mutable.
The factories listOf, setOf, mapOf create read-only collections. If you know in advance you’ll be changing the contents, choose mutableListOf, mutableSetOf, mutableMapOf. This isn’t a whim; it’s a way to make the code’s intent clear without comments.
Mistake #6: forgetting that arrays and collections are different entities.
Sometimes a beginner sees arrayOf(...), then sees listOf(...) and thinks it’s “the same thing, just a different word.” But arrays are a separate type with their own rules (for example, arrays are always mutable by elements). When you choose a container, you also choose a style of working with data, so it’s better to keep the distinction in mind from the start.