1. Вступ
Коли ви вперше бачите Delegates.observable, виникає логічне запитання: «Навіщо мені якась магія з by, якщо я можу написати set(value) і там усе перевірити або записати в лог?» Запитання чесне. І відповідь теж проста: observable корисний тоді, коли вам потрібно централізовано реагувати на присвоювання й не розмазувати однаковий код по кількох методах або сеттерах. Наприклад — для логування, підрахунку змін, встановлення прапорця «об’єкт змінено».
Важливо одразу окреслити межі: це оглядова лекція. Ми не писатимемо власні делегати й не заглиблюватимемося в поглиблені теми — як саме влаштовані getValue/setValue, рефлексія та тонкощі багатопотоковості. Натомість навчимося впевнено користуватися Delegates.observable як готовим інструментом зі стандартної бібліотеки.
Що означає by у властивостях
Синтаксис делегованої властивості має такий вигляд: val/var імʼя: Тип by <вираз>. Читається це приблизно так: «читання і (якщо var) запис цієї властивості обслуговує інший об’єкт — делегат». У документації Kotlin це пояснюють так: аксесори властивості «переадресовуються» методам делегата (getValue() і setValue()).
Якщо згадати попередню лекцію, by lazy { ... } — це теж делегат (просто найпопулярніший, тому й здається «особливим»). Delegates.observable — делегат іншого типу: він зберігає значення та викликає обробник після кожного присвоювання.
Щоб не виникало відчуття, ніби ми «зненацька» стрибаємо в новий синтаксис, ось невеликий орієнтир:
| Механізм | Що розв’язує | Коли виконується код | Чи є кеш |
|---|---|---|---|
|
Контроль запису (валідація/нормалізація) | Щоразу під час присвоювання | Залежить від вас |
обчислювана властивість () |
Обчислення представлення | Щоразу під час читання | Ні |
|
Ліниве обчислення | Перший доступ на читання | Так (вбудований) |
|
Реакція на зміни | Щоразу під час присвоювання (після запису) | Так (делегат зберігає поточне значення) |
2. Як працює Delegates.observable
Базовий контракт і коли спрацьовує обробник
Delegates.observable приймає початкове значення й обробник змін. Обробник викликається щоразу, коли ви присвоюєте значення властивості. Є важливий нюанс: він спрацьовує після того, як присвоювання вже відбулося.
Обробник має три параметри: «опис властивості», старе значення й нове значення. У коді це зазвичай виглядає так:
import kotlin.properties.Delegates
class User {
var name: String by Delegates.observable("<без імені>") { _, old, new ->
println("$old -> $new") // <без імені> -> Alice, потім Alice -> Bob
}
}
fun main() {
val u = User()
u.name = "Alice"
u.name = "Bob"
}
Зверніть увагу: перший параметр обробника ми замінили на _. Це нормальна практика: якщо параметр не потрібен, не заважаємо собі читати код.
Є два важливі наслідки того, що обробник викликається «після присвоювання».
По‑перше, усередині обробника ви можете читати саму властивість і отримаєте вже нове значення (бо його вже записано). Це зручно, але інколи збиває з пантелику, якщо ви очікували «спочатку повідомлення, потім запис».
По‑друге, observable — не те місце, де ви «зупиняєте» неправильне значення. Якщо потрібно блокувати присвоювання, це вже інший інструмент (і сьогодні ми його не розбираємо, щоб не розповзатися на інші теми).
Перший параметр обробника і навіщо він інколи потрібен
Перший параметр обробника — це об’єкт, який описує властивість (по суті, метадані про властивість). Документація показує, що цей параметр приходить в обробник разом із old/new.
Ми поки що не вивчали рефлексію глибоко (і не треба), але одну практичну річ можна взяти вже зараз: у властивості є імʼя. Інколи це зручно для налагоджувального логу — особливо якщо у вас кілька observable-властивостей і ви хочете однаковий стиль повідомлень.
Зробімо гаманець трохи «балакучішим»:
import kotlin.properties.Delegates
class Wallet {
var balanceCents: Int by Delegates.observable(0) { prop, old, new ->
println("${prop.name}: $old -> $new") // balanceCents: 0 -> 100
}
private set
fun deposit(cents: Int) {
require(cents > 0) { "поповнення має бути > 0" }
balanceCents += cents
}
}
Так, це виглядає як «трішки магії». Але на практиці prop.name — радше охайний бонус: хочете — використовуєте, не хочете — ставите _.
Невеликий погляд під капот: чому є prop$delegate
Це міні‑огляд для розуміння, без занурення у написання власних делегатів. Під час делегування Kotlin зазвичай генерує приховане поле, яке зберігає делегат, а аксесори властивості просто викликають методи делегата. У документації це показують на прикладі, де з’являється поле виду prop$delegate.
Якщо дуже спростити, це можна уявити так:
obj.prop = value
↓
делегат.setValue(obj, опис_властивості, value)
↓
делегат записує нове значення
↓
делегат викликає обробник observable (old/new)
Саме тому observable виглядає «ніби вбудовано в мову»: насправді це просто готовий делегат зі стандартної бібліотеки, а синтаксис by — мовна підтримка делегування.
Тут важливо не перегнути: розуміти загальну ідею корисно, але «як написати свій делегат» — це вже окрема тема. Сьогодні туди не йдемо.
3. Практика: де observable допомагає, а де заважає
Міні‑трекер бюджету: логуємо зміну балансу
Щоб приклади не були «у вакуумі», продовжимо умовний застосунок: простий трекер бюджету (гаманець), у якому є баланс і операції поповнення/списання. Ми вже вміємо інкапсулювати стан: баланс не можна змінювати напряму — лише через методи, які підтримують інваріанти.
Створімо клас Wallet і додаймо реакцію на зміну балансу: друкуватимемо в консоль лог змін. Це типовий сценарій для observable: спостерігати за присвоюванням, не підміняючи бізнес-логіку.
import kotlin.properties.Delegates
class Wallet {
var balanceCents: Int by Delegates.observable(0) { _, old, new ->
println("баланс: $old -> $new") // наприклад: баланс: 0 -> 500
}
private set
fun deposit(cents: Int) {
require(cents > 0) { "поповнення має бути > 0" }
balanceCents += cents
}
}
І перевірмо в main:
fun main() {
val w = Wallet()
w.deposit(500) // баланс: 0 -> 500
w.deposit(250) // баланс: 500 -> 750
}
Тут відбуваються одразу дві корисні речі. По‑перше, ми зберігаємо правильний дизайн: ззовні баланс змінювати не можна (private set). По‑друге, логіка логування «приклеюється» до самої властивості й не дублюється в кожному методі.
observable та інкапсуляція: де мають жити правила і перевірки
Найпоширеніша помилка новачків — побачити observable, зрадіти й почати запихати туди всю бізнес-логіку: «якщо нове значення погане — відкочуємо», «якщо баланс пішов у мінус — виправимо» тощо. Проблема в тому, що observable — це реакція після присвоювання. Тобто ви вже записали значення й лише потім починаєте «щось виправляти».
Правильніше тримати правила там, де ви закладали їх спочатку: у методах, які змінюють стан, і/або в сеттерах із require(...). А observable залишати для побічних дій: логування, лічильника змін, встановлення прапорця «брудно».
Наприклад, зробімо методи deposit і withdraw та заборонимо виходити в мінус. А в observable лише позначатимемо, що стан змінювався.
import kotlin.properties.Delegates
class Wallet {
var isDirty: Boolean = false
private set
var balanceCents: Int by Delegates.observable(0) { _, old, new ->
if (old != new) isDirty = true
}
private set
fun withdraw(cents: Int) {
require(cents > 0) { "зняття має бути > 0" }
require(balanceCents >= cents) { "недостатньо коштів" }
balanceCents -= cents
}
}
І невеликий запуск:
fun main() {
val w = Wallet()
println(w.isDirty) // false
w.withdraw(0) // IllegalArgumentException (зняття має бути > 0)
}
Тут важлива ідея: ми не «лікуємо наслідки», а запобігаємо їм. observable просто фіксує факт зміни, але не «керує» коректністю.
observable vs звичайний сеттер: що обрати
observable — не заміна сеттеру. Це інший інструмент.
Якщо ваше завдання — нормалізувати рядок (trim, приведення регістру), перевірити діапазон або підтримувати інваріант, то сеттер (або метод) зазвичай зрозуміліший і прямолінійніший. Якщо ж завдання — реагувати на зміни (логувати, рахувати зміни, сповіщати), observable часто робить код компактнішим.
Уявімо, що в нашому застосунку є «рівень гучності сповіщень», і ми хочемо: зберігати значення від 0 до 10, а під час зміни друкувати лог. Нехай правила живуть у методі, а лог — в observable.
import kotlin.properties.Delegates
class NotificationSettings {
var volume: Int by Delegates.observable(5) { _, old, new ->
println("гучність: $old -> $new")
}
private set
fun setVolume(x: Int) {
require(x in 0..10) { "гучність має бути в межах 0..10" }
volume = x
}
}
Тепер «контракт класу» читається приємно: setVolume — єдині двері для зміни, volume можна лише читати, а логування — централізоване.
observable у реальному житті: лічильник змін
У великих проєктах observable часто асоціюють з UI: «змінилося поле — онови екран». Але навіть у консольному застосунку він може бути зручним.
Наприклад, ви хочете рахувати кількість змін балансу, щоб наприкінці показати: «за сесію було N фінансових операцій». Так, можна інкрементити лічильник у кожному методі. Але якщо операції з часом «розповзуться» (з’являться ще знижки, перекази, комісії), десь легко забути оновити лічильник — і тоді почнеться «чому статистика бреше».
З observable можна прив’язати лічильник до самого факту зміни властивості:
import kotlin.properties.Delegates
class Wallet {
var changeCount: Int = 0
private set
var balanceCents: Int by Delegates.observable(0) { _, old, new ->
if (old != new) changeCount++
}
private set
fun deposit(cents: Int) {
require(cents > 0) { "поповнення має бути > 0" }
balanceCents += cents
}
}
Перевірка:
fun main() {
val w = Wallet()
w.deposit(100)
w.deposit(100)
println(w.changeCount) // 2
}
Так, це все ще побічний ефект. Але він локалізований і прозорий: «кожна зміна балансу збільшує лічильник».
4. Типові помилки під час роботи з Delegates.observable
Помилка №1: розміщувати бізнес-логіку й правила коректності в обробнику observable.
Дуже легко почати робити в обробнику все підряд: перевіряти діапазони, відкочувати значення, «лагодити» об’єкт. Проблема в тому, що обробник викликається після присвоювання, а отже ви працюєте вже зі зміненим станом. Значно надійніше тримати інваріанти в методах (deposit/withdraw) або в сеттері з require(...), а в observable залишити логування та прості реакції.
Помилка №2: випадково влаштувати рекурсію — присвоювати спостережувану властивість усередині обробника.
Якщо в обробнику ви зробите щось на кшталт balanceCents = ..., ви знову викличете присвоювання, потім знову обробник — і так аж до StackOverflowError або до моменту, коли ви почнете сумніватися в реальності того, що відбувається. Якщо потрібно «виправляти» значення, найчастіше це ознака того, що потрібен інший дизайн (валідувати до присвоювання), а не спроба лагодити вже записане.
Помилка №3: забути, що обробник викликається навіть під час присвоювання того самого значення.
observable реагує на факт присвоювання. Якщо ви зробили x = x, обробник теж може викликатися. Тому, якщо ви рахуєте зміни або ставите прапорець isDirty, майже завжди корисно порівнювати old і new та реагувати лише за реальної зміни: if (old != new) ...
Помилка №4: перетворювати observable на прихований логер, який завжди шумить.
Налагоджувальні println в обробнику — це зручно в навчальних прикладах, але в реальному проєкті такий лог швидко перетворюється на шум. Навіть у навчальному застосунку краще поступово привчатися: логування або робити вимикним (хоча б через прапорець), або друкувати лише справді важливе, або обмежувати вивід.
Помилка №5: очікувати, що observable замінює інкапсуляцію.
Інколи здається: «раз я спостерігаю зміни, то можна зробити public var, нехай хто завгодно змінює, а я в обробнику проконтролюю». Це слизька доріжка. Спостереження не замінює обмеження доступу. Якщо властивість не можна змінювати ззовні — робіть private set і змінюйте її через методи. А observable нехай буде вашим охайним «датчиком», а не єдиним охоронцем на вході.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ