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 потрібні тоді, коли змінюється саме форма входу або хочеться виразити ланцюжок делегування як окремі «зручні входи», а не просто підставити одне значення.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ