JavaRush /Курси /Kotlin SELF /copy() і незмінюваніс...

copy() і незмінюваність моделей — оновлення без мутації та shallow copy

Kotlin SELF
Рівень 33 , Лекція 2
Відкрита

1. Незмінюваність моделей і copy()

Якщо ви лише починаєте, незмінюваність може звучати як «обмеження заради обмеження»: навіщо не змінювати обʼєкт просто на місці? Проблема в тому, що програми зростають, і разом із ними збільшується кількість місць, де один і той самий обʼєкт і читають, і змінюють. У якийсь момент ви ловите баг рівня «я змінив суму витрати в одному місці, а звіт зламався в іншому — хоча я туди навіть не заглядав».

Незмінюваність — це спроба зробити дані схожими на чек із магазину: його можна переписати (створити новий), але не можна «підтерти цифру ручкою» так, щоб ніхто не помітив.

Спершу на дуже простому прикладі відчуйте різницю між «змінити обʼєкт» і «створити оновлену версію».

Уявімо, що в нас є модель користувача:

data class User(val name: String, val age: Int)

fun main() {
    val u1 = User("Ann", 20)
    // u1.age = 21 // так не можна: age — val

    val u2 = u1.copy(age = 21)
    println(u1) // User(name=Ann, age=20)
    println(u2) // User(name=Ann, age=21)
}

Тут не відбувається жодної «магії»: ми не змінюємо u1, а створюємо новий обʼєкт u2, який відрізняється лише полем age. Такий підхід робить історію змін очевидною: був один стан — став інший.

Що робить copy() у data class

У data class компілятор генерує copy() автоматично. Ідея проста: ви копіюєте обʼєкт, але можете замінити частину полів.

Важливо, що copy() зазвичай має параметри зі значеннями за замовчуванням, які дорівнюють поточним значенням обʼєкта. Часто це пояснюють як еквівалент «ручної» реалізації на кшталт copy(name = this.name, age = this.age).

Тобто copy() — це як кнопка «зберегти як…», тільки для обʼєкта: ви берете поточну версію даних і робите нову, змінивши конкретні поля.

Мініперевірка «що взагалі відбувається»:

data class Config(val host: String, val port: Int)

fun main() {
    val base = Config(host = "localhost", port = 8080)
    val prod = base.copy(host = "prod.company")

    println(base) // Config(host=localhost, port=8080)
    println(prod) // Config(host=prod.company, port=8080)
}

Зверніть увагу, наскільки тут «людський» код: ми явно бачимо, що змінюємо лише host. Це особливо приємно, коли полів 5–10, і ви не хочете щоразу створювати обʼєкт «із нуля».

2. Оновлення даних через версії обʼєктів

Коли ви переходите на copy(), корисно змінити картинку в голові: модель даних — це не «живий організм, якого ми весь час правимо», а «знімок стану». Знімки можна зберігати, порівнювати, логувати, передавати далі.

Це сильно спрощує налагодження: якщо щось пішло не так, ви можете вивести «до» і «після» — і вони справді будуть різними обʼєктами.

У нашому практичному консольному застосунку (облік витрат) ми вже дійшли до того, що витрати зручно зберігати як обʼєкти (після переходу від Pair/Triple). Тож зафіксуймо просту модель Expense і потренуймося оновлювати її через copy().

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

fun main() {
    val e1 = Expense(id = 1, title = "Coffee", amountCents = 350, category = "Food")
    val e2 = e1.copy(amountCents = 450)

    println(e1) // Expense(id=1, title=Coffee, amountCents=350, category=Food)
    println(e2) // Expense(id=1, title=Coffee, amountCents=450, category=Food)
}

Тепер важливе практичне питання: «Гаразд, новий обʼєкт я створив. А як мені оновити список витрат?» Тут є два популярні підходи — і обидва для нашого рівня цілком нормальні.

Оновити елемент у MutableList: замінити обʼєкт повністю

Ми зберігаємо витрати в MutableList<Expense> і замінюємо елемент за індексом — але замінюємо його повністю новим обʼєктом (а не змінюємо поля всередині обʼєкта).

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

fun main() {
    val expenses = mutableListOf(
        Expense(1, "Coffee", 350),
        Expense(2, "Taxi", 1200)
    )

    expenses[0] = expenses[0].copy(amountCents = 450)
    println(expenses[0]) // Expense(id=1, title=Coffee, amountCents=450)
}

Оновити список функціонально: створити нову колекцію

Ми робимо «новий список» через map, не чіпаючи старий. Це корисно, якщо ви хочете максимально функціональний стиль і мінімум мутацій.

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

fun main() {
    val expenses = listOf(
        Expense(1, "Coffee", 350),
        Expense(2, "Taxi", 1200)
    )

    val updated = expenses.map { e ->
        if (e.id == 1) e.copy(amountCents = 450) else e
    }

    println(updated[0]) // Expense(id=1, title=Coffee, amountCents=450)
}

Обидва підходи спираються на одну й ту саму ідею: «оновлення = створення нової версії обʼєкта». Різниця лише в тому, чи оновлюєте ви контейнер (список) на місці, чи створюєте нову колекцію.

Щоб мозок не «плавився», можна тримати під рукою маленьку табличку:

Що змінюємо Підхід Що мутує Що лишається незмінним
Один елемент у MutableList
list[i] = list[i].copy(...)
список (комірка) обʼєкт усередині (ми замінили його новим)
Колекцію повністю
val newList = oldList.map { ...copy... }
нічого (створили нову) старий список і старі обʼєкти

Чому copy() зазвичай краще поєднується з val, а не з var

Дуже поширена стартова помилка виглядає так: студенти роблять data class із купою var, бо «ну я ж буду змінювати поля». І наче все працює… доки не починається плутанина: хто, де й коли змінив поле — і чому звіт показує дивні значення.

Із val модель поводиться як «стабільне значення»: якщо ви тримаєте посилання на обʼєкт Expense, ви певні, що він не зміниться у вас під носом. Це особливо важливо, коли ви:

  • передаєте обʼєкт у функції;
  • кладете обʼєкт у колекції;
  • порівнюєте обʼєкти за ==;
  • друкуєте обʼєкт у лог (і хочете, щоб лог відповідав реальності).

copy() і val — це як «редактор без кнопки Save поверх старого файла»: ви щоразу зберігаєте нову версію. Так, спочатку здається, що це зайві рухи. Але на практиці це економить час на розслідування «хто зіпсував дані».

Порівняймо «мутабельну» і «версійну» модель на короткому прикладі:

data class MutableUser(var name: String, var age: Int)
data class StableUser(val name: String, val age: Int)

fun main() {
    val a = MutableUser("Ann", 20)
    a.age = 21
    println(a) // MutableUser(name=Ann, age=21)

    val b1 = StableUser("Bob", 20)
    val b2 = b1.copy(age = 21)
    println(b1) // StableUser(name=Bob, age=20)
    println(b2) // StableUser(name=Bob, age=21)
}

Другий варіант явно показує «до/після» і не дає випадково змінити b1. У цьому й полягає головна практична цінність.

3. Shallow copy: коли copy() розділяє вкладені посилання

А ось тут починається найважливіший (і найпідступніший) момент цієї лекції: copy() робить поверхневу копію (shallow copy). Це означає, що copy() копіює значення полів, але не «провалюється всередину» вкладених обʼєктів. Компоненти рекурсивно не копіюються, а посилання на інші обʼєкти можуть бути спільними.

Простими словами: якщо всередині вашого обʼєкта є щось змінюване (наприклад, MutableList), то і оригінал, і копія дивитимуться на один і той самий список.

Відтворімо класичний сюрприз:

data class Team(val name: String, val members: MutableList<String>)

fun main() {
    val a = Team("A", mutableListOf("Ann"))
    val b = a.copy()

    b.members.add("Bob")

    println(a.members) // [Ann, Bob]
    println(b.members) // [Ann, Bob]
}

Це не баг Kotlin. Це чесна математика посилань: і a.members, і b.members вказують на один і той самий MutableList.

Щоб не було відчуття «мені брехали», намалюймо схему:

flowchart LR
    A["a: Team"] --> LA["members -> List#1"]
    B["b: Team (copy)"] --> LA
    LA --> M1["Ann"]

Після b.members.add("Bob") елемент додався до List#1, а отже його «бачать» обидва обʼєкти.

Як жити з shallow copy у межах курсу

Маємо простий практичний висновок: якщо ви хочете активно використовувати copy() як стиль оновлення, намагайтеся будувати модель даних так, щоб усередині неї не було «прихованих мутацій».

Найпростіше цього досягти дисципліною: усередині моделі замість MutableList зберігати List (інтерфейс лише для читання), а зміни робити через створення нового списку. Це узгоджується з тим, що ми вже вивчали про колекції, а також із тим, що копіювання колекцій теж, як правило, поверхневе: елементи лишаються тими самими обʼєктами.

Наприклад, нехай у витрати будуть теги, але ми не віддаватимемо назовні «живу» мутабельну колекцію:

data class Expense(
    val id: Int,
    val title: String,
    val tags: List<String>
)

fun main() {
    val e1 = Expense(1, "Coffee", tags = listOf("food"))
    val e2 = e1.copy(tags = e1.tags + "morning")

    println(e1.tags) // [food]
    println(e2.tags) // [food, morning]
}

Оператор + для List створює новий список із доданим елементом. Тобто ви знову робите «нову версію даних», а не мутуєте стару — і це якраз дуже добре поєднується з copy().

4. Практика: оновлюємо витрату без мутації

Зараз ми зберемо невеликий, але цілісний фрагмент логіки для нашого консольного трекера витрат. Ідея така: у нас є список витрат, і ми хочемо «оновити суму» або «перейменувати заголовок», не чіпаючи обʼєкт напряму — лише через copy().

Це виглядатиме трохи багатослівніше, зате буде передбачувано: функція не змінює те, що їй передали, а повертає нову версію.

Почнімо з мінімальної моделі:

data class Expense(
    val id: Int,
    val title: String,
    val amountCents: Long,
    val category: String,
    val tags: List<String>
)

Тепер зробімо функцію «оновити суму» (зверніть увагу: вхід — Expense, вихід — новий Expense):

fun withNewAmount(expense: Expense, newAmountCents: Long): Expense {
    require(newAmountCents >= 0) { "Сума має бути невідʼємною: $newAmountCents" }
    return expense.copy(amountCents = newAmountCents)
}

І перевірмо в main:

fun main() {
    val e1 = Expense(1, "Coffee", 350, "Food", tags = listOf("morning"))
    val e2 = withNewAmount(e1, 450)

    println(e1.amountCents) // 350
    println(e2.amountCents) // 450
}

Тепер варіант «оновити витрату в списку за id». Список — мутабельний, але обʼєкт ми замінюємо повністю:

fun updateAmountById(expenses: MutableList<Expense>, id: Int, newAmountCents: Long) {
    val index = expenses.indexOfFirst { it.id == id }
    if (index == -1) return

    expenses[index] = expenses[index].copy(amountCents = newAmountCents)
}

Мініперевірка:

fun main() {
    val expenses = mutableListOf(
        Expense(1, "Coffee", 350, "Food", listOf()),
        Expense(2, "Taxi", 1200, "Transport", listOf())
    )

    updateAmountById(expenses, id = 1, newAmountCents = 450)
    println(expenses[0]) // Expense(id=1, title=Coffee, amountCents=450, category=Food, tags=[])
}

А тепер додаймо «додати тег» (знову без мутації, бо tags: List<String>):

fun addTag(expense: Expense, tag: String): Expense {
    val clean = tag.trim()
    if (clean.isEmpty()) return expense
    return expense.copy(tags = expense.tags + clean)
}

І перевірка:

fun main() {
    val e1 = Expense(1, "Coffee", 350, "Food", tags = listOf("morning"))
    val e2 = addTag(e1, "cafe")

    println(e1.tags) // [morning]
    println(e2.tags) // [morning, cafe]
}

Якщо ви зараз подумали: «Але ж це купа копій!» — так, копій справді буде більше. Натомість ви отримуєте код, у якому зміни даних читаються як текст: «взяли витрату → зробили версію з новим amount → поклали в список».

Для навчального проєкту й більшості звичайних застосунків це цілком адекватний компроміс між простотою та надійністю.

Для наочності можна уявляти оновлення так:

flowchart TD
    A["Expense (старий)"] -->|"copy(amountCents=...)"| B["Expense (нова версія)"]
    B -->|замінили в списку| C["MutableList⟨Expense⟩ (оновлено комірку)"]

5. Типові помилки під час роботи з copy() і незмінюваністю

Помилка №1: очікувати «глибоку копію» від copy().
Дуже легко одного разу написати val b = a.copy() і підсвідомо очікувати, що тепер «усе незалежне». Але copy() у data class — це shallow copy: вкладені посилання (наприклад, на MutableList) будуть спільними, і зміни в одному місці проявляться в іншому.

Помилка №2: зберігати в моделі MutableList/MutableMap і водночас будувати архітектуру на copy().
Сама по собі мутабельна колекція не є «забороненою». Проблема починається тоді, коли ви думаєте «я оновлюю обʼєкт лише через copy()», але всередині лежить мутабельна структура, яку хтось змінює напряму. Тоді ви отримуєте гібрид: зовні — незмінюваність, усередині — прихована мутація. Такі баги дуже неприємно шукати.

Помилка №3: використовувати позиційні аргументи в copy() і переплутати порядок.
Технічно copy() можна викликати й позиційно, але щойно полів стає більше двох, шанс помилитися різко зростає. Значно безпечніше (і читабельніше) використовувати іменовані аргументи: expense.copy(amountCents = 450). Це особливо важливо, бо copy() якраз створено для точкових змін.

Помилка №4: «лікувати» все незмінюваністю та copy(), навіть там, де простіше пряме оновлення.
Іноді обʼєкт справді локальний, короткоживучий і нікому не передається — наприклад, ви збираєте дані введення перед створенням моделі. У таких місцях var може бути абсолютно нормальним інструментом. Помилка — перетворювати copy() на релігію. Це лише стиль, який допомагає там, де дані розділяються між частинами програми.

Помилка №5: забувати, що незмінюваність — це про домовленість, а не про магію.
Якщо ви зберігаєте val tags: List<String>, це захищає вас від tags.add(...) на рівні компілятора. Але якщо ви десь таки передали всередину List посилання на мутабельний список, і хтось тримає на нього окреме посилання, зміни все одно можливі. Копіювання колекцій стандартними функціями теж зазвичай shallow: елементи залишаються тими самими обʼєктами. Тому «незмінюваність» у прикладному коді — це ще й звичка обережно поводитися з тим, що ви віддаєте назовні.

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