1. Вступ
Коли ви пишете навчальний проєкт (наприклад, наш консольний трекер витрат), код швидко обростає дрібними «зручностями»: то нормалізувати команду, то зробити гарний підпис для суми, то обчислити «останній індекс рядка, але безпечно». Усе це можна оформити функціями — і це нормально. Але інколи за змістом хочеться саме властивість, бо ми не «виконуємо дію», а «дивимося характеристику» об’єкта: expense.title, money.formatted, command.normalized.
Extension‑властивості допомагають зробити код читабельнішим. Вони особливо доречні там, де ви форматуєте виведення, будуєте звіти, робите прості перевірки й перетворення. Важливо лише пам’ятати: це не магія і не «додавання полів в об’єкт». Kotlin не змінює вихідні класи — ми просто отримуємо зручніший синтаксис доступу.
2. Як влаштовані extension‑властивості: чому це не поле
Extension‑властивість виглядає як властивість, але за суттю це просто пара функцій доступу (ґетер/сетер). Тому в неї немає «місця всередині об’єкта», куди можна покласти значення. Kotlin чесно каже: хочете extension‑property — будь ласка, але лише через get()/set(). Саме тому ви не можете написати = 123 як «ініціалізацію» — компілятор це забороняє.
Базові форми такі:
val String.normalized: String
get() = trim().lowercase()
var StringBuilder.indent: Int
get() = 0 // приклад, але беззмістовний
set(value) { /* ... */ }
І корисна «ментальна модель» (дуже грубо, але для розуміння працює):
obj.someProp
↓
getSomeProp(obj) // умовно: згенерована функція-ґетер
Можна навіть намалювати міні‑схему:
flowchart LR
A["obj.someProp"] --> B["someProp.get()"]
B --> C["виконується код get()"]
C --> D["повертається значення"]
Щоб не плутатися, тримайте в голові просте правило: якщо ви можете уявити це як «обчислюване значення прямо зараз» — extension‑властивість підходить. Якщо ж вам потрібно «запам’ятати значення в об’єкті й потім діставати» — extension‑властивість не підходить.
Нижче — невелика таблиця-порівняння, щоб закріпити різницю між звичайною властивістю класу та extension‑властивістю:
| Що порівнюємо | Звичайна властивість у класі | Extension‑властивість |
|---|---|---|
| Чи є backing field (внутрішнє поле зберігання) | Може бути (field) | Немає |
| Чи можна писати = ... як ініціалізацію | Так | Ні, заборонено |
| Чи можна зробити обчислювану властивість | Так | Так |
| Чи можна зберігати стан «в об’єкті» | Так | Ні (лише зовнішні трюки) |
| Доступ до private полів класу | Так | Ні |
3. Приклади: рядки та моделі даних
Нормалізація введення команд
У консольних застосунках половина «болю» — це введення користувача. Користувач вводить " ADD ", "Add", "add", інколи навіть "aDd" (і за що їх любити?). Тож нормалізація рядка — річ вічна.
У попередніх лекціях ми зробили б це функцією. Але тут можна дозволити собі властивість, бо «нормалізована версія рядка» — це саме характеристика рядка, а не «дія, яка щось змінює».
val String.normalizedCommand: String
get() = trim().lowercase()
fun main() {
val raw = " AdD "
println(raw.normalizedCommand) // add
}
Такий код читається майже як «у рядка є нормалізована команда». Але пам’ятайте: рядок від цього не змінюється — ми просто щоразу обчислюємо результат.
Ще один класичний приклад — «останній індекс, але безпечно». У порожнього рядка length == 0, і length - 1 дасть -1. Це нормально, якщо ви так і задумували (наприклад, щоб узагалі не лізти в індекси).
val String.lastIndexOrMinusOne: Int
get() = length - 1
fun main() {
println("".lastIndexOrMinusOne) // -1
println("kotlin".lastIndexOrMinusOne) // 5
}
Тут extension‑властивість — це, по суті, «документація в коді»: ви бачите ім’я lastIndexOrMinusOne і відразу розумієте, що станеться на порожньому рядку.
Форматування Money і рядки виведення для Expense
Коли проєкт росте, ви майже неминуче вводите власні типи. У трекері витрат зручно мати тип «гроші», щоб не плутати «франки/долари» з «внутрішніми копійками/центами» і не складати випадково рядки з числами.
Зробимо мінімальний тип грошей через value class (ми з ними вже знайомі з блоку про типобезпечність), а форматування винесемо в extension‑властивість. Важливо: форматування — це «представлення». Воно не змінює об’єкт, тож властивість тут доречна.
@JvmInline
value class Money(val cents: Long)
val Money.formatted: String
get() {
val sign = if (cents < 0) "-" else ""
val abs = kotlin.math.abs(cents)
val dollars = abs / 100
val rest = abs % 100
return "$sign$dollars.${rest.toString().padStart(2, '0')}"
}
fun main() {
println(Money(12345).formatted) // 123.45
println(Money(-90).formatted) // -0.90
}
Так, це обчислення не «безплатне» (там і abs, і padStart). Але воно досить легке й очікуване. Коли ви пишете money.formatted, ви наче просите «рядкове представлення». Це нормально.
Тепер додамо модель витрати та зробимо гарний рядок для списку. Знову ж: ми не хочемо засмічувати data class форматерами (часто модель даних хочеться тримати простою), тож extension‑властивість — хороший компроміс.
data class Expense(
val title: String,
val amount: Money,
val category: String
)
val Expense.listLine: String
get() = "${category.padEnd(10)} | ${title.padEnd(15)} | ${amount.formatted}"
fun main() {
val e = Expense("Coffee", Money(390), "food")
println(e.listLine) // food | Coffee | 3.90
}
Зверніть увагу на «чесність» назви. listLine прямо каже: це рядок для виведення. Якби ми назвали це val Expense.info, стало б менш зрозуміло, який саме формат і де його використовують.
4. var extension‑властивості та зовнішнє зберігання
Із val extension‑властивостями зазвичай усе добре: вони обчислюють і повертають. А от var викликає закономірне питання: «Якщо я роблю set(value), куди воно записується?» І тут варто зупинитися й подумки запитати себе: «А я точно не намагаюся причепити поле до об’єкта?»
Kotlin дозволяє var extension‑властивості, але нагадує: у них немає backing field, тож самі по собі вони нічого не зберігають. Якщо вам потрібно «ніби прикріпити значення», доведеться зберігати його десь зовні (зазвичай у Map).
Покажемо це на прикладі, дуже схожому на той, що наводять у документації Kotlin. Припустімо, ми хочемо додати до витрати «тег» (наприклад, "coffee", "home", "urgent"), але не хочемо (або не можемо) змінювати Expense прямо зараз.
Зробимо зовнішнє сховище та extension‑властивість, яка в нього «дивиться». Щоб приклад був стабільнішим, прив’яжемося до id, а не до об’єкта цілком. (Зверніть увагу: тут для прикладу використано спрощену версію Expense.)
data class Expense(
val id: Int,
val title: String
)
private val tagsById = mutableMapOf<Int, String>()
var Expense.tag: String?
get() = tagsById[id]
set(value) {
if (value == null) tagsById.remove(id) else tagsById[id] = value
}
fun main() {
val e = Expense(id = 1, title = "Coffee")
println(e.tag) // null
e.tag = "drink"
println(e.tag) // drink
}
Технічно все працює. Але з погляду дизайну це вже «тонкий лід»: Expense виглядає так, ніби в нього є поле tag, а насправді тег живе в окремій мапі. Це може бути нормальним тимчасовим рішенням, але його легко перетворити на джерело дивних багів (наприклад, якщо id перевикористовують або якщо ви забули очищати tagsById).
5. Обмеження: ініціалізація, важкий get() і побічні ефекти
Чому не можна ініціалізувати extension‑властивість
Іноді новачок думає так: «Гаразд, раз немає поля, я зараз його створю. Напишу var X.y = 0 — і готово». Kotlin на це відповідає: «Ініціалізатори для extension‑властивостей заборонені». Причина проста: ініціалізатор передбачає зберігання значення, а зберігати його ніде — ми не можемо фізично додати поле в чужий об’єкт.
Ось як виглядає «неправильна мрія», яка не скомпілюється:
data class User(val name: String)
var User.age = 0 // НЕ скомпілюється: немає get/set і немає поля
Якщо ви справді хочете, щоб у User було поле age, правильні варіанти зазвичай такі: додати властивість у сам клас, зробити обгортку (wrapper) навколо об’єкта або чесно зберігати стан у зовнішньому сховищі (наприклад, у репозиторії/мапі), не вдаючи, ніби це «поле всередині».
І так, зовнішнє сховище можна сховати за var extension‑властивістю (як у прикладі вище). Але це вже усвідомлений трюк, а не «звичайний спосіб додати поле».
Важкий get() і неочікувані ефекти
Є тонка психологічна штука: коли ви бачите obj.prop, мозок очікує, що це буде швидко й без сюрпризів. А коли ви бачите obj.calculateTotal(), мозок заздалегідь готується, що там може бути обчислення. Тому властивість із важким get() може стати пасткою для читача — і для вас же за місяць.
Покажемо на прикладі списку витрат. Здається, спокусливо написати так:
@JvmInline
value class Money(val cents: Long)
data class Expense(val amount: Money)
val List<Expense>.total: Money
get() {
var sum = 0L
for (e in this) sum += e.amount.cents
return Money(sum)
}
Формально це коректно. Але тепер expenses.total — це щоразу прохід по всьому списку. Якщо ви викличете цю властивість у циклі десять разів, то зробите десять проходів. Для навчального проєкту на малих даних це терпимо, але звичка — небезпечна.
У таких випадках часто краще вибрати extension‑функцію (яку ми вже вміємо писати), щоб сама назва виклику чесніше попереджала про обчислення. Те, що Kotlin дозволяє властивість, не означає, що так завжди краще.
Ще гірше, коли get() має побічні ефекти. Наприклад, друкує щось або мутує колекцію. Це перетворює obj.prop на «мінімальний жахастик на ніч»: ви просто прочитали значення, а застосунок раптово щось змінив або надрукував у консоль.
6. Де зберігати extension‑властивості в проєкті
Коли extensions стають корисними, з’являється спокуса зробити файл Extensions.kt і складати туди все підряд: рядки, гроші, звіти, котиків. Це швидко перетворюється на кладовище випадкових функцій, у якому неможливо щось знайти (як папка Downloads, тільки гірше).
Kotlin‑конвенції пропонують доволі здорову ідею: extension‑функції/властивості, які важливі «для всіх клієнтів» класу, краще тримати поруч із класом. А ті, що потрібні конкретному сценарію (наприклад, лише CLI‑виведенню), варто тримати поруч із цим сценарієм, а не глобально.
Для нашого навчального трекера витрат це можна пояснити так: формат Expense.listLine — це саме виведення для консолі. Отже, логічно тримати його поруч із CLI‑кодом або у файлі форматування. А от Money.formatted, якщо його використовують у різних місцях, логічно тримати поруч із Money або в одному «фінансовому» модулі проєкту.
І ще один практичний момент: extension‑властивості під’єднуються імпортами, і конфлікти імен цілком реальні. Що точніші та предметніші імена ви обираєте (listLine, formatted, normalizedCommand), то менше шансів, що ви випадково створите два val String.value у різних пакетах і потім гратимете в «вгадай імпорт».
7. Типові помилки під час роботи з extension‑властивостями
Помилка №1: спроба «прикрутити поле» через var X.prop = ....
Це частий рефлекс після мов, у яких можна робити динамічні поля або є метапрограмування. Kotlin так не працює: extension‑властивість не може мати ініціалізатора, бо це передбачає зберігання значення в об’єкті, а об’єкт ми не змінюємо. Правильна стратегія — писати get/set і чітко розуміти, де насправді житиме стан.
Помилка №2: «важкий» get() під виглядом простої властивості.
Якщо get() робить помітну роботу (проходить по великій колекції, будує довгий рядок, читає файл), читач коду може не очікувати такої «ціни» доступу. У результаті з’являються неочікувані підгальмовування та дублювання обчислень. У таких випадках зазвичай чесніше вибрати функцію, щоб сам синтаксис підказував: «тут обчислення».
Помилка №3: побічні ефекти в get(): друк, мутація, приховані зміни.
Властивості в Kotlin читаються як «взяв значення». Якщо ваш get() усередині робить println(), змінює глобальну змінну або модифікує колекцію, код стає непередбачуваним. Такі речі краще оформлювати функціями з промовистими іменами, щоб дія була явною.
Помилка №4: var extension‑властивість, яка «ніби зберігає», але насправді зберігає в незрозумілому місці.
Трюк із MutableMap інколи потрібен (як тимчасова прокладка), і Kotlin навіть показує подібний приклад у документації. Але якщо читачеві коду не очевидно, де живе значення і хто відповідає за життєвий цикл цього сховища, ви отримуєте важковідтворювані баги. Якщо властивість за змістом має бути частиною моделі — найчастіше краще додати звичайну властивість у клас.
Помилка №5: очікувати доступу до private полів класу з extension‑властивості.
Extension‑властивість не стає «частиною класу» й не отримує привілеїв. Вона бачить лише те, що доступно ззовні. Якщо ви вперлися в private, це сигнал: або вам потрібен публічний метод/властивість у самому класі, або ви намагаєтеся порушити інкапсуляцію — і варто переосмислити дизайн.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ