JavaRush /Курсы /Kotlin SELF /Extension‑свойства

Extension‑свойства

Kotlin SELF
38 уровень , 1 лекция
Открыта

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, это сигнал: либо вам нужен публичный метод/свойство в самом классе, либо вы пытаетесь нарушить инкапсуляцию и стоит переосмыслить дизайн.

1
Задача
Kotlin SELF, 38 уровень, 1 лекция
Недоступна
Команда терминала
Команда терминала
1
Задача
Kotlin SELF, 38 уровень, 1 лекция
Недоступна
Последний символ
Последний символ
1
Задача
Kotlin SELF, 38 уровень, 1 лекция
Недоступна
Ценник в кассе
Ценник в кассе
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ