1. Властивість у Kotlin — це маленький API
Якщо ви лише починаєте, дуже легко думати так: «var x — це просто змінна, яка живе всередині обʼєкта». У побутовому сенсі це майже так… доки не зʼясується, що читання й запис властивості — це операції, і ними можна керувати. Kotlin побудований так, щоб ви проєктували обʼєкт не як «мішок із даними», а як набір безпечних дій.
Важливо вловити ідею: коли ви пишете obj.name, ви не «дістаєте поле з памʼяті» напряму (принаймні концептуально). Ви викликаєте читання властивості, тобто її getter. А коли ви пишете obj.name = "...", ви викликаєте запис властивості, тобто setter. Саме тому Kotlin дозволяє перевизначати поведінку читання й запису: ви задаєте невеликий «контракт» навколо властивості.
У документації Kotlin це видно навіть «за слідами компілятора»: коли у властивості є логіка читання й/або запису, по суті зʼявляються функції get() і set(value). Це добре помітно на прикладах генерації коду для делегованих властивостей, де get()/set(value) явно показані як форма аксесорів.
Щоб краще відчути механіку, ось мінісхема:
flowchart LR
A["Код ззовні: obj.x"] --> B["get()"]
C["Код ззовні: obj.x = v"] --> D["set(v)"]
B --> E["повернення значення"]
D --> F["перевірки / нормалізація / запис"]
2. Керування доступом і представленням властивості
private set: читаємо всі, змінюємо лише всередині класу
Іноді вам потрібна проста річ: нехай властивість можна читати звідки завгодно, але змінювати — лише всередині обʼєкта. Це надзвичайно популярний патерн, бо він добре підтримує інкапсуляцію. Наприклад, баланс рахунку, кількість спроб, внутрішній лічильник ID — усе це зазвичай не можна «присвоювати» ззовні як завгодно.
private set — це як скляна вітрина: дивитися можуть усі, а торкатися руками — лише персонал.
Мінімальний приклад: «баланс можна лише поповнювати»:
class Wallet {
var balanceCents: Int = 0
private set
fun deposit(amountCents: Int) {
require(amountCents > 0) { "amountCents має бути > 0" }
balanceCents += amountCents
}
}
fun main() {
val w = Wallet()
w.deposit(250)
println(w.balanceCents) // 250
}
Зверніть увагу на важливу деталь: ми не забороняємо читання balanceCents, але забороняємо прямий запис w.balanceCents = 1_000_000. Тепер баланс змінюється лише через зрозумілу дію deposit(...). А отже, у вас завжди є точка контролю (перевірки, логування, обмеження — що завгодно).
Чим відрізняється private set від private var:
private var — це «ззовні властивості взагалі не існує».
var ... private set — це «ззовні властивість існує, але тільки для читання».
Коли ви проєктуєте API класу, це дає користувачу дуже різні відчуття. Найчастіше варіант «бачити можна, змінювати не можна» — саме те, що потрібно.
Computed properties: властивість-вітрина над даними
Буває, що зберігати дані потрібно в одному форматі (наприклад, у центах), а показувати користувачу — в іншому (у доларах, із двома знаками після крапки). Або вам потрібен гарний текст «для представлення», але ви не хочете зберігати його окремо, щоб не створювати друге джерело істини (і не синхронізувати одне з іншим вручну).
Тут і зʼявляється computed property: властивість, яка не зберігає значення, а обчислює його в get().
До речі, у Kotlin є навіть стилістичне правило: властивість краща за функцію, якщо обчислення дешеве, не кидає винятків і повертає один і той самий результат за незмінного стану обʼєкта. Це добре відображає філософію computed properties.
Приклад: витрата в центах, відображення «долари.центи». Уявімо, що ми розвиваємо консольний застосунок обліку витрат (ми почали його раніше на колекціях, а тепер перенесли елементи в класи). Зробімо клас Expense, який зберігає суму в центах, а рядок для виводу обчислює.
class Expense(
val id: Int,
amountCents: Int,
note: String
) {
var amountCents: Int = amountCents
private set
var note: String = note
private set
val amountLabel: String
get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"
}
Тут amountLabel — computed property: він щоразу чесно будується з amountCents. Жодних «а раптом забули оновити рядок після зміни суми».
Computed property vs «збережене поле» — невелика таблиця:
| Підхід | Що це | Плюси | Мінуси |
|---|---|---|---|
| Збережувана властивість (із backing field) | |
швидко читати | ризик «двох джерел істини», якщо ви зберігаєте ще й похідні значення |
| Computed property | |
завжди актуально, не зберігає зайвого | обчислюється під час кожного читання (тому має бути розумно дешевим) |
3. Backing field і field: де зберігається значення
Коли властивість має звичайний вигляд var name: String = "...", Kotlin зберігає значення «всередині» обʼєкта. У термінах мови це називається backing field (внутрішнє поле-сховище). Коли ви пишете кастомний get() або set(value), вам іноді потрібно звернутися саме до цього сховища.
Для цього в Kotlin існує спеціальне слово field.
Якщо запамʼятати одне правило з цієї частини лекції, то ось воно: усередині setter не можна присвоювати властивості значення через її ж імʼя — отримаєте рекурсію. Потрібно присвоювати field.
Неправильний setter (рекурсія) — класична пастка для новачка:
class User {
var name: String = "Невідомо"
set(value) {
name = value.trim() // ❌ рекурсія: setter викликає сам себе
}
}
Що станеться? Setter викличе setter, той викличе setter… і так далі, доки не закінчиться стек викликів.
Правильний setter через field:
class User {
var name: String = "Невідомо"
set(value) {
field = value.trim() // ✅ записуємо у внутрішнє сховище
}
}
fun main() {
val u = User()
u.name = " Alice "
println(u.name) // Alice
}
Тут field — це «реальне значення властивості», а name — це «доступ до властивості через її API».
Де field існує, а де — ні:
field існує лише тоді, коли у властивості є backing field, тобто Kotlin справді зберігає значення. У computed property зазвичай немає сховища: значення обчислюється «на льоту», і тому field там просто не потрібен.
4. Валідація й нормалізація в set: підтримуємо інваріанти
Коли ми говоримо «інваріант», це звучить трохи академічно. Але сенс простий: обʼєкт має залишатися коректним завжди, а не лише «якщо користувач класу був достатньо уважним і не чіпав нічого зайвого».
Setter — чудове місце, щоб:
- нормалізувати введення (наприклад, trim() для рядків),
- відкидати некоректні значення через require(...),
- підтримувати обмеження діапазону (наприклад, рівень гучності 0..10).
Сетер — це такий «охоронець на вході»: якщо ви спробували занести в обʼєкт щось дивне, обʼєкт одразу каже «ні», а не вдає, що все добре.
Нормалізація: нотатка не зберігає зайвих пробілів.
У Expense ми хочемо, щоб нотатка зберігалася акуратно. Водночас ми не хочемо писати .trim() по всьому коду застосунку. Отже, нормалізацію потрібно централізувати.
class Expense(val id: Int, amountCents: Int, note: String) {
var note: String = note
set(value) {
field = value.trim()
}
}
Валідація: не можна встановити відʼємну суму.
Тепер додамо правило: сума витрати має бути більшою за нуль (або принаймні невідʼємною — вирішуйте, як правильно для продукту). Setter — гарне місце для такого правила. Але нам ще потрібно визначитися, чи хочемо ми взагалі дозволяти змінювати суму ззовні.
Зробімо так: ззовні змінювати не можна, зате всередині класу — можна (наприклад, через метод changeAmount(...)).
class Expense(val id: Int, amountCents: Int) {
var amountCents: Int = amountCents
private set(value) {
require(value >= 0) { "amountCents має бути >= 0" }
field = value
}
fun changeAmount(newAmountCents: Int) {
amountCents = newAmountCents
}
}
Зверніть увагу, як тут гарно поєднуються ідеї: private set закриває прямий запис ззовні, а setter однаково існує й захищає інваріант. Тобто навіть усередині класу ви випадково не зможете присвоїти «сміття» — перевірка зосереджена в одному місці.
5. Приклад: облік витрат із безпечними властивостями
Зараз буде важливий момент «як це застосовується в реальному коді». Бо інакше ґетери й сетери здаються магією заради магії. Уявімо, що наш консольний застосунок зберігає список витрат у памʼяті (ми вже вміємо працювати з MutableList, введенням/валідацією і командами).
Ми хочемо, щоб:
- витрата мала id, який не можна змінити;
- сума не могла стати відʼємною;
- нотатка була нормалізована (trim());
- у витрати була зручна computed-властивість для виводу суми.
Клас Expense (мінімально практичний):
class Expense(
val id: Int,
amountCents: Int,
note: String
) {
var amountCents: Int = amountCents
private set(value) {
require(value >= 0) { "amountCents має бути >= 0" }
field = value
}
var note: String = note
set(value) {
field = value.trim()
}
val amountLabel: String
get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"
fun changeAmount(newAmountCents: Int) {
amountCents = newAmountCents
}
}
Тут усе коротко, але вже «по-дорослому»: зовнішній код не може зламати обʼєкт випадковим присвоюванням.
Клас-сховище ExpenseBook: computed property для суми всіх витрат.
Тепер зробимо обʼєкт, який зберігає витрати. І додамо computed property totalCents, щоб не зберігати окрему змінну й не синхронізувати її вручну після кожного додавання/видалення.
class ExpenseBook {
private val expenses: MutableList<Expense> = mutableListOf()
val totalCents: Int
get() = expenses.sumOf { it.amountCents }
fun add(expense: Expense) {
expenses.add(expense)
}
}
totalCents тут — computed property. Так, він перераховується під час кожного читання. Але для навчального застосунку це нормально. І головне — логічно надійно: сума завжди відповідає списку.
Міні-демо в main:
fun main() {
val book = ExpenseBook()
book.add(Expense(id = 1, amountCents = 199, note = " кава "))
book.add(Expense(id = 2, amountCents = 2500, note = "продукти"))
println(book.totalCents) // 2699
}
6. Типові помилки під час роботи з ґетерами й сетерами
Помилка №1: рекурсія в setter через присвоювання «самому собі».
Найчастіше це виглядає так: ви пишете кастомний set(value) і за звичкою робите prop = value. Але prop = value — це знову виклик setter. У підсумку ви влаштовуєте вічний двигун, який працює не на кофеїні, а на StackOverflow. Лікується це одним словом: усередині setter використовуйте field = ...
Помилка №2: computed property перетворюють на «дорогу функцію», яку читають сто разів.
Computed property добра, коли обчислення дешеве й логічно схоже на «властивість-вітрину». Якщо всередині get() ви починаєте робити важку обробку даних, довгі цикли або складний парсинг, читання властивості перетворюється на несподівано дорогу операцію. Kotlin-стиль якраз підказує: властивість доречна, коли обчислення дешеве й передбачуване.
Помилка №3: валідацію розмазують по коду замість того, щоб тримати її в одному місці.
Новачки часто роблять так: в одному місці перевірили, в іншому забули, у третьому — «ну там точно нормально». У результаті обʼєкт інколи коректний, а інколи ні, і баги живуть своїм життям. Правильніше закріплювати інваріанти або в init, або в set, або в методах класу — але так, щоб будь-яка зміна стану проходила через один і той самий контроль.
Помилка №4: роблять public var, а потім намагаються «домовитися» з користувачем класу.
Іноді здається: «ну я ж напишу в коментарі, що не можна присвоювати відʼємне». Це працює приблизно як табличка «не натискати червону кнопку» у фільмах — рука так і тягнеться. Якщо значення не можна змінювати ззовні, закривайте запис через private set і надавайте методи, які змінюють стан безпечно.
Помилка №5: плутають «сховати властивість» і «закрити запис».
private var ховає властивість повністю — ззовні її наче не існує. var ... private set лишає публічне читання, але забороняє запис. Новачки інколи обирають private var, а потім дивуються, що їм доводиться писати окремий getBalance() і вручну підтримувати API. Зазвичай простіше й чистіше дати читання як властивість, а запис — закрити.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ