JavaRush /Курсы /Kotlin SELF /Secondary constructors и делегирование this(...) в Kotlin...

Secondary constructors и делегирование this(...) в Kotlin

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

1. Зачем нужны secondary constructors

Когда вы только начинаете писать классы, кажется, что одного конструктора вполне достаточно: «ну мы же всегда создаём Expense(title, amount, category)». А потом приходит реальность: данные приходят в разных форматах. Пользователь вводит число строкой, из консоли прилетает одна строка title;amount;category, иногда часть полей хочется сделать дефолтными, а иногда нужен «короткий путь» типа Expense("Кофе", 250), а категорию поставить по умолчанию.

И тут обычно проявляются два сценария боли.

Первый: вы тащите парсинг и нормализацию наружу, и main() превращается в место, где одновременно живут ввод, разбор, проверки, сообщения об ошибках и логика добавления в список. Получается «комбайн», который сложно читать и ещё сложнее поддерживать.

Второй: вы пытаетесь «уместить всё» в primary constructor, добавляете туда кучу параметров (String?, флаги, дефолты), и конструктор из «чёткого входа» превращается в «шведский стол»: взять можно что угодно, но непонятно, что будет на выходе.

Secondary constructors — это третий путь: оставить primary constructor строгим и понятным, а дополнительные способы создания оформить как «адаптеры», которые приводят вход к правильной форме.

Kotlin прямо описывает вторичные конструкторы как дополнительные способы инициализации, полезные, когда нужно несколько вариантов создания объекта.

2. Основы: синтаксис и делегирование

Как объявляется secondary constructor

Secondary constructor объявляется внутри тела класса и начинается ключевым словом constructor. У него есть параметры и (обычно) тело в { ... }.

Самая важная часть — это this(...). Это делегирование конструктора: мы говорим «создай объект через другой конструктор этого же класса» (обычно через primary). При наличии primary constructor вторичные конструкторы должны делегировать ему (напрямую или через другие secondary), чтобы общий путь инициализации был единым.

Минимальный пример «адаптации формата»:

class User(val name: String, val age: Int) {

    constructor(name: String, ageText: String) : this(
        name = name,
        age = ageText.toIntOrNull() ?: 0
    )
}

fun main() {
    val u1 = User("Ann", 20)
    val u2 = User("Bob", "18")
    val u3 = User("Eve", "oops")

    println("${u1.name} ${u1.age}") // Ann 20
    println("${u2.name} ${u2.age}") // Bob 18
    println("${u3.name} ${u3.age}") // Eve 0
}

Обратите внимание: secondary constructor здесь не «дублирует» создание объекта. Он переводит ageText: String в age: Int и делегирует дальше.

Secondary constructor как адаптер, а не второй «завод»

Очень хочется начать писать в secondary constructor «настоящую инициализацию»: проверки, нормализацию, сложные условия, присваивания. Но если так сделать, вы фактически построите две разные дороги к созданию объекта — и они начнут расходиться по правилам.

Сегодня в одном конструкторе вы сделали trim(), завтра забыли в другом, послезавтра добавили новую проверку только в одном, и вот уже один и тот же класс ведёт себя по-разному в зависимости от того, каким конструктором его создали. Это неприятно даже без явных багов — просто читать такой код тяжело.

Практичная ментальная модель такая:

Primary constructor + init — это «единый завод» по выпуску корректных объектов.

Secondary constructors — это «приёмные пункты», которые принимают ввод в разной форме и приводят его к стандарту завода.

Если хочется увидеть это как схему:

flowchart TD
    A["Сырой ввод (строки, CSV, частичные данные)"] --> B["Secondary constructor (адаптер)"]
    B --> C["Primary constructor + init (единые правила)"]
    C --> D["Готовый корректный объект"]

Смысл не только в «красоте архитектуры». Делегирование в primary гарантирует, что общий код инициализации (включая init-блоки и инициализаторы свойств) отработает единообразно.

Прямое и косвенное делегирование

В реальной жизни у класса может быть несколько secondary constructors. Иногда удобно, чтобы один вторичный делегировал другому, а тот уже — primary. Это косвенное делегирование, и Kotlin считает это нормальным: главное, чтобы в итоге мы пришли к primary (если он есть).

Сравним два вида делегирования на простом примере:

class Point(val x: Int, val y: Int) {

    // прямое делегирование в primary
    constructor(x: Int) : this(x, 0)

    // косвенное делегирование: сначала в constructor(x: Int), потом в primary
    constructor() : this(0)
}

fun main() {
    println("A: ${Point(5, 7).x}, ${Point(5, 7).y}") // A: 5, 7
    println("B: ${Point(5).x}, ${Point(5).y}")       // B: 5, 0
    println("C: ${Point().x}, ${Point().y}")         // C: 0, 0
}

Почему это удобно? Потому что иногда вы хотите выразить правило «если y не указан — он 0», а «если вообще ничего не указано — и x, и y равны 0». Цепочка делегирования позволяет не повторять эти правила.

3. Кейс: Expense в трекере расходов

Теперь реализуем приложение, которое у нас развивается по курсу: простой консольный трекер расходов (условный BudgetBuddy).

Проблема, с которой мы сталкиваемся: в CLI пользователь вводит данные строками. Значит, где-то нужно делать trim(), toIntOrNull(), проверки «пусто/не пусто», «сумма > 0». Если оставить это снаружи, main() будет пухнуть. Если запихнуть в primary constructor много вариантов входа — он станет мутным.

Поэтому сделаем так:

  • Primary constructor Expense(...) принимает уже нормализованные значения.
  • Secondary constructors принимают сырой ввод и приводят к нормальному виду.

Primary constructor и init: единые правила корректности

Начнём с primary constructor и init. init — удобное место для валидации через require(...).

class Expense(
    val title: String,
    val amount: Int,
    val category: String
) {
    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amount > 0) { "amount must be > 0, got $amount" }
        require(category.isNotBlank()) { "category must not be blank" }
    }

    override fun toString(): String = "$title: $amount [$category]"
}

Проверки тут простые: пустые строки запрещены, сумма должна быть положительной. Это наш инвариант: объект расхода не должен существовать в бессмысленном состоянии вроде «пустое название, сумма -500».

Secondary constructor: принимаем строки и нормализуем

Теперь сделаем удобный способ создания Expense из строк, как будто они пришли из readln(). Здесь secondary constructor будет делать три вещи: trim(), парсить amount, и делегировать в primary.

Важно: мы не превращаем secondary constructor в «монстра», он остаётся адаптером. А «главные правила» всё равно живут в init.

class Expense(
    val title: String,
    val amount: Int,
    val category: String
) {
    constructor(titleInput: String, amountText: String, categoryInput: String) : this(
        title = titleInput.trim(),
        amount = amountText.trim().toIntOrNull() ?: 0,
        category = categoryInput.trim()
    )

    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amount > 0) { "amount must be > 0, got $amount" }
        require(category.isNotBlank()) { "category must not be blank" }
    }

    override fun toString(): String = "$title: $amount [$category]"
}

Если человек ввёл amountText = "oops", то toIntOrNull() вернёт null, мы подставим 0, и уже init «завалит» создание расхода через require(amount > 0). Это fail-fast поведение: объект не создаётся, потому что вход некорректен.

Ещё один формат: одна строка title;amount;category

В консольных утилитах часто хочется сокращать ввод. Например, команда может выглядеть так: add Кофе;250;еда.

То есть пользователь вводит одну строку, а мы её разбираем. Сделаем минимально: split(';'), проверка количества частей и снова делегирование.

Поскольку конструктор не может «вернуть null», нужно выбрать стратегию: либо ставить дефолты (что опасно), либо падать через require. Для учебного проекта и дисциплины корректности лучше падать.

class Expense(
    val title: String,
    val amount: Int,
    val category: String
) {
    constructor(line: String) : this(
        titleInput = line.split(';').getOrNull(0) ?: "",
        amountText = line.split(';').getOrNull(1) ?: "0",
        categoryInput = line.split(';').getOrNull(2) ?: ""
    )

    constructor(titleInput: String, amountText: String, categoryInput: String) : this(
        title = titleInput.trim(),
        amount = amountText.trim().toIntOrNull() ?: 0,
        category = categoryInput.trim()
    )

    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amount > 0) { "amount must be > 0, got $amount" }
        require(category.isNotBlank()) { "category must not be blank" }
    }

    override fun toString(): String = "$title: $amount [$category]"
}

Здесь есть «подозрительное место»: line.split(';') вызывается три раза. Мы могли бы оптимизировать, но пока выбираем простоту и читаемость. Сейчас цель — чтобы код не выглядел как заклинание на древнем языке.

Обратите внимание на цепочку: constructor(line) делегирует в constructor(titleInput, amountText, categoryInput), а тот — в primary. Это как раз косвенное делегирование.

Как это выглядит в main()

Когда secondary constructors сделаны правильно, main() начинает выглядеть проще: он выбирает «каким способом создать объект», но не занимается постоянным trim() и toIntOrNull().

Мини-демо:

fun main() {
    val expenses = mutableListOf<Expense>()

    val e1 = Expense("Кофе", 250, "еда")
    val e2 = Expense("  Такси  ", "510", " транспорт ")
    val e3 = Expense("Пицца;900;еда")

    expenses.add(e1)
    expenses.add(e2)
    expenses.add(e3)

    expenses.forEach { println(it) }
    // Кофе: 250 [еда]
    // Такси: 510 [транспорт]
    // Пицца: 900 [еда]
}

main() почти не знает о том, как нормализуются данные. Он говорит: «создай расход», а класс сам гарантирует, что расход будет корректным — или не будет создан вообще.

4. Чего избегать в secondary constructors

Secondary constructors легко превратить в место, где происходит «всё подряд». Но если вы хотите, чтобы класс оставался предсказуемым, полезно помнить несколько ограничений языка и несколько ограничений здравого смысла.

С точки зрения языка: если у класса есть primary constructor, secondary должен делегировать в него напрямую или косвенно. Это правило помогает избежать ситуации, где одна ветка инициализации обходит общий код.

С точки зрения дизайна: если вы обнаружили, что во вторичном конструкторе у вас 30 строк логики, много if/else и ещё «чуть-чуть печати в консоль для отладки», — вероятно, вы строите второй завод по созданию объектов. Это не «запрещено», но почти гарантированно приведёт к рассинхронизации правил.

И ещё один практический момент: когда вы создаёте объект через secondary constructor, сначала отрабатывает делегирование (primary + init), и только потом выполняется тело secondary. Поэтому не рассчитывайте, что тело secondary — это «самое начало жизни объекта». Оно скорее «дополнительный шаг после базовой инициализации».

5. Типичные ошибки

Ошибка №1: дублировать проверки в каждом secondary constructor.
Часто студент делает так: в primary проверяет одно, во втором конструкторе проверяет другое, в третьем — вообще забывает проверить. Через неделю вы добавляете новое правило («категория не может быть пустой») и начинаете вспоминать, где ещё нужно дописать require. Лечится просто: общие правила корректности живут в одном месте — в init (или рядом с primary), а secondary только приводит вход к нужному виду.

Ошибка №2: пытаться «обойти» primary constructor и собрать объект кусками.
Иногда хочется написать secondary constructor, который не делегирует this(...), а просто присваивает поля. В Kotlin при наличии primary constructor так нельзя: вторичный конструктор обязан делегировать в primary (прямо или косвенно).
На практике это даже хорошо: язык заставляет вас держать единый путь инициализации и не плодить «тайные входы» в класс.

Ошибка №3: делать secondary constructor слишком «умным» и превращать его в сценарий.
Secondary constructor — плохое место для сложного ветвления «если команда такая — делаем так, иначе так». Такой код обычно относится не к созданию объекта, а к логике приложения (например, к разбору команд CLI). Если вы запихнёте это в конструктор, объект начнёт «знать» слишком много про внешний мир, а тестировать и читать класс станет тяжело.

Ошибка №4: использовать «волшебные дефолты», которые создают некорректный объект.
В примерах выше мы делали toIntOrNull() ?: 0, и это нормально только потому, что дальше стоит require(amount > 0). Если же вы без проверок оставляете дефолты вроде 0, "", "UNKNOWN", то вы получаете объект, который формально существует, но по смыслу невалиден. Потом такие объекты «протекают» в коллекции, отчёты, сортировки — и вы ловите баги уже далеко от места создания.

Ошибка №5: путать роль secondary constructor и значений по умолчанию.
Иногда secondary constructor пишут там, где достаточно дефолтного параметра в primary. Например, если категория почти всегда "еда", возможно, лучше сделать category: String = "еда" в primary, а не плодить constructor(title, amount). Secondary нужны тогда, когда меняется именно форма входа или хочется выразить цепочку делегирования как отдельные «удобные входы», а не просто подставить одно значение.

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