JavaRush /Courses /Kotlin SELF /Indexing operators get/set: obj[i], obj[i, j]

Indexing operators get/set: obj[i], obj[i, j]

Kotlin SELF
Level 38, Lesson 3
Available

1. Introduction

When you see code like list[0] or map["mode"], your brain barely spends any effort reading it: “got an element by index/key”. That is the power of indexers: they make data access short and familiar. But there is a flip side: the shorter the notation, the easier it is to hide unexpected rules inside.

That is why indexers are useful in two cases: when the action really looks like accessing a cell/element, and when you are ready to honestly describe the behavior on errors. Otherwise, a regular method like getById(...), which immediately hints “there may be logic here”, is better.

How Kotlin understands obj[i] and obj[i] = value

From the compiler’s point of view, an expression in square brackets is not a “special operation”, but simply a convenient notation for calling functions named get and set. And it is important to get used to this idea: you do not “turn on an indexer”; you write ordinary functions, but you mark them as operator, so the compiler allows the [] syntax.

Kotlin states it very straightforwardly: a[i] turns into a.get(i), and the assignment a[i] = b — into a.set(i, b); for multiple indices, the principle is the same.

Below is a mini “translation” table worth keeping in mind (this is literally a compiler rule, not someone’s opinion):

Written in code Compiles to
a[i]
a.get(i)
a[i, j]
a.get(i, j)
a[i] = b
a.set(i, b)
a[i, j] = b
a.set(i, j, b)

The same principle works for any number of indices: a[i1, i2, i3] → a.get(i1, i2, i3).

2. Indexers in the standard library: List and Map

Before writing your own get/set, it is useful to notice: we have been using them for a long time—we just did not call them “operator functions”.

List: has get, but no set

When you have a List<T>, you can read an element by index, but you cannot modify the list via [] =, because List is a read-only interface.

fun main() {
    val names: List<String> = listOf("Ann", "Bob")
    println(names[0]) // Ann
    // names[0] = "Kate" // compile error: List has no set
}

MutableList: set appears, so [] = works

fun main() {
    val names: MutableList<String> = mutableListOf("Ann", "Bob")
    names[1] = "Kate"
    println(names) // [Ann, Kate]
}

Your brain reads this as “replaced the element at the index”, and that is a good contract: no hidden logic, a pure replacement.

Map: indexer by key and null as part of the contract

With Map it gets more interesting, because reading by key typically returns V?, that is, null, if the key is missing. This is not an “annoyance”; it is the official contract.

fun main() {
    val ages: Map<String, Int> = mapOf("Ann" to 20)
    println(ages["Ann"])   // 20
    println(ages["Bob"])   // null
}

And with MutableMap, writing works too:

fun main() {
    val settings = mutableMapOf<String, String>()
    settings["mode"] = "debug"
    println(settings["mode"]) // debug
}

Notice: here set almost always means “put/replace the value by key”. It is predictable—and predictability is exactly what we will try to preserve when we implement our own indexers.

3. Our own indexers in the learning app BudgetBuddy

Now let’s integrate indexers into our practical console application. Let’s say it is called BudgetBuddy and stores a list of expenses. Previously, we most likely had functions like addExpense(...), listExpenses(), findById(...). Today we will add “convenient access” on top of the existing data model.

We will take three small steps: introduce the ExpenseId type, the Expense model, and the ExpenseBook container, which will support book[index] and book[id].

ExpenseId as a separate type

If both the index and the id are Int, it is easy to accidentally mix up the meaning. To make the code self-documenting, we will use a value class (you have seen it in previous topics): it is a “wrapper around a number”, but with its own type.

@JvmInline
value class ExpenseId(val value: Int)

Now ExpenseId(10) and just 10 are different entities, and the compiler will not let you accidentally “pass an index instead of an id”. It is like a badge at a conference: same face, but the label helps you avoid mixing people up.

Expense model

For simplicity, let the amount be stored in cents (this helps avoid Double issues). If you already have Money from earlier examples—great, but to avoid bloating the code, we will keep Long here.

data class Expense(
    val id: ExpenseId,
    val title: String,
    val amountCents: Long
)

ExpenseBook container and indexer by position

Let’s start with the most familiar option: index-based access like a list. Inside ExpenseBook we will have a MutableList<Expense>. The get(index) indexer must do a bounds check; otherwise you will crash somewhere deep inside, and the message will be less friendly.

class ExpenseBook {
    private val items = mutableListOf<Expense>()

    fun add(expense: Expense) {
        items.add(expense)
    }

    operator fun get(index: Int): Expense {
        require(index in items.indices) { "No expense with index $index" }
        return items[index]
    }
}

Now you can do this:

fun main() {
    val book = ExpenseBook()
    book.add(Expense(ExpenseId(1), "Coffee", 250))

    println(book[0]) // Expense(id=ExpenseId(value=1), title=Coffee, amountCents=250)
}

The key point: book[0] reads as “the first expense”, and that is true.

set(index, value): replace by index

If we want book[0] = ..., we need operator fun set(index, value).

class ExpenseBook {
    private val items = mutableListOf<Expense>()

    fun add(expense: Expense) { items.add(expense) }

    operator fun set(index: Int, value: Expense) {
        require(index in items.indices) { "Cannot write to index $index" }
        items[index] = value
    }
}

Usage:

fun main() {
    val book = ExpenseBook()
    book.add(Expense(ExpenseId(1), "Coffee", 250))

    book[0] = Expense(ExpenseId(1), "Coffee (large)", 350)
    println(book[0].title) // Coffee (large)
}

And this is where it is important not to “outsmart” yourself: [] = is almost always expected to mean replacement. If you make it so that book[0] = x “adds a new element to the end” or “adds values together”, the user of your class will be completely confused. Yes, you can do it formally, but it is like a hot-water tap that sometimes pours coffee: unexpected and bad for your sanity.

4. Contracts, boundaries, and indexer design

The most practical part of indexers is not operator, but the decision: how your type behaves on error. In the Kotlin world there are several familiar styles, and it is important to pick one and be consistent.

Fail-fast or nullable

Fail-fast means: if the index is invalid, throw an exception via require(...). This is a good approach when an invalid index is a programmer error and it is better to crash immediately with a clear message.

The nullable approach means: if the element is missing, return null. This is a good approach when the absence of an element is normal (as with Map[key]).

In a table, it looks like this:

Approach get signature What happens when missing
Fail-fast
operator fun get(index: Int): Expense
exception (IllegalArgumentException via require)
Nullable
operator fun get(id: ExpenseId): Expense?
returns null

Often it makes sense to have both variants, but for different “index types”. By list index, fail-fast is usually expected because the index is almost always computed by code. By id, returning null is often more convenient because “there is no such id” may be a normal search result.

Indexer by ExpenseId: book[id]

Let’s add lookup by id to ExpenseBook. Here we will use a nullable contract (like Map).

class ExpenseBook {
    private val items = mutableListOf<Expense>()

    fun add(expense: Expense) { items.add(expense) }

    operator fun get(id: ExpenseId): Expense? =
        items.firstOrNull { it.id == id }
}

Usage:

fun main() {
    val book = ExpenseBook()
    book.add(Expense(ExpenseId(1), "Coffee", 250))

    println(book[ExpenseId(1)]?.title) // Coffee
    println(book[ExpenseId(2)]?.title) // null
}

Pay attention to ?.title: since get returns Expense?, we must handle null safely. And that is not the compiler being pedantic—it is protection against the situation “I was sure the element existed, but it does not”.

set by id: be careful with expectations

It is very tempting to make book[id] = expense and implement “if it exists—replace it; if not—add it” inside. Such an upsert is sometimes convenient, but it has an unpleasant side effect: assignment stops being obvious. The reader cannot tell whether we are updating an existing entry or creating a new one.

In learning code, I recommend a stricter but honest contract: set(id, value) updates only an existing element and fails if there is no such id. And we keep creating a new element for add(...).

class ExpenseBook {
    private val items = mutableListOf<Expense>()

    fun add(expense: Expense) { items.add(expense) }

    operator fun set(id: ExpenseId, value: Expense) {
        require(value.id == id) { "value.id must match the id key" }

        val index = items.indexOfFirst { it.id == id }
        require(index >= 0) { "No expense with id=${id.value}" }

        items[index] = value
    }
}

Usage:

fun main() {
    val book = ExpenseBook()
    book.add(Expense(ExpenseId(1), "Coffee", 250))

    book[ExpenseId(1)] = Expense(ExpenseId(1), "Coffee (large)", 350)
    println(book[ExpenseId(1)]?.amountCents) // 350
}

Yes, it is a bit more code. But now assignment has a normal meaning: “update an existing record”. And it trains contract discipline very well.

How to design an indexer so it does not become a riddle

An indexer is an expressiveness tool, but it can easily turn into a “trick for the sake of a trick”. It helps to lock in a few practical principles.

A good indexer almost always behaves like container access: reading should not change state, writing should be writing, not a “complex operation disguised as assignment”. Bounds checks should either be explicit via require(...) with a good message, or the contract should directly promise null/a default. If you do something third (for example, “on an invalid index return the last element”), it may be convenient exactly once, and after that a group of people with flashlights will be investigating it.

It is useful to mentally substitute the compiler translation: if your code looks like obj[i], the reader thinks “get”; if it looks like obj[i] = v, they think “set”. And semantically, set is assigning into a cell, not “add, merge, recalculate the report, and email the accountant”.

5. Multiple indices: obj[i, j]

When people first see obj[i, j], many have a micro-shock: “is this a two-dimensional array?” Sometimes yes, sometimes no. In Kotlin, square brackets with two indices are just a call to get(i, j) or set(i, j, value).

The clearest example is a table, grid, or matrix. In BudgetBuddy, you can imagine we have a “spending plan”: categories × months, and we want to access it as plan[category, month].

Categories as an enum

enum class Category {
    FOOD, TRANSPORT, FUN
}

BudgetPlan: limits by (category, month) pair

To keep the example short, let month just be an index 1..12 (no dates). Internally, we store a LongArray for each category.

class BudgetPlan {
    private val limits = mutableMapOf<Category, LongArray>()

    operator fun get(category: Category, month: Int): Long {
        require(month in 1..12) { "Month must be 1..12" }
        val arr = limits[category] ?: LongArray(12)
        return arr[month - 1]
    }
}

Usage:

fun main() {
    val plan = BudgetPlan()
    println(plan[Category.FOOD, 1]) // 0
}

Right now, reading always returns 0 for an “empty” plan—this is a valid contract if you consider the absence of a limit equivalent to zero. But it is important to say out loud: this is a business decision, not syntax.

set(category, month, value)

class BudgetPlan {
    private val limits = mutableMapOf<Category, LongArray>()

    operator fun set(category: Category, month: Int, value: Long) {
        require(month in 1..12) { "Month must be 1..12" }
        require(value >= 0) { "Limit cannot be negative" }

        val arr = limits.getOrPut(category) { LongArray(12) }
        arr[month - 1] = value
    }
}

Usage:

fun main() {
    val plan = BudgetPlan()
    plan[Category.FOOD, 1] = 30_000
    println(plan[Category.FOOD, 1]) // 30000
}

Why index order is part of the contract

It is easy to make the “classic UI dev mistake” here: swap month and category. The compiler will not help because the types are different (and that is good), but in a more general case the indices may have the same type, for example Int, Int as (x, y)—and then the confusion becomes a real threat.

So the rule is very simple: pick the parameter order once (for example, (category, month) or (x, y)) and stick to it everywhere. If your type is logically a “table of rows and columns”, it is usually convenient to fix either “key first, coordinate second” or the other way around—but the key is to be consistent across the entire project.

6. Typical mistakes when working with indexers

Mistake #1: you forgot the operator modifier on get or set.
This mistake looks like: “why doesn’t obj[i] compile, I wrote fun get(i: Int)”. The answer is simple: for square brackets, the compiler looks for an operator function specifically. Without operator, it is just a regular method that you can only call as obj.get(i).

Mistake #2: get and set have different bounds contracts.
Sometimes get carefully checks the index via require(...), while set directly indexes into the array and crashes with IndexOutOfBoundsException. As a result, you get “either the library is broken, or I am”. Contracts should be symmetric: if you cannot read at an index, you should not be able to write either, and the error message should be equally clear.

Mistake #3: set performs an unexpected operation instead of a write.
It is very tempting to make book[id] = expense and implement “if it wasn’t there—add it” inside. And then someone writes book[id] = book[id]!! trying to “change nothing”, but you suddenly change element order or create a duplicate. Bracket assignment should mean writing into a cell; otherwise reading code turns into guesswork.

Mistake #4: index order is not fixed, and both variants appear in the project.
One day you write grid[x, y], another day—grid[row, col], and a week later you do grid[y, x], because “that’s how it is in matrices”. This is not a joke; it is a classic bug that later looks like “everything computes, but the answers are weird”. Index order is part of the API, and you should standardize it immediately.

Mistake #5: missing consistency checks in set.
If you implement set(id, value) to update a record, it is useful to verify that value.id == id. Without that, you can accidentally store “an expense with id=7” under the key ExpenseId(3) and end up with silent data corruption. These checks feel pedantic right up until you catch the bug “why does the March report show expenses from August”.

Mistake #6: the indexer is used for heavy computations and side effects.
Because obj[i] looks like a lightweight operation, the reader expects it to be O(1) or close to it, and definitely without surprises. If, inside get, you do sorting, recalculate a report, or log, you will get very odd slowdowns and even stranger call sites. For heavy actions, a method name that is honest is better: recalculateTotals() is longer, but it does not lie.

1
Task
Kotlin SELF, level 38, lesson 3
Locked
Banner character
Banner character
1
Task
Kotlin SELF, level 38, lesson 3
Locked
Tournament scoreboard
Tournament scoreboard
1
Task
Kotlin SELF, level 38, lesson 3
Locked
Mode settings
Mode settings
1
Task
Kotlin SELF, level 38, lesson 3
Locked
Lab grid
Lab grid
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION