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[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 | |
исключение (IllegalArgumentException через require) |
| Nullable | |
возвращается 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() звучит длиннее, зато не врёт.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ