JavaRush /Курси /Kotlin SELF /Делегати властивостей: Delegates.observable як реакція на...

Делегати властивостей: Delegates.observable як реакція на присвоювання

Kotlin SELF
Рівень 32 , Лекція 4
Відкрита

1. Вступ

Коли ви вперше бачите Delegates.observable, виникає логічне запитання: «Навіщо мені якась магія з by, якщо я можу написати set(value) і там усе перевірити або записати в лог?» Запитання чесне. І відповідь теж проста: observable корисний тоді, коли вам потрібно централізовано реагувати на присвоювання й не розмазувати однаковий код по кількох методах або сеттерах. Наприклад — для логування, підрахунку змін, встановлення прапорця «об’єкт змінено».

Важливо одразу окреслити межі: це оглядова лекція. Ми не писатимемо власні делегати й не заглиблюватимемося в поглиблені теми — як саме влаштовані getValue/setValue, рефлексія та тонкощі багатопотоковості. Натомість навчимося впевнено користуватися Delegates.observable як готовим інструментом зі стандартної бібліотеки.

Що означає by у властивостях

Синтаксис делегованої властивості має такий вигляд: val/var імʼя: Тип by <вираз>. Читається це приблизно так: «читання і (якщо var) запис цієї властивості обслуговує інший об’єкт — делегат». У документації Kotlin це пояснюють так: аксесори властивості «переадресовуються» методам делегата (getValue() і setValue()).

Якщо згадати попередню лекцію, by lazy { ... } — це теж делегат (просто найпопулярніший, тому й здається «особливим»). Delegates.observable — делегат іншого типу: він зберігає значення та викликає обробник після кожного присвоювання.

Щоб не виникало відчуття, ніби ми «зненацька» стрибаємо в новий синтаксис, ось невеликий орієнтир:

Механізм Що розв’язує Коли виконується код Чи є кеш
set(value)
Контроль запису (валідація/нормалізація) Щоразу під час присвоювання Залежить від вас
обчислювана властивість (
get() = ...
)
Обчислення представлення Щоразу під час читання Ні
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 нехай буде вашим охайним «датчиком», а не єдиним охоронцем на вході.

1
Опитування
Властивості та інкапсуляція, рівень 32, лекція 4
Недоступний
Властивості та інкапсуляція
Властивості та інкапсуляція
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ