JavaRush /Курсы /Kotlin SELF /Типичные ошибки старта ООП: мутабельность, ссылки, ответс...

Типичные ошибки старта ООП: мутабельность, ссылки, ответственность, имена

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

1. Введение

Когда вы впервые начинаете писать классы, мозг часто делает хитрый финт: «О, теперь у меня есть свои типы — значит, я могу складывать в них всё, что вижу, и менять как захочу». Это звучит логично… ровно до первого бага, когда сумма внезапно стала отрицательной, заголовок расхода «сам поменялся», а метод list() начал печатать в консоль в самых неожиданных местах. Сегодня разберём типичные грабли старта ООП — чтобы наступать на них чуть реже и хотя бы осознанно.

Договоримся про контекст: мы продолжаем наш консольный мини‑проект учёта расходов (условно назовём его BudgetBuddy). У нас есть сущность расхода Expense и список MutableList<Expense>, в который мы добавляем элементы и строим отчёты.

2. Мутабельность: когда var превращает код в желе

Обычно самая первая «вредная привычка» в ООП выглядит так: вы видите, что свойства можно объявлять как var, и рука сама пишет var везде. Потому что «а вдруг потом понадобится поменять». В итоге получается класс, который можно случайно изменить откуда угодно, как пластилин: чуть нажал — и форма другая. Проблема не в том, что var — зло; проблема в том, что бесконтрольная изменяемость делает поведение программы непредсказуемым, особенно когда объектов много и они лежат в коллекциях.

Слишком много var: «на всякий случай» — самый дорогой аргумент

Начнём с иллюстрации. Вот «подозрительный» вариант модели расхода:

class Expense(var title: String, var amount: Int, var category: String)

fun main() {
    val e = Expense("Coffee", 300, "food")
    e.amount = -10
    println(e.amount) // -10
}

Код компилируется, всё честно. Но с точки зрения предметной области «расход = -10» звучит как «анти‑расход», «кредит от вселенной» или просто ошибка. Если класс позволяет записать некорректное значение, значит, корректность состояния держится на дисциплине программиста (а дисциплина программиста обычно заканчивается на втором кофе).

Более здоровый дефолт для стартовой модели — делать свойства неизменяемыми (val), пока вы точно не знаете, что и зачем будете менять:

class Expense(val title: String, val amount: Int, val category: String)

fun main() {
    val e = Expense("Coffee", 300, "food")
    println("${e.title}: ${e.amount}") // Coffee: 300
}

Это не делает программу «идеальной», но сильно снижает количество мест, где может появиться хаос: объект после создания стабилен, а значит «читается» проще.

val защищает ссылку, а не внутренности

Есть ещё одна ловушка. Многие думают: «Окей, тогда я буду хранить всё в val, и всё станет неизменяемым». Но val для объекта запрещает переназначить ссылку, а не изменить объект (если у объекта есть var-свойства). То же самое относится к коллекциям: val защищает переменную от переназначения, но не делает коллекцию «замороженной». Это прямо видно на примере со стандартной библиотекой: val-список можно менять, если он MutableList.

fun main() {
    val expenses = mutableListOf<String>()
    expenses.add("Coffee")
    expenses.add("Taxi")

    println(expenses) // [Coffee, Taxi]

    // expenses = mutableListOf() // так нельзя, это и есть смысл val
}

Именно поэтому полезно держать в голове простую таблицу (она экономит нервы):

Что вы пишете Что это означает по смыслу Что остаётся “мутабельным”
val x = ...
нельзя переназначить x объект внутри может меняться, если у него есть var
var x = ...
можно переназначить x плюс объект внутри тоже может меняться
val list = mutableListOf(...)
нельзя заменить список на другой но можно add/remove, потому что список mutable

Правило: val по умолчанию, var по необходимости

Когда вы пишете модель, попробуйте мысленно произнести фразу: «В реальной жизни эта штука меняется?»

Название расхода обычно не меняется (если пользователь ошибся — чаще это уже другая операция: «исправить ввод», но это отдельное действие). Сумма тоже обычно фиксируется. Категория иногда меняется, но это уже осознанный сценарий «переклассифицировать». Значит, начать можно с val, а для редкого сценария изменения — сделать отдельный метод, который выполняет проверку и меняет ровно то, что надо.

Вот пример, где ровно одно поле изменяемое, и изменение происходит через метод:

class Expense(val title: String, val amount: Int, var category: String) {

    fun recategorize(newCategory: String) {
        val prepared = newCategory.trim()
        if (prepared.isBlank()) return
        category = prepared.lowercase()
    }
}

fun main() {
    val e = Expense("Taxi", 1200, "transport")
    e.recategorize("  Travel  ")
    println(e.category) // travel
}

Заметьте, мы не обсуждаем сейчас «продвинутую защиту свойств» (accessors, private set и т.п.) — это отдельные темы. Здесь важна простая идея: если объект меняется, пусть он меняется предсказуемо, через методы, которые держат минимальные правила корректности.

3. Общие ссылки: один объект, два имени и три проблемы

Вторая большая ошибка новичка — забыть, что переменная объектного типа хранит ссылку. Вы это уже видели, но теперь проблема становится практической: объект лежит в списке, на него ссылаются разные переменные, вы где-то поменяли поле — и внезапно «сломалось» вообще в другом месте. Это не мистика, это обычная реальность ссылочных типов.

Ментальная модель: «табличка объектов» и стрелочки‑ссылки

Полезно представлять это так: где-то есть объект Expense(...), а переменные — это стрелочки на него.

flowchart LR
    a[val a] --> O["Expense(title='Coffee', amount=300)"]
    b[val b] --> O

Если объект изменяемый, изменение через любую стрелочку меняет один и тот же объект.

Пример: почему поменялось «не там»

class Box(var value: Int)

fun main() {
    val a = Box(10)
    val b = a

    b.value = 99

    println(a.value) // 99
}

С точки зрения JVM тут один объект Box, и две переменные, которые на него указывают. Ошибка возникает, когда вы ожидали, что b = a «создаст копию». Нет, копию создаёт только явное создание нового объекта Box(...).

Как это ломает коллекции объектов

Теперь перенесём ту же идею на список расходов. Представьте, что вы нашли расход по индексу, сохранили в переменную и где-то позже поменяли. Если объект изменяемый, вы поменяли элемент списка (потому что в списке лежит ссылка на тот же объект).

class Expense(var title: String, var amount: Int)

fun main() {
    val expenses = mutableListOf(
        Expense("Coffee", 300),
        Expense("Taxi", 1200)
    )

    val first = expenses[0]
    first.amount = 999

    println(expenses[0].amount) // 999
}

Иногда это именно то, что нужно. Но чаще на старте вы ловите «побочные эффекты»: поменяли в одном месте — отразилось в другом, и отлаживать тяжело.

val не убирает общие ссылки

val не убирает общие ссылки. val только говорит: «эту переменную нельзя направить на другой объект». Но если внутри объекта есть var, состояние всё равно меняется. Поэтому, если вы хотите меньше сюрпризов, два простых пути обычно работают лучше всего на раннем этапе.

  • Первый путь — делать модель более неизменяемой (больше val, меньше var), чтобы даже при общих ссылках нечего было «случайно менять».
  • Второй путь — если вам нужен «независимый» объект, вы создаёте новый объект явно, не надеясь на «копирование присваиванием».
class Expense(val title: String, val amount: Int)

fun main() {
    val original = Expense("Coffee", 300)
    val independent = Expense(original.title, original.amount)

    println(original === independent) // false
}

Мы пока не используем специальные механизмы «копирования модели» — держим мысль простой: новый объект появляется только через ClassName(...).

4. Ответственность: модель без readln() и println()

Ещё одна типичная ошибка старта ООП — сделать класс «богом всего»: и данные хранит, и ввод читает, и меню рисует, и отчёты печатает. Сначала кажется удобно: «ну всё же про расходы». А потом класс разрастается, и любое изменение превращается в операцию на открытом сердце без анестезии.

Признак проблемы: «класс знает про консоль»

Вот подозрительный пример (он работает, но он начинает тянуть проект не туда):

class Expense(val title: String, val amount: Int) {
    fun printLine() {
        println("$title: $amount")
    }
}

fun main() {
    val e = Expense("Coffee", 300)
    e.printLine() // Coffee: 300
}

Почему это проблема? Потому что Expense теперь «умеет печатать». А если вы завтра захотите вывод не в консоль, а в строку (для отчёта), или в файл, или в GUI — модель становится неудобной. Она начинает зависеть от способа отображения.

Гораздо гибче, когда модель описывает что это такое, а вывод — это отдельная логика (пусть даже пока в main или в функции‑утилите).

class Expense(val title: String, val amount: Int)

fun formatExpense(e: Expense): String {
    return "${e.title}: ${e.amount}"
}

fun main() {
    val e = Expense("Coffee", 300)
    println(formatExpense(e)) // Coffee: 300
}

Так Expense остаётся «чистым»: он не знает ни про консоль, ни про ввод, ни про то, как вы хотите отображать данные.

Модель не должна «спрашивать пользователя»

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

Правильнее: main (или ваши функции сценария) читают ввод, валидируют, создают объект. Объект не бегает по комнате с микрофоном и вопросом «а сколько вы потратили?».

5. Имена: почему Item, Data, Info — это туман

На старте ООП есть желание назвать всё максимально «универсально»: Item, Entity, Data, Record. Кажется, что так «профессиональнее». На деле это как подписать коробки при переезде словами «вещи» и «ещё вещи», а потом искать зарядку от ноутбука. В программировании имена — это часть интерфейса, а не украшение.

Плохие имена делают код длиннее

Сравним два варианта. Первый — «универсальный»:

class Item(val a: String, val b: Int)

fun main() {
    val x = Item("Coffee", 300)
    println(x.a) // Coffee
    println(x.b) // 300
}

Второй — предметный:

class Expense(val title: String, val amount: Int)

fun main() {
    val e = Expense("Coffee", 300)
    println(e.title)  // Coffee
    println(e.amount) // 300
}

Во втором варианте мозг не делает лишнюю работу. Вы читаете код и понимаете смысл без дополнительного «перевода»: amount — это сумма, а не «какое-то b».

Коротко и понятно лучше, чем коротко и непонятно

Понятность — это не обязательно длинно. title, amount, category — коротко и ясно. А вот t, a, c — это уже ребус. В маленьком учебном проекте это терпимо, но как только появляется 5–10 функций и список объектов — ребусы начинают съедать время.

6. Мини‑рефакторинг BudgetBuddy без «архитектуры ради архитектуры»

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

Модель: минимум изменяемости, максимум ясности

class Expense(val title: String, val amount: Int, val category: String)

fun createExpense(titleInput: String, amount: Int, categoryInput: String): Expense? {
    val title = titleInput.trim()
    val category = categoryInput.trim().lowercase()

    if (title.isBlank()) return null
    if (amount <= 0) return null
    if (category.isBlank()) return null

    return Expense(title = title, amount = amount, category = category)
}

Мы сделали простую фабрику‑функцию, которая возвращает Expense?: либо корректный объект, либо null, если данные плохие. Это знакомый вам паттерн «прочитал → подготовил → провалидировал» из ранних дней курса, просто теперь итогом является объект.

Список: val на коллекции, операции — через функции

Важно помнить: val на mutable‑коллекции защищает ссылку от переназначения, что полезно для предсказуемости.

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses.add(e)
}

fun totalAmount(expenses: List<Expense>): Int {
    return expenses.sumOf { it.amount }
}

fun main() {
    val expenses = mutableListOf<Expense>()

    addExpense(expenses, Expense("Coffee", 300, "food"))
    addExpense(expenses, Expense("Taxi", 1200, "transport"))

    println(totalAmount(expenses)) // 1500
}

Здесь у нас нет «класса‑комбайна». Модель — отдельно, операции над коллекцией — отдельно, main — место, где связывается сценарий.

Защита от общих ссылок через дизайн

Сейчас Expense полностью неизменяемый. Это значит: даже если у вас тысяча ссылок на один и тот же объект расхода (что само по себе уже подозрительно, но допустим), «случайно изменить» нечего. Вы либо создаёте новый расход, либо работаете с существующим как с фактом.

Если же вы точно знаете, что расход должен изменяться (например, категория меняется по команде пользователя), делайте изменение через метод, и пусть метод принимает решение, что допустимо.

7. Типичные ошибки старта ООП

Ошибка №1: «всё делаем var, потому что потом пригодится».
Так вы покупаете себе свободу «потом», но платите за неё хаосом «сейчас»: любой кусок кода может поменять объект, и вам сложно доказать, где именно это произошло. На старте безопаснее писать val в свойствах по умолчанию и добавлять var только под конкретный сценарий изменения, желательно через отдельный метод с проверками.

Ошибка №2: забывать, что присваивание копирует ссылку, а не создаёт новый объект.
Момент, когда вы делаете b = a, не создаёт клон. Это просто второе имя того же объекта. Если объект изменяемый, изменение через a будет видно через b, и наоборот. Лечится не «магией», а дисциплиной: либо делайте объекты более неизменяемыми, либо создавайте новый объект явно, когда вам нужен независимый экземпляр.

Ошибка №3: хранить сущность как набор параллельных переменных и списков.
Когда у вас есть titles, amounts, categories, вы рано или поздно получите рассинхрон: удалили элемент из одного списка, забыли из другого, и теперь «такси стоит 300, а кофе — 1200». Один список объектов (MutableList<Expense>) решает эту проблему на уровне структуры данных.

Ошибка №4: делать модель ответственной за ввод и вывод.
Класс Expense не должен читать readln() и печатать println() — не потому что «так нельзя», а потому что вы теряете гибкость: модель начинает зависеть от конкретного интерфейса (консоль). Лучше держать ввод/вывод в сценариях (main и функции вокруг него), а модель — как описание данных и простых правил изменения.

Ошибка №5: непонятные имена (Data, Item, Info, a/b/c).
Универсальность имени часто означает только одно: вы не хотите сейчас думать о смысле. Но смысл всё равно придётся придумать — просто позже, в момент отладки. Хорошие имена экономят время чтения кода, а время чтения в реальной разработке обычно дороже времени написания.

Ошибка №6: пытаться «лечить» проблему общих ссылок дополнительными var и перестановками переменных.
Когда вы замечаете неожиданные изменения, очень хочется «перекинуть объект в другую переменную» и надеяться, что станет лучше. Но проблема не в переменной, а в том, что у вас один объект и много ссылок на него. Если нужен независимый экземпляр — создавайте новый объект. Если независимость не нужна — делайте изменения централизованно и предсказуемо.

1
Задача
Kotlin SELF, 30 уровень, 4 лекция
Недоступна
Понятный расход
Понятный расход
1
Задача
Kotlin SELF, 30 уровень, 4 лекция
Недоступна
Скидка копией
Скидка копией
1
Задача
Kotlin SELF, 30 уровень, 4 лекция
Недоступна
Общие ссылки
Общие ссылки
1
Задача
Kotlin SELF, 30 уровень, 4 лекция
Недоступна
Отчёт без консоли
Отчёт без консоли
1
Опрос
Классы и объекты, 30 уровень, 4 лекция
Недоступен
Классы и объекты
Классы и объекты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ