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‑свойство. Важно: форматирование — это «представление», оно не меняет объект, поэтому property здесь уместно.
@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‑property, которое в него смотрит. Чтобы пример был более стабильным, привяжемся к 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‑property (как в примере выше). Но это уже осознанный трюк, а не «обычный способ добавить поле».
Тяжёлый 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 позволяет property, не означает, что так всегда лучше.
Ещё хуже, когда 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‑property, который «как будто хранит», но на самом деле хранит в непонятном месте.
Трюк с MutableMap иногда нужен (как временная прокладка), и Kotlin даже показывает подобный пример в документации. Но если читателю кода не очевидно, где живёт значение и кто отвечает за жизненный цикл этого хранилища, вы получаете трудноотлаживаемые баги. Если свойство по смыслу должно быть частью модели — чаще всего лучше добавить обычное свойство в класс.
Ошибка №5: ожидать доступа к private полям класса из extension‑свойства.
Extension‑свойство не становится «частью класса» и не получает привилегий. Оно видит только то, что видно снаружи. Если вы упёрлись в private, это сигнал: либо вам нужен публичный метод/свойство в самом классе, либо вы пытаетесь нарушить инкапсуляцию и стоит переосмыслить дизайн.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ