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”, но такие договоры обычно живут ровно до первого усталого вечера.
Чтобы увидеть разницу, полезно сравнить подходы в виде маленькой таблицы:
| Подход | Как выглядит | Что хорошо | Что начинает болеть |
|---|---|---|---|
| Отдельные переменные | |
просто | нет “одного целого”, легко перепутать |
|
|
одно значение | без смысла |
| Класс | |
понятные имена полей | надо один раз объявить тип |
Класс — это следующий шаг: вы создаёте свой тип данных с нормальными именами, и код начинает читаться как текст.
Класс и объект
Когда вы видите 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
}
Здесь c — val, но c.value — var. Это означает: переменная 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, значит, вы реально работаете с одним объектом, просто через два имени. Это не чинит баг само по себе, но мгновенно объясняет, почему он возможен.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ