1. Инкапсуляция и состояние объекта
Когда вы пишете класс, вы как будто создаёте маленькую вселенную: внутри есть состояние (свойства) и правила жизни (методы). И если вы открываете двери этой вселенной настежь, любой внешний код сможет зайти и устроить там вечеринку с салютом… в серверной. Инкапсуляция — это не «прятать всё от всех», а «сделать так, чтобы менять состояние можно было только безопасными способами».
В Kotlin это решается очень практично: мы выбираем, какие свойства должны быть доступны всем (public), какие — только самому классу (private), а какие — только внутри проекта/модуля (internal). И чем раньше вы начнёте думать об этом как о привычке, тем меньше у вас будет ситуаций вида «почему у меня баланс стал -1000, я же ничего такого не делал».
Что такое состояние объекта и как его легко сломать
Состояние — это значения свойств внутри объекта. Проблема в том, что даже самый дружелюбный разработчик (особенно будущий вы через две недели) может случайно присвоить туда что-то нелепое. Баги от такого обычно неприятные: программа компилируется, запускается, но логика начинает «плыть».
Давайте на простом примере — наш мини‑проект «учёт расходов». Пусть у нас есть расход:
class Expense(
val title: String,
val amount: Int
)
А теперь заведём «книгу расходов» наивным способом — просто публичным списком:
class ExpenseBook {
val expenses: MutableList<Expense> = mutableListOf()
}
Выглядит удобно: добавляй, удаляй, сортируй — делай что хочешь. Но именно это и проблема: внешний код получает прямой доступ к внутренностям объекта.
fun main() {
val book = ExpenseBook()
book.expenses.add(Expense("Кофе", 250))
book.expenses.clear() // упс: стерли все расходы
}
В этот момент ваш ExpenseBook перестаёт быть «книгой с правилами», а становится просто пакетом данных без гарантий. Если вы хотели, например, запрещать удаление всех расходов одним махом или вести статистику изменений — вы уже не контролируете ничего. Инкапсуляция начинается там, где класс говорит: «Снаружи — только то, что я разрешаю».
2. Модификаторы видимости в Kotlin
Базовые модификаторы: public, private, internal
Чтобы управлять доступом к свойствам, Kotlin даёт модификаторы видимости. Они применяются к свойствам, функциям, классам и даже top-level объявлениям (то есть функциям/константам вне классов). Самое важное для сегодняшней лекции — эти три:
| Модификатор | Где видно | Как думать по-человечески |
|---|---|---|
|
везде | «это часть публичного API класса» |
|
только внутри класса (или файла для top-level) | «это внутренняя кухня» |
|
внутри одного модуля | «для проекта можно, наружу нельзя» |
Ключевая деталь: если вы ничего не написали, то по умолчанию будет public. То есть «открыто всем». Это удобно для старта, но опасно как постоянная привычка.
Отдельный нюанс: private у top-level объявлений работает на уровне файла — то есть приватные top-level функции/переменные видны в пределах одного .kt файла. Это прямо используется в документации как пример видимости расширений и top-level деталей.
public по умолчанию: когда это нормально, а когда уже подозрительно
public — не зло. Это просто «открытая дверь». Если вы действительно хотите, чтобы свойство было частью контракта класса, делайте его публичным. Чаще всего это нормально для данных, которые не должны меняться, то есть для val.
Например, расход может быть неизменяемым: название и сумма заданы при создании и дальше не прыгают туда-сюда, как настроение у кота.
class Expense(
val title: String,
val amount: Int
)
Но уже здесь есть риск: amount может быть отрицательным. В прошлых лекциях мы обсуждали проверки через require(), поэтому улучшим модель и защитим инвариант «сумма > 0»:
class Expense(
val title: String,
val amount: Int
) {
init {
require(title.isNotBlank()) { "title must not be blank" }
require(amount > 0) { "amount must be > 0" }
}
}
Теперь Expense хотя бы не бывает «битым» с момента создания.
А вот public var — это уже сигнал «осторожно». Если вы делаете изменяемое свойство публичным, вы разрешаете внешнему коду менять состояние объекта напрямую, в обход любых правил. Иногда это оправдано (например, в совсем простых учебных примерах), но в реальном коде это почти всегда будущий источник странностей.
private: главный инструмент инкапсуляции
private — это способ сказать: «Снаружи сюда руками не лезем». В терминах проектирования это почти всегда означает: хранение — приватное, изменения — через методы.
Вернёмся к ExpenseBook. Мы хотим, чтобы список расходов жил внутри книги, но наружу не торчал как провод без изоляции. Сделаем список приватным и дадим безопасные операции.
class ExpenseBook {
private val expenses: MutableList<Expense> = mutableListOf()
fun add(expense: Expense) {
expenses.add(expense)
}
fun all(): List<Expense> = expenses.toList() // снимок, не оригинал
}
Обратите внимание на важный трюк: мы возвращаем List<Expense>, а не MutableList<Expense>, и делаем toList(). Это значит, что внешний код получает копию (снимок), а не ссылку на внутренний список. Да, копирование стоит каких-то ресурсов, но на учебном проекте это отличный способ почувствовать идею «не отдавай наружу внутренности как есть».
Проверим, что «сломать» объект теперь сложнее:
fun main() {
val book = ExpenseBook()
book.add(Expense("Кофе", 250))
val snapshot = book.all()
println(snapshot.size) // 1
// snapshot.add(...) // так нельзя: у List нет add()
// book.expenses.clear() // так нельзя: expenses private
}
С точки зрения новичка это может казаться «почему Kotlin мне мешает?!». Но это не Kotlin мешает — это вы сами поставили правильный забор вокруг состояния, чтобы потом не ловить баги по ночам.
Ещё один очень частый приём — сделать параметр конструктора приватным свойством. Это полезно, когда значение нужно внутри класса, но наружу вы не хотите его светить:
class User(private val userId: String) {
fun label(): String = "User($userId)"
}
Снаружи userId не видно, но класс может использовать его для своих методов. Такой дизайн очень помогает не превращать каждый класс в «публичный стенд со всеми проводами наружу».
internal: видно модулю, но не «внешнему миру»
internal — компромисс между public и private. Он говорит: «Это можно использовать внутри нашего модуля, но снаружи (например, если мы соберём библиотеку) этого быть не должно».
Тут важно понять, что такое модуль. В простом Kotlin/JVM проекте модуль обычно совпадает с Gradle-модулем (часто это весь проект, если он один). То есть internal удобно для деталей, которые нужны разным файлам/пакетам внутри проекта, но не должны стать частью публичного API.
Простой пример:
internal class BuildInfo(
val buildType: String
)
fun main() {
val info = BuildInfo("debug")
println(info.buildType) // debug
}
Если вы потом превратите проект в библиотеку, код из другого модуля не сможет использовать BuildInfo. Внутри — пожалуйста.
И ещё один важный момент: internal применим не только к свойствам и классам, но и к функциям, включая extension-функции. В документации Kotlin это прямо подчёркивается: internal-расширение доступно только внутри модуля.
Это хороший ориентир для мышления: internal = наша внутренняя инфраструктура проекта.
3. Практический дизайн и пример ExpenseBook
Что делать публичным, что прятать
Когда вы проектируете класс, полезно мысленно разделить его на «контракт» и «реализацию». Контракт — то, как класс разрешает себя использовать. Реализация — как он устроен внутри. Инкапсуляция — это граница между этими двумя слоями.
Давайте зафиксируем несколько практичных правил (без фанатизма, но как хорошие “стартовые настройки мозга”). Если свойство отражает факт и не должно меняться после создания объекта, обычно достаточно public val. Если свойство связано с внутренним состоянием и может меняться, почти всегда лучше сделать его private и менять через методы, чтобы у вас было одно место, где проверяются правила. Если же свойство или класс нужен нескольким частям проекта, но вы не хотите обещать его внешним пользователям, выбирайте internal.
В следующей лекции мы добавим к этому ещё более тонкий контроль через геттеры/сеттеры и private set, но сегодня цель — научиться хотя бы правильно закрывать «внутренности», чтобы объект не превращался в бесконтрольный мешок данных.
Небольшая таблица-ориентир:
| Ситуация | Как лучше сделать |
|---|---|
| «Это значение — часть модели, оно задаётся при создании и не меняется» | |
| «Это внутреннее хранилище, его нельзя менять напрямую» | |
| «Нужно разным файлам проекта, но не хотим открывать наружу» | |
| «Хочется дать доступ к коллекции, но без права менять» | |
Развиваем мини‑приложение: ExpenseBook как владелец состояния
Сейчас соберём мини-версию, которая уже выглядит как нормальная модель предметной области. У нас будет Expense (неизменяемый расход) и ExpenseBook (книга расходов), которая владеет коллекцией и даёт понятные операции.
Сначала Expense:
class Expense(
val title: String,
val amount: Int
) {
init {
require(title.isNotBlank()) { "title must not be blank" }
require(amount > 0) { "amount must be > 0" }
}
}
Теперь ExpenseBook — обратите внимание на private список и публичные методы:
class ExpenseBook {
private val expenses: MutableList<Expense> = mutableListOf()
fun add(title: String, amount: Int) {
expenses.add(Expense(title, amount))
}
fun totalAmount(): Int = expenses.sumOf { it.amount }
}
И пример использования в main (очень короткий, чисто чтобы увидеть идею):
fun main() {
val book = ExpenseBook()
book.add("Кофе", 250)
book.add("Проезд", 70)
println("Total = ${book.totalAmount()}") // Total = 320
}
Здесь важно, что состояние (expenses) живёт только внутри ExpenseBook. Снаружи вы работаете через осмысленные операции add(...) и totalAmount(). Это и есть «класс отвечает за свои инварианты»: внешний код не может внезапно очистить список, подсунуть Expense с отрицательной суммой (потому что Expense не создастся), или заменить коллекцию целиком.
4. Типичные ошибки при выборе видимости
Ошибка №1: делать всё public “потому что так проще”.
Это правда проще… до первого бага. Когда у класса куча public var, вы теряете контроль над состоянием: вы не знаете, кто, где и когда изменил свойство. В итоге класс перестаёт быть «типом с поведением» и превращается в набор полей, которые любой может испортить. Лучше начинать с более закрытого дизайна и открывать только то, что реально нужно.
Ошибка №2: отдавать наружу изменяемую коллекцию как есть.
public val expenses: MutableList<Expense> выглядит безобидно, но это фактически «удалённое управление вашим объектом». Снаружи можно сделать clear(), removeAt(), sort(), и ваш класс даже не узнает, что произошло. Безопаснее хранить MutableList приватно, а наружу возвращать List (либо копию, либо read-only представление). Иначе вы сами ломаете инкапсуляцию, даже если поставили красивые require(...) в конструкторе.
Ошибка №3: путать private и “невозможно использовать вообще”.
Новички иногда воспринимают private как наказание: «сделал приватным — и теперь ничего не работает». На самом деле идея в том, что вы должны дать другой способ взаимодействия: методы, которые выражают намерение (add, totalAmount, removeLast и т.д.). Инкапсуляция не про «спрятать и забыть», а про «контролировать доступ».
Ошибка №4: использовать internal, не понимая границы модуля.
Если думать, что internal — это «почти private», можно случайно построить архитектуру, где половина проекта зависит от внутренних деталей другой половины, и потом будет больно выносить код в библиотеку или разделять на модули. Держите простое правило: internal — это детали для “нашего проекта”, а не для будущих пользователей и не для соседних модулей.
Ошибка №5: пытаться “вручную следить” за инвариантами из внешнего кода.
Иногда встречается стиль: «пусть свойства будут публичными, а я буду аккуратным». Это работает до того момента, пока код не начинает расти и у вас не появляется ещё один “я”, который не видел ваших обещаний. Инварианты должны жить рядом с состоянием — внутри класса, а не в голове разработчика (в голове обычно и так очередь задач, кофе и экзистенциальные вопросы).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ