1. Інкапсуляція та стан обʼєкта
Коли ви пишете клас, ви ніби створюєте маленький всесвіт: усередині є стан (властивості) і правила життя (методи). І якщо відчинити двері цього всесвіту навстіж, будь-який зовнішній код зможе зайти й улаштувати там вечірку із салютом… у серверній. Інкапсуляція — це не «сховати все від усіх», а «зробити так, щоб змінювати стан можна було лише безпечними способами».
У Kotlin це вирішується дуже практично: ви обираєте, які властивості мають бути доступні всім (public), які — лише самому класу (private), а які — тільки в межах проєкту/модуля (internal). Що раніше ви почнете ставитися до цього як до звички, то рідше потраплятимете в ситуації на кшталт: «чому в мене баланс став -1 000, я ж нічого такого не робив?».
Що таке стан обʼєкта і як його легко зламати
Стан — це значення властивостей усередині обʼєкта. Проблема в тому, що навіть найпривітніший розробник (особливо майбутній ви — за два тижні) може випадково присвоїти туди щось безглузде. Баги від цього зазвичай неприємні: програма компілюється, запускається, але логіка починає «плисти».
Розгляньмо простий приклад — наш мініпроєкт «облік витрат». Нехай у нас є витрата:
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 не має бути порожнім" }
require(amount > 0) { "amount має бути > 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 не має бути порожнім" }
require(amount > 0) { "amount має бути > 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("Разом = ${book.totalAmount()}") // Разом = 320
}
Тут важливо, що стан (expenses) живе лише всередині ExpenseBook. Ззовні ви працюєте через осмислені операції add(...) і totalAmount(). Це і є «клас відповідає за свої інваріанти»: зовнішній код не може раптово очистити список, підсунути Expense з відʼємною сумою (бо Expense не створиться) або замінити колекцію цілком.
4. Типові помилки під час вибору видимості
Помилка №1: робити все public «бо так простіше».
Так, це простіше… до першого бага. Коли в класу купа public var, ви втрачаєте контроль над станом: не зрозуміло, хто, де й коли змінив властивість. У підсумку клас перестає бути «типом із поведінкою» і перетворюється на набір полів, які будь-хто може зіпсувати. Краще починати з більш закритого дизайну й відкривати лише те, що справді потрібно.
Помилка №2: віддавати назовні змінювану колекцію як є.
public val expenses: MutableList<Expense> виглядає невинно, але на практиці це «віддалене керування вашим обʼєктом». Ззовні можна викликати clear(), removeAt(), sort() — і ваш клас навіть не дізнається, що сталося. Безпечніше зберігати MutableList приватно, а назовні повертати List (або копію, або представлення лише для читання). Інакше ви самі ламаєте інкапсуляцію — навіть якщо поставили красиві require(...) у конструкторі.
Помилка №3: плутати private і «неможливо використати взагалі».
Новачки інколи сприймають private як покарання: «зробив приватним — і тепер нічого не працює». Насправді ідея в тому, що ви маєте дати інший спосіб взаємодії: методи, які виражають намір (add, totalAmount, removeLast тощо). Інкапсуляція не про «сховати й забути», а про «контролювати доступ».
Помилка №4: використовувати internal, не розуміючи меж модуля.
Якщо думати, що internal — це «майже private», можна випадково побудувати архітектуру, у якій половина проєкту залежить від внутрішніх деталей іншої половини. А потім буде боляче виносити код у бібліотеку або ділити його на модулі. Тримайте просте правило: internal — це деталі для «нашого проєкту», а не для майбутніх користувачів і не для сусідніх модулів.
Помилка №5: намагатися «вручну стежити» за інваріантами із зовнішнього коду.
Іноді трапляється стиль: «нехай властивості будуть публічними, а я буду обережним». Це працює до того моменту, поки код не починає зростати й у вас не зʼявляється ще один «я», який не бачив ваших обіцянок. Інваріанти мають жити поруч зі станом — усередині класу, а не в голові розробника (у голові зазвичай і так черга задач, кава й екзистенційні питання).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ