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 — інтерфейс лише для читання.

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() звучить довше, зате не бреше.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ