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 | |
список (ячейка) | объект внутри (мы его заменили новым) |
| Коллекцию целиком | |
ничего (создали новую) | старый список и старые объекты |
Почему 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 (read-only интерфейс), а изменения делать через создание нового списка. Это сочетается с тем, что мы уже изучали про коллекции и про то, что копирование коллекций тоже, как правило, поверхностное: элементы остаются теми же объектами.
Например, пусть у расхода будут теги, но мы не дадим наружу «живую» мутабельную коллекцию:
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) { "Amount must be non-negative: $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: элементы остаются теми же объектами. Поэтому «неизменяемость» в прикладном коде — это ещё и привычка аккуратно обращаться с тем, что вы отдаёте наружу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ