JavaRush /Курсы /Kotlin SELF /Индексаторы get/set: obj[i], obj[i, j]

Индексаторы get/set: obj[i], obj[i, j]

Kotlin SELF
38 уровень , 3 лекция
Открыта

1. Введение

Когда вы видите код вида list[0] или map["mode"], мозг почти не тратит энергию на чтение: «взяли элемент по индексу/ключу». В этом сила индексаторов — они делают доступ к данным коротким и привычным. Но есть и обратная сторона: чем короче запись, тем опаснее спрятать внутрь неожиданные правила.

Поэтому индексаторы полезны в двух случаях: когда действие действительно похоже на доступ к ячейке/элементу, и когда вы готовы честно описать поведение на ошибках. Иначе лучше обычный метод getById(...), который сразу намекает: «тут может быть логика».

Как Kotlin понимает obj[i] и obj[i] = value

С точки зрения компилятора выражение в квадратных скобках — это не «особая операция», а просто удобная форма записи вызова функций с именами get и set. И тут важно привыкнуть к мысли: вы не «включаете индексатор», вы пишете обычные функции, но помечаете их operator, чтобы компилятор разрешил использовать синтаксис [].

Kotlin формулирует это прямолинейно: a[i] превращается в a.get(i), а присваивание a[i] = b — в a.set(i, b); для нескольких индексов принцип тот же.

Ниже — мини-таблица «перевода», которую стоит держать в голове (это буквально правило компилятора, не чьё-то мнение):

Запись в коде Во что компилируется
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)

Этот же принцип работает для любого количества индексов: a[i1, i2, i3]a.get(i1, i2, i3).

2. Индексаторы в стандартной библиотеке: List и Map

Прежде чем писать свои get/set, полезно заметить: мы ими пользуемся давно, просто не называли это «операторными функциями».

List: есть get, но нет set

Когда у вас List<T>, вы можете читать элемент по индексу, но не можете менять список через [] =, потому что List — read-only интерфейс.

fun main() {
    val names: List<String> = listOf("Ann", "Bob")
    println(names[0]) // Ann
    // names[0] = "Kate" // ошибка компиляции: у List нет set
}

MutableList: появляется set, значит работает [] =

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

Ваша голова читает это как «заменили элемент по индексу», и это хороший контракт: никакой скрытой логики, чистая замена.

Map: индексатор по ключу и null как часть контракта

С Map интереснее, потому что чтение по ключу обычно возвращает V?, то есть null, если ключа нет. Это не «неприятность», это официальный контракт.

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

А с MutableMap работает и запись:

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

Заметьте: здесь set почти всегда означает «положить/заменить значение по ключу». Это предсказуемо — и именно предсказуемость мы будем пытаться сохранять, когда сделаем свои индексаторы.

3. Свои индексаторы в учебном приложении BudgetBuddy

Теперь давайте встроим индексаторы в наше практическое консольное приложение. Пусть оно называется BudgetBuddy и хранит список расходов. Раньше мы, скорее всего, делали функции вида addExpense(...), listExpenses(), findById(...). Сегодня добавим «удобный доступ» поверх уже существующей модели данных.

Сделаем три маленьких шага: заведём тип ExpenseId, модель Expense, и контейнер ExpenseBook, который будет поддерживать book[index] и book[id].

ExpenseId как отдельный тип

Если и индекс, и id — это Int, то легко случайно перепутать смысл. Чтобы код стал самодокументируемым, используем value class (он уже знаком по предыдущим темам): это «обёртка над числом», но со своим типом.

@JvmInline
value class ExpenseId(val value: Int)

Теперь ExpenseId(10) и просто 10 — разные сущности, и компилятор не даст вам случайно «вставить индекс вместо id». Это как бейджик на конференции: лицо то же, но надпись помогает не путать людей.

Модель расхода Expense

Для простоты пусть сумма хранится в копейках/центах (так мы избегаем проблем Double). Если у вас уже есть Money из прошлых примеров — отлично, но чтобы не раздувать код, здесь оставим Long.

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

Контейнер ExpenseBook и индексатор по позиции

Начнём с самого знакомого: доступ по индексу как у списка. Внутри ExpenseBook будет MutableList<Expense>. Индексатор get(index) обязан делать проверку границ, иначе вы получите падение где-нибудь глубоко внутри, и сообщение будет менее дружелюбным.

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) { "Нет расхода с индексом $index" }
        return items[index]
    }
}

Теперь можно так:

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

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

Ключевой момент: book[0] читается как «первый расход», и это правда.

set(index, value): замена по индексу

Если мы хотим book[0] = ..., нужен 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) { "Нельзя записать по индексу $index" }
        items[index] = value
    }
}

Использование:

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)
}

И вот здесь важно не «перемудрить»: [] = почти всегда ожидается как замена. Если вы сделаете так, что book[0] = x будет «добавлять новый элемент в конец» или «суммировать значения», пользователь класса будет в полном недоумении. Да, формально можно, но это будет похоже на кран горячей воды, из которого иногда льётся кофе: неожиданно и опасно для психики.

4. Контракты, границы и дизайн индексаторов

Самая практическая часть индексаторов — не operator, а решение: как ваш тип ведёт себя на ошибке. В Kotlin-мире есть несколько привычных стилей, и важно выбрать один и быть последовательным.

Fail-fast или nullable

Fail-fast означает: если индекс неверный — бросаем исключение через require(...). Это хороший подход, когда неверный индекс — это ошибка программиста, и лучше упасть сразу с понятным сообщением.

Nullable-подход означает: если элемента нет — возвращаем null. Это хороший подход, когда отсутствие элемента — нормальная ситуация (как у Map[key]).

В таблице это выглядит так:

Подход Сигнатура get Что происходит при отсутствии
Fail-fast
operator fun get(index: Int): Expense
исключение (IllegalArgumentException через require)
Nullable
operator fun get(id: ExpenseId): Expense?
возвращается null

Часто имеет смысл иметь оба варианта, но для разных «типов индекса». По индексу списка обычно ожидается fail-fast, потому что индекс почти всегда вычисляет код. По id часто удобнее вернуть null, потому что «такого id нет» может быть результатом поиска.

Индексатор по ExpenseId: book[id]

Добавим в ExpenseBook поиск по id. Здесь сделаем nullable-контракт (как у 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 }
}

Использование:

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

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

Обратите внимание на ?.title: раз get возвращает Expense?, мы обязаны работать с null безопасно. И это не занудство компилятора — это защита от ситуации «я был уверен, что элемент есть, а его нет».

set по id: осторожно с ожиданиями

Очень хочется сделать book[id] = expense и внутри «если есть — заменить, если нет — добавить». Такой upsert иногда удобен, но у него неприятный побочный эффект: присваивание перестаёт быть очевидным. Читатель не понимает: мы обновляем существующее или создаём новое?

В учебном коде я рекомендую более строгий, но честный контракт: set(id, value) обновляет только существующий элемент и падает, если такого id нет. А создание нового элемента оставляем для 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 должен совпадать с ключом id" }

        val index = items.indexOfFirst { it.id == id }
        require(index >= 0) { "Нет расхода с id=${id.value}" }

        items[index] = value
    }
}

Использование:

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
}

Да, чуть больше кода. Зато у присваивания появляется нормальный смысл: «обновить существующую запись». И это отлично тренирует дисциплину контрактов.

Как проектировать индексатор так, чтобы он не стал ребусом

Индексатор — это инструмент выразительности, но он легко превращается в «трюк ради трюка». Здесь полезно зафиксировать несколько практических принципов.

Хороший индексатор почти всегда ведёт себя как доступ к контейнеру: чтение не должно менять состояние, запись должна быть именно записью, а не «сложной операцией под прикрытием присваивания». Проверка границ должна быть либо явной через require(...) с нормальным сообщением, либо контракт должен прямо обещать null/дефолт. Если вы делаете что-то третье (например, «на неверном индексе возвращаем последний элемент»), то это может быть удобно ровно один раз, а потом это будет расследовать группа людей с фонарями.

Полезно мысленно подставлять перевод компилятора: если ваш код выглядит как obj[i], значит читатель думает «get», если obj[i] = v, он думает «set». А set по смыслу — это присваивание в ячейку, а не «добавь, объедини, пересчитай отчёт и отправь письмо бухгалтеру».

5. Несколько индексов: obj[i, j]

Когда люди впервые видят obj[i, j], у многих случается микро-шок: «это что, двумерный массив?» Иногда да, иногда нет. В Kotlin квадратные скобки с двумя индексами — это просто вызов get(i, j) или set(i, j, value).

Самый понятный пример — таблица, сетка или матрица. В BudgetBuddy можно представить, что у нас есть «план расходов»: категории × месяцы, и мы хотим обращаться как plan[category, month].

Категории как enum

enum class Category {
    FOOD, TRANSPORT, FUN
}

BudgetPlan: лимиты по паре (категория, месяц)

Чтобы пример был коротким, пусть месяц — это просто индекс 1..12 (без дат). Внутри храним LongArray для каждой категории.

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

    operator fun get(category: Category, month: Int): Long {
        require(month in 1..12) { "Месяц должен быть 1..12" }
        val arr = limits[category] ?: LongArray(12)
        return arr[month - 1]
    }
}

Использование:

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

Сейчас чтение всегда даёт 0 для «пустого» плана — это нормальный контракт, если вы считаете отсутствие лимита эквивалентом нулю. Но важно проговорить: это уже бизнес-решение, не синтаксис.

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) { "Месяц должен быть 1..12" }
        require(value >= 0) { "Лимит не может быть отрицательным" }

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

Использование:

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

Почему порядок индексов — часть контракта

Здесь легко сделать «вечную ошибку UI-шника»: перепутать month и category местами. Компилятор не поможет, потому что типы разные (и это хорошо), но в более общем случае индексы могут быть одного типа, например Int, Int как (x, y) — и тогда путаница становится реальной угрозой.

Поэтому правило очень простое: выберите порядок параметров один раз (например, (category, month) или (x, y)) и держитесь его везде. Если ваш тип логически «таблица строк и столбцов», то обычно удобно фиксировать «сначала столбец-ключ, потом координата-номер» или наоборот, но главное — одинаково во всём проекте.

6. Типичные ошибки при работе с индексаторами

Ошибка №1: забыли модификатор operator у get или set.
Эта ошибка выглядит как «почему obj[i] не компилируется, я же написал fun get(i: Int)». Ответ простой: для квадратных скобок компилятор ищет именно операторную функцию. Без operator это будет обычный метод, который можно вызвать только как obj.get(i).

Ошибка №2: get и set имеют разные контракты по границам.
Иногда get аккуратно проверяет индекс через require(...), а set напрямую лезет в массив и падает IndexOutOfBoundsException. В итоге вы получаете «то ли библиотека сломалась, то ли я». Контракты должны быть симметричными: если по индексу нельзя читать — нельзя и писать, и сообщение об ошибке должно быть одинаково понятным.

Ошибка №3: set делает неожиданную операцию вместо записи.
Очень соблазнительно сделать book[id] = expense и внутри «если не было — добавим». А потом кто-то пишет book[id] = book[id]!! в попытке “ничего не поменять”, а вы внезапно меняете порядок элементов или создаёте дубликат. Присваивание в скобках должно означать запись в ячейку, иначе чтение кода превращается в угадайку.

Ошибка №4: порядок индексов не зафиксирован, и в проекте появляются оба варианта.
В один день вы пишете grid[x, y], в другой — grid[row, col], а через неделю делаете grid[y, x], потому что «так же в матрицах». Это не шутка, это классическая ошибка, которая потом выглядит как «у нас всё считается, но ответы странные». Порядок индексов — часть API, и его нужно стандартизировать сразу.

Ошибка №5: отсутствие проверки согласованности данных в set.
Если вы делаете set(id, value) для обновления записи, полезно проверить, что value.id == id. Без этого можно случайно записать «расход с id=7» по ключу ExpenseId(3) и получить тихую порчу данных. Такие проверки кажутся занудными ровно до того момента, пока вы не ловите баг «почему отчёт за март показывает расходы из августа».

Ошибка №6: индексатор используется для тяжёлых вычислений и побочных эффектов.
Поскольку obj[i] выглядит как лёгкая операция, читатель ожидает, что это O(1) или около того, и точно без сюрпризов. Если внутри get вы делаете сортировку, пересчёт отчёта или логирование, вы получите очень странные тормоза и ещё более странные места вызова. Для тяжёлых действий лучше честное имя метода: recalculateTotals() звучит длиннее, зато не врёт.

1
Задача
Kotlin SELF, 38 уровень, 3 лекция
Недоступна
Символ баннера
Символ баннера
1
Задача
Kotlin SELF, 38 уровень, 3 лекция
Недоступна
Табло турнира
Табло турнира
1
Задача
Kotlin SELF, 38 уровень, 3 лекция
Недоступна
Настройки режима
Настройки режима
1
Задача
Kotlin SELF, 38 уровень, 3 лекция
Недоступна
Сетка лаборатории
Сетка лаборатории
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
kasnil Уровень 66
14 апреля 2026
В задаче Сетка лаборатории принимается только инфиксная функция until. Если использовать ..< operator, проверка возвращает: В условии требуется проверка диапазона 0..rows-1 и 0..cols-1. В текущей реализации используется синтаксис `row in 0..<rows` и `col in 0..<cols`, который не является корректным в Kotlin (оператор `..<` отсутствует); замените на `row in 0 until rows` и `col in 0 until cols`, либо `row in 0..rows-1` и `col in 0..cols-1`. Хотя данный оператор появился с версии 1.9, а курс по 2 версии Kotlin