JavaRush /Курсы /Kotlin SELF /Зачем классы и как мыслить объектами: свой тип и ссылки

Зачем классы и как мыслить объектами: свой тип и ссылки

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

1. Введение

Когда мы только начинаем программировать, естественно хранить всё в отдельных переменных: это просто и похоже на математику. Но как только переменные становятся “частями одного смысла”, начинается бытовой хаос: значения легко перепутать, забыть обновить, передать не в том порядке. Язык не знает, что эти кусочки должны жить вместе, поэтому не может вам помочь.

Представьте, что мы пишем маленький учёт расходов (мы будем к нему возвращаться сегодня и дальше). У нас есть “название расхода” и “сумма”. Наивный вариант выглядит так:

fun main() {
    val title = "Coffee"
    val amount = 300

    println("$title: $amount") // Coffee: 300
}

Пока это один расход — вроде бы нормально. А теперь представьте, что расходов становится много. Вариант “сделаю title1, amount1, title2, amount2” быстро превращается в музей боли, где экспонаты подписаны маркером “примерно вот это”.

Чуть более “продвинутый” новичковый вариант — два параллельных списка:

fun main() {
    val titles = mutableListOf("Coffee", "Taxi")
    val amounts = mutableListOf(300, 1200)

    println("${titles[0]}: ${amounts[0]}") // Coffee: 300
}

И вот тут начинается магия: если вы удалите элемент из одного списка и забудете удалить из другого, данные “рассинхронизируются”. Программа продолжит работать — просто будет печатать кофе за 1200 и такси за 300. Это не ошибка компилятора, это ошибка реальности.

В этот момент хочется сказать: “Я же точно знаю, что title и amount — одно целое. Почему Kotlin не знает?”. Ответ: потому что вы пока не сказали Kotlin’у об этом явно. Классы — это как раз способ сказать: “Вот это — единая сущность”.

Pair как временное решение

Когда становится ясно, что “две переменные — одно целое”, многие делают логичный ход: склеивают значения в Pair. Это уже похоже на нормальную структуру данных: одно значение, внутри которого лежит два поля.

Сценарий с расходом через Pair:

fun main() {
    val expense: Pair<String, Int> = "Coffee" to 300

    println(expense.first)  // Coffee
    println(expense.second) // 300
}

Pair реально полезен: он позволяет быстро объединить два значения и передавать их дальше как одно. Это удобно, когда вы делаете небольшую одноразовую связку, например “имя и счётчик” или “результат и сообщение”.

Но у Pair есть фундаментальная проблема: имена first и second не несут предметного смысла. Через неделю вы увидите pair.second и будете вспоминать: “второе — это сумма? возраст? рейтинг? количество котиков?”. Да, можно договориться “second всегда amount”, но такие договоры обычно живут ровно до первого усталого вечера.

Чтобы увидеть разницу, полезно сравнить подходы в виде маленькой таблицы:

Подход Как выглядит Что хорошо Что начинает болеть
Отдельные переменные
title, amount
просто нет “одного целого”, легко перепутать
Pair
"Coffee" to 300
одно значение
first/second
без смысла
Класс
Expense(title, amount)
понятные имена полей надо один раз объявить тип

Класс — это следующий шаг: вы создаёте свой тип данных с нормальными именами, и код начинает читаться как текст.

Класс и объект

Когда вы видите Int, вы воспринимаете его как готовый тип: число, с которым можно что-то делать. Класс — это способ создать свой тип примерно в том же стиле: “это не два значения, это один объект — расход”.

С технической стороны класс объявляется словом class. Например, “пустой” класс может выглядеть так:

class Expense

Но “пустой” класс бесполезен: он как коробка без содержимого. Поэтому мы добавим поля (свойства). Сейчас мы сделаем это самым прямолинейным способом — через свойства в теле класса, чтобы не забегать вперёд в синтаксис конструктора (это будет отдельная тема дальше).

class Expense {
    var title: String = ""
    var amount: Int = 0
}

Теперь можно создать объект этого класса. В Kotlin, в отличие от некоторых других языков, при создании объекта не пишут new — просто вызывают “как функцию” имя класса.

fun main() {
    val e = Expense()
    e.title = "Coffee"
    e.amount = 300

    println("${e.title}: ${e.amount}") // Coffee: 300
}

Что тут важно на уровне “понять и перестать бояться”:

  • Expense — это тип.
  • e — это переменная, в которой лежит ссылка на объект типа Expense.
  • Expense() — это момент создания нового объекта (экземпляра класса).
  • e.title и e.amount — доступ к данным объекта через точку.

И вот уже мы сделали ключевой шаг: вместо “две независимые переменные, которые нужно держать в голове” у нас появилось “одно значение, у которого есть осмысленные части”.

Привязываем идею к приложению с расходами

Сейчас мы аккуратно начнём переводить идею классов в наш учебный контекст: приложение, где есть расходы. Мы пока не строим архитектуру и не придумываем “правильную” модель на века — нам нужно почувствовать, как класс превращает набор значений в осмысленную сущность.

Сделаем простую заготовку: класс Expense, создание объекта, и функция печати. Обратите внимание: функция принимает один параметр (расход), а не два (название и сумма). Это уже плюс к читаемости.

class Expense {
    var title: String = ""
    var amount: Int = 0
}

fun printExpense(e: Expense) {
    println("${e.title}: ${e.amount}")
}

fun main() {
    val e = Expense()
    e.title = "Coffee"
    e.amount = 300

    printExpense(e) // Coffee: 300
}

Теперь сравните это с вариантом на Pair. Там printExpense выглядел бы так: “передайте мне пару, а я буду помнить, что first — это title”. То есть часть смысла живёт только в вашей голове. В варианте с классом смысл живёт в коде.

И ещё одна приятная вещь: объект можно “передавать дальше” как единое значение. Например, можно сделать функцию “создать расход” и вернуть объект:

class Expense {
    var title: String = ""
    var amount: Int = 0
}

fun createExpense(title: String, amount: Int): Expense {
    val e = Expense()
    e.title = title
    e.amount = amount
    return e
}

fun main() {
    val lunch = createExpense("Lunch", 900)
    println("${lunch.title}: ${lunch.amount}") // Lunch: 900
}

Пока это выглядит чуть многословно, и это нормально: мы сознательно используем самый явный стиль, чтобы было понятно, что именно происходит. Kotlin позволяет делать короче, но “короче” имеет смысл только после того, как “понятно”.

2. Ссылки на объекты и поведение переменных

Переменная хранит ссылку на объект

Слова “ссылка” обычно звучат как начало лекции, после которой хочется выйти в окно (или хотя бы в коридор). Но здесь нам не нужна теория про память, адреса и байтики. Нам нужна прикладная модель поведения, чтобы вы не ловили “странные баги” и не думали, что Kotlin шутит над вами.

Упростим до бытовой аналогии. Объект — это как папка на столе. Переменная — это как наклейка с названием, которую вы приклеили на папку. Если вы приклеите вторую наклейку на ту же папку, папок не станет две — папка останется одна, просто имён у неё теперь два.

Посмотрим на код:

class Box {
    var value: Int = 0
}

fun main() {
    val a = Box()
    a.value = 10

    val b = a
    b.value = 99

    println(a.value) // 99
}

Ключевая строка тут — val b = a. Она не создаёт новый объект. Она делает так, что b указывает на тот же объект, что и a. Поэтому изменение b.value меняет состояние одного и того же объекта, и при чтении a.value вы увидите 99.

Полезно зафиксировать это схемой. Это не “настоящая память”, а просто картинка, чтобы мозг не сопротивлялся:

flowchart LR
    a[a] --> obj["Box(value=10)"]
    b[b] --> obj

После b.value = 99 объект становится Box(value=99), и обе переменные видят одно и то же.

Это поведение — не “особенность Kotlin”, а базовая идея объектных типов: переменная хранит ссылку, а не “весь объект целиком”. Это будет важнейшим фундаментом на всех следующих шагах.

val и var: нельзя переназначить, но можно изменить

Очень частая путаница у начинающих: “если у меня val, значит значение неизменяемое”. Это верно для чисел и строк в повседневном смысле, но для объектов нужно уточнение: val запрещает переназначить переменную, но не обязательно запрещает изменять объект, на который она указывает.

Давайте сделаем пример, который покажет разницу “переназначить ссылку” и “изменить объект”:

class Counter {
    var value: Int = 0
}

fun main() {
    val c = Counter()
    c.value = 5
    c.value += 1

    println(c.value) // 6
}

Здесь cval, но c.valuevar. Это означает: переменная c всегда будет указывать на тот же объект, но внутри объекта значение поля можно менять.

А вот так нельзя:

class Counter {
    var value: Int = 0
}

fun main() {
    val c = Counter()
    // c = Counter() // так нельзя: val нельзя переназначать
}

Если перевести на человеческий язык: val говорит “наклейку нельзя переклеивать на другую папку”, но не говорит “в папке нельзя менять документы”.

Почему это важно именно сегодня? Потому что как только вы начнёте хранить объекты в коллекциях, или передавать их в функции, или присваивать одну переменную другой, внезапно всплывёт эффект “я вроде поменял тут, а поменялось вон там”. И это почти всегда вопрос ссылок, а не мистики.

“Одинаковые данные” и “один объект”

Ещё одна точка, где новички обычно делают честные глаза: “Но я же создал два одинаковых объекта, почему они не одно и то же?”. Потому что “одинаковые поля” и “одинаковый объект” — разные понятия.

Смотрите:

class Box {
    var value: Int = 0
}

fun main() {
    val a = Box()
    a.value = 10

    val b = Box()
    b.value = 10

    println(a === b) // false
}

Оператор === проверяет, указывают ли две переменные на один и тот же объект. Здесь — нет, потому что мы два раза написали Box(), то есть создали два разных объекта.

А теперь сравним с “двумя именами у одного объекта”:

class Box {
    var value: Int = 0
}

fun main() {
    val a = Box()
    a.value = 10

    val b = a
    println(a === b) // true
}

Эта тема тесно связана с == (сравнение “по значению”) и === (сравнение “по ссылке”). Вы уже встречали эту идею раньше, и сегодня нам важно удержать только практическую часть: === помогает проверить, это “одна папка с двумя наклейками” или “две отдельные папки”.

Про “сравнение по данным” мы пока не делаем далеко идущих выводов: у обычного класса без дополнительной логики == не обязан сравнивать поля так, как вам хотелось бы. Поэтому сегодня держим фокус на ссылках и на том, как это влияет на изменения состояния.

3. Типичные ошибки при работе с объектами и ссылками

Ошибка №1: думать, что b = a создаёт копию объекта.
Такое ожидание возникает из привычки к числам и строкам: “присвоил — значит получил своё независимое значение”. Но с объектами присваивание копирует ссылку, а не делает новый объект. Если вам нужна независимая сущность, вы должны создать новый объект явно (например, снова вызвать Expense() или другой конструктор).

Ошибка №2: считать, что val делает объект “неизменяемым”.
val защищает переменную от переназначения, но не запрещает менять var-свойства объекта. Это полезно, но иногда неожиданно: “почему оно меняется, если val?”. Потому что меняется не ссылка, а состояние объекта. Если вы хотите меньше сюрпризов, полезная привычка — начинать с val-ссылок и очень осознанно добавлять изменяемость внутрь объекта.

Ошибка №3: злоупотреблять Pair там, где уже есть предметный смысл.
Pair прекрасен как временная склейка, но как только вы ловите себя на мысли “first — это название расхода”, это сигнал: пора дать сущности имя (Expense) и нормальные свойства. Иначе код со временем превращается в набор загадок: “а second тут что?”.

Ошибка №4: пытаться сразу “запихнуть всё в класс” без понимания границ.
В первый день ООП часто хочется сделать класс, который и хранит данные, и печатает себя, и читает ввод, и сортирует список, и ещё умеет варить кофе. На старте держите класс как модель данных (поля) — этого уже достаточно, чтобы выиграть в читаемости и снизить количество ошибок.

Ошибка №5: игнорировать проверку “один объект или два” при отладке.
Когда “вдруг меняется не там”, лучший быстрый тест — проверить a === b. Если true, значит, вы реально работаете с одним объектом, просто через два имени. Это не чинит баг само по себе, но мгновенно объясняет, почему он возможен.

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