1. The [] operator: reading and writing
Now let’s move on to what this was all about: how to get an element from an array and how to write it back. In Kotlin, the [] operator is the standard way to access array elements: a[i] reads a value, and a[i] = ... writes a new one.
Let’s start with the smallest example: read, change, read again.
fun main() {
val steps = intArrayOf(3000, 5000, 4500)
println(steps[0]) // 3000
steps[0] = 7000
println(steps[0]) // 7000
}
Two things are important to notice here.
First, arrays are mutable: their elements can be changed even if the array variable is declared as val. val prevents reassigning the reference to the array itself, but it does not prevent changing the contents of the “box”. This is normal behavior: “the box is the same, the items inside changed”.
Second, the assignment steps[0] = 7000 works only if the index is valid. If the index is invalid, the program will crash at runtime.
2. Indices and zero-based numbering
When you first see a[0], you might want to ask: “Why not a[1]? I’m a human, not a robot.” The answer is simple: historically, that’s how it ended up in almost all languages, and Kotlin is no exception. An index is an offset from the start of the array: the first element is at offset 0. It’s inconvenient for exactly the first two days, and then you start counting from zero even for notebook pages.
The main rule for arrays sounds like this:
- the first element of an array has index 0
- the last element of an array has index size - 1
- valid indices are from 0 to size - 1 inclusive
Kotlin strictly follows this model: array indexing starts at zero, and access/modification is done via the [] operator.
Let’s look at a short illustration. Imagine an array of three numbers:
values: [ 50, 10, 99 ]
indices: 0 1 2
size = 3
last index = size - 1 = 2
That’s why the expression a[3] for an array of length 3 is an attempt to access the fourth element, which doesn’t exist.
3. size: how many elements and where the boundary is
If an array has size elements, it’s tempting to think: “If the size is 3, then the indices are 1..3.” And this is where Kotlin tells you “nope”: size is the number of elements, not the “maximum index”.
If size = 3, then the indices are: 0, 1, 2. Let’s compare in a table:
of the array |
Valid indices | Last index |
|---|---|---|
|
none | — |
|
|
|
|
|
|
|
|
|
By the way, note size = 0 (an empty array): it has no valid indices at all. That’s why any attempt to do a[0] will result in an error. But such an array is still perfectly valid.
Mini example:
fun main() {
val a = intArrayOf(10, 20, 30)
println("size = ${a.size}") // size = 3
println("last index = ${a.size - 1}") // last index = 2
}
a.size - 1 is an expression you’ll write very often. First in programs, and then in real life.
4. indices: the correct index range
If you constantly write 0 and size - 1, it’s easy to make an off-by-one mistake. And “off by 1” bugs are an ancient programming curse that feeds on your deadlines :)
That’s why Kotlin provides a handy thing: a.indices.
indices is a ready-made set of all valid indices of the array. If the array length is 3, then indices roughly corresponds to 0..2. If the array is empty, indices will be an “empty range”, which is also convenient: many loops simply won’t do anything.
The best part: indices can be used both in loops and in checks.
Checking an index via in a.indices
Imagine the user enters a day-of-week number, and you want to write steps into an array. Users are creative creatures: today they enter 3, tomorrow 999, the day after tomorrow -5 (just to see “what happens”). So you need to validate the index.
fun main() {
val steps = intArrayOf(3000, 5000, 4500) // 3 days as an example
val i = 2
if (i in steps.indices) {
println("steps[$i] = ${steps[i]}") // steps[2] = 4500
} else {
println("Invalid index: $i")
}
}
This reads roughly like: “if i is within the valid indices of the steps array”.
And yes: in here is exactly a membership test: “is the value within the range”. This combo (in + indices) is one of the most basic techniques for safe array work.
Iterating over an array via for (i in a.indices)
Today we’re not diving deep into “sum/max/search” algorithms, but iterating by indices is already useful, because sometimes you need the index itself, not just the value.
fun main() {
val steps = intArrayOf(3000, 5000, 4500)
for (i in steps.indices) {
println("Day#$i: ${steps[i]}")
// Day#0: 3000
// Day#1: 5000
// Day#2: 4500
}
}
Why is this better than for (i in 0..steps.size - 1)? Because you don’t write the bounds by hand — fewer chances to make a mistake.
What happens when you go out of bounds
There are things Kotlin can validate ahead of time (for example, “you can’t add String and Int”). And there are things that depend on runtime data. Array indices are exactly the second case.
If you write:
val a = intArrayOf(1, 2, 3)
println(a[10])
The compiler may not detect your mistake. Maybe you’ll change the array later, maybe the index comes from input. So the code compiles, but the program crashes at runtime with an “array index out of bounds” exception.
5. Step tracker and safe updates
To make the examples more realistic, let’s come up with a small practice program that can be extended later as part of the arrays level. Let it be, again, a console weekly step tracker. Right now it’s as simple as possible: we have an array for 7 days, we ask the user for the day (index) and the new step count, and we update the array safely.
The key point: we are not trying to make a “perfect UI”, we’re not using commands or complex parsing — only what you already know.
fun main() {
val steps = IntArray(7) // by default all elements = 0
println("Enter the day index (0..6):")
val day = readln().toIntOrNull()
println("Enter the number of steps:")
val value = readln().toIntOrNull()
if (day == null || value == null) {
println("Error: you must enter integers.")
return
}
if (day in steps.indices) {
steps[day] = value
println("OK: steps[$day] = ${steps[day]}")
} else {
println("Error: day must be from 0 to 6.")
}
}
Notice the “double protection” style. First we check that the input parsed (toIntOrNull() did not return null). Then we check that the index is within bounds. This is not paranoia, it’s real life: the user can enter anything, including “kittens” and -100, and the program should at least not crash.
6. Bulk operations: fill() and copyOf()
Sometimes you need to perform a “batch” operation on an array: quickly reset values or, on the contrary, save a copy (snapshot) before changes. Kotlin provides simple but very useful methods for this.
fill(value): quickly fill an array with one value
Sometimes you need to overwrite an entire array: reset values, set a default, fill with “empty” data. You can do it manually with a loop, but it’s boring: programmers are expected to be constructively lazy.
That’s what fill(value) is for: it walks through the array and writes the same value into every element.
Imagine the scenario: we track steps for a week, but we want to “start over” (for example, a new challenge). Then the easiest thing is to reset:
fun main() {
val steps = intArrayOf(1200, 5000, 4300, 0, 8000, 6500, 2000)
steps.fill(0)
for (x in steps) {
print("$x ")
}
// 0 0 0 0 0 0 0
}
It’s important not to mix up expectations here: fill(0) does not change the size of the array, it only changes the element values.
If you fill an IntArray(7) with sevens, you’ll have seven elements, all equal to 7 — no magic:
fun main() {
val steps = IntArray(7)
steps.fill(7000)
println(steps[0]) // 7000
println(steps[6]) // 7000
}
This is one of those operations that makes code shorter and clearer: “fill the array with value X” reads better than a 7-line loop for the same idea.
copyOf(): array copy vs “second name”
Now comes one of the most important topics of this level — and one of the most “sneaky” for beginners.
When you write:
val a = intArrayOf(1, 2, 3)
val b = a
you do not copy the array. You create a second variable that points to the exact same array. It’s like two desktop shortcuts leading to the same folder. Delete a file via one shortcut — and it disappears via the other one too.
But copyOf() creates a new array that initially contains the same values, but exists independently.
Let’s look at the difference in code. First — just a reference without duplicating the array:
fun main() {
val original = intArrayOf(10, 20, 30)
val alias = original
alias[0] = 999
println("original[0] = ${original[0]}") // original[0] = 999
println("alias[0] = ${alias[0]}") // alias[0] = 999
}
Many people expect original to remain (10, 20, 30), but no: alias is not a copy, it’s the same array.
Now — a correct independent copy:
fun main() {
val original = intArrayOf(10, 20, 30)
val copy = original.copyOf()
copy[0] = 999
println("original[0] = ${original[0]}") // original[0] = 10
println("copy[0] = ${copy[0]}") // copy[0] = 999
}
In the step tracker, you can use this as a “week snapshot”: you save the old values, and then start changing the current ones.
Practice: array snapshot and week reset
Let’s combine two operations (copyOf() and fill()) into a small but practical scenario. Let the program do this: we have weekly steps, we create a copy as an “archive”, then reset the current week to zeros. In real life, this can be “a new week starts with a clean slate”.
fun main() {
val steps = intArrayOf(1200, 5000, 4300, 0, 8000, 6500, 2000)
val archive = steps.copyOf()
steps.fill(0)
print("archive: ")
for (x in archive) print("$x ")
println()
// archive: 1200 5000 4300 0 8000 6500 2000
print("steps: ")
for (x in steps) print("$x ")
println()
// steps: 0 0 0 0 0 0 0
}
7. Common mistakes
Mistake #1: thinking the last index equals size.
This is a classic: an array of 3 elements, you write a[3] and wonder why the program crashes. The rule is simple: size is the number of elements, and the last index is size - 1. If you don’t want to keep this in your head all the time, use a.indices for loops and checks so you don’t compute bounds manually.
Mistake #2: not validating an index that comes from input (or from a calculation).
As long as the index is “written by hand” and you’re sure it’s correct, everything is fine. But as soon as the index comes from a user or is computed in a more complex way, it can become negative or too large. Then accessing a[i] turns into a program crash. The habit if (i in a.indices) is not an “extra line”, but insurance against uncontrolled behavior.
Mistake #3: assuming that val b = a creates a copy of the array.
No, it’s just another name for the same array. Change b[0] — and a[0] changes too. If you need an independent copy, use copyOf(). This is especially important when you save “snapshots” of state: without copyOf() you’ll accidentally ruin the “archive” while changing the “current” data.
Mistake #4: trying to “print an array” via println(a) and expecting a list of elements.
For a beginner, this looks logical: “I’m printing a variable.” But by default you’ll see not the contents, but a technical object representation. The clearest way at this level to see the elements is to loop through them and print each one. Yes, it’s a bit longer, but you clearly understand what’s happening.
Mistake #5: expecting that fill() will change the array size or add elements.
fill(value) does not add or remove anything: it only overwrites existing elements. The array remains fixed-size — exactly as you created it in the beginning. If you want a structure “that grows”, that’s a separate topic for later; for now, keep the principle in mind: an array is a fixed box, and fill() only places identical items into it.