JavaRush /Курси /Kotlin SELF /Primary constructor як API класу: параметри, властивості ...

Primary constructor як API класу: параметри, властивості й значення за замовчуванням

Kotlin SELF
Рівень 31 , Лекція 0
Відкрита

1. Primary constructor: «вхідні двері» до класу

Коли ми починаємо писати класи, легко подумати: «Ну, конструктор — це просто спосіб передати значення всередину». Насправді ж конструктор — це публічний вхід у ваш тип, тобто частина API класу. Це як вхідні двері до офісу: їх можна зробити зручними й чітко позначеними, а можна поставити 12 однакових дверей без табличок — і потім дивуватися, чому співробітники заходять до серверної.

У Kotlin основний спосіб створювати обʼєкти — це primary constructor, який пишуть прямо в заголовку класу. Його вважають «головним маршрутом створення», а решту коду зазвичай вибудовують навколо нього. У документації Kotlin це сформульовано прямо: primary constructor оголошують у заголовку класу, а параметри можуть ставати властивостями через val/var.

Простий приклад «клас як функція створення обʼєкта»:

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

fun main() {
    val u = User(name = "Ann", age = 20)
    println(u.name) // Ann
    u.age += 1
    println(u.age)  // 21
}

Зверніть увагу на ще один «приємний бонус Kotlin»: під час створення обʼєкта не потрібен new — ви просто викликаєте клас як функцію.
Дрібниця, але вона робить код компактнішим: менше «ритуальних слів», більше змісту.

2. Параметр конструктора vs властивість: що стає станом

У цьому місці майже всі новачки наступають на ті самі граблі. Здається логічним: «Я написав class X(a: Int) — отже, в обʼєкта є a». Але ні: параметр конструктора без val/var — це лише вхідне значення, як параметр функції. Він не зобовʼязаний ставати частиною стану обʼєкта.

Kotlin уміє робити зручну річ: якщо параметр оголошено з val або var, він одночасно є і параметром конструктора, і властивістю обʼєкта. Це один із ключових елементів «kotlin-стилю»: менше шаблонного коду.

Порівняймо два варіанти.

Варіант A: параметр — це властивість

class Label(val text: String)

fun main() {
    val l = Label("Hi")
    println(l.text) // Hi
}

Варіант B: параметр — не властивість

class Label(text: String) {
    val text: String = text
}

fun main() {
    val l = Label("Hi")
    println(l.text) // Hi
}

Обидва варіанти працюють, але сенс у них різний. У варіанті B ви ніби кажете: «Під час створення мені дали text, але зберігатиму я text (своє)». У документації Kotlin окремо підкреслюють: параметри без val/var не зберігаються як властивості і доступні лише в тілі класу — доки ви не перенесете їх у властивість.

Щоб не тримати це в голові «на відчуттях», корисно зафіксувати так:

Запис у заголовку Це властивість обʼєкта? Можна звернутися ззовні (obj.x)? Типовий сенс
class A(x: Int)
ні ні вхідне значення «на момент створення»
class A(val x: Int)
так (лише для читання) так частина стану, змінювати не можна
class A(var x: Int)
так (змінна) так частина стану, можна змінювати

3. Проєктуємо Expense: тип, який приємно створювати

Зараз буде важлива практична думка: хороший конструктор — це той, на який приємно дивитися саме в місці створення обʼєкта. Бо справжній біль не в тому, щоб написати клас, а в тому, щоб за місяць відкрити код і зрозуміти, що означає Expense(1, "Taxi", 900, "transport") — це таксі за 9 USDT чи за 900 USDT, і що взагалі означає «1».

Уявімо, що в нашому консольному трекері витрат один запис — це витрата. У неї є ідентифікатор, назва, сума (у цілих одиницях, щоб не мучитися з Double) і категорія рядком (поки рядком: enum буде пізніше).

Почнімо з простого:

class Expense(
    val id: Int,
    val title: String,
    val amount: Int,
    val category: String
)

fun main() {
    val e = Expense(1, "Coffee", 350, "food")
    println(e.title)   // Coffee
    println(e.amount)  // 350
}

Уже непогано: це один обʼєкт, у ньому все разом, і ми більше не «склеюємо» дані за індексами в різних списках.

Але тепер чесно: рядок Expense(1, "Coffee", 350, "food") читається посередньо. За чотирьох параметрів це ще терпимо, але щойно зʼявиться пʼятий — почнеться «вгадайка».

Ось тут конструктор як API проявляється особливо яскраво: ми не лише обираємо типи, а й проєктуємо читабельність виклику.

4. Значення за замовчуванням: менше хаосу під час створення

У якийсь момент вам захочеться: «Нехай категорія буде необовʼязковою — якщо не вказали, вважаємо "other"». І тут є два шляхи. Перший — зробити кілька варіантів створення (спойлер: secondary constructors будуть пізніше). Другий — використати значення за замовчуванням.

Primary constructor підтримує значення за замовчуванням так само, як і функції. Це описано в документації: можна задавати значення за замовчуванням прямо в параметрах конструктора, і якщо аргумент не передали — буде використано значення за замовчуванням.

Зробімо так:

class Expense(
    val id: Int,
    val title: String,
    val amount: Int,
    val category: String = "other"
)

fun main() {
    val a = Expense(1, "Coffee", 350)
    val b = Expense(2, "Taxi", 900, "transport")

    println(a.category) // other
    println(b.category) // transport
}

Важливий ефект: тепер у нашого класу менше «режимів створення», і це добре. Мозок читача не має тримати в памʼяті, що «якщо категорія не прийшла, то треба створити обʼєкт інакше». Клас сам уміє коректно стартувати.

На практиці значення за замовчуванням найчастіше дають два виграші. По‑перше, вони зменшують кількість обовʼязкових даних: обовʼязковим лишається тільки те, без чого обʼєкт не має сенсу. По‑друге, знижують кількість помилок під час створення: менше місць, де треба «щось не забути».

5. Іменовані аргументи: читабельність на боці виклику

Тепер зробимо те, що перетворює «чотири параметри — терпимо» на «шість параметрів — усе ще читабельно»: іменовані аргументи.

Ключова ідея проста: імена параметрів конструктора — це не просто «як ви їх назвали у себе». Це частина зовнішнього інтерфейсу: люди бачитимуть їх у викликах, особливо якщо використовують іменовані аргументи. Тому невдале імʼя параметра в конструкторі — це як поганий підпис на кнопці в застосунку.

Подивімося, наскільки зрозуміліше:

class Expense(
    val id: Int,
    val title: String,
    val amount: Int,
    val category: String = "other"
)

fun main() {
    val e = Expense(
        id = 10,
        title = "Lunch",
        amount = 550,
        category = "food"
    )

    println(e.title)    // Lunch
    println(e.amount)   // 550
}

Так, це трохи довше. Але «довжина» тут працює як страховка від помилок — особливо коли параметри одного типу повторюються. Два рядки String підряд — класичне джерело ситуацій «переплутав місцями й не помітив».

Щоб побачити, як легко помилитися без іменованих аргументів, уявіть клас:

class Client(val firstName: String, val lastName: String)

fun main() {
    val c = Client("Ivan", "Petrov")
    println("${c.firstName} ${c.lastName}") // Ivan Petrov
}

Якщо ви випадково поміняєте місцями аргументи — компілятор не врятує: типи однакові. А з іменованими аргументами шанс переплутати значно менший.

6. val чи var: «можна змінювати» — це частина API

Зараз буде момент, коли програміст дорослішає й починає говорити серйозні речі на кшталт «мутабельність треба заслужити». Але без фанатизму: var — не зло, просто вона відчиняє двері до змін, а зміни потрібно контролювати.

Різниця проста. val у конструкторі означає: обʼєкт створено — і цю властивість не можна перепризначити (хоча якщо всередині зберігається мутабельний обʼєкт, це вже окрема історія, але зараз ми туди не йдемо). var означає: значення властивості можна змінювати після створення.

Наприклад, у витрати зазвичай немає сенсу змінювати id після створення. Часто немає сенсу змінювати й amount (запис витрати — це факт). Отже, у моделі логічно тримати це як val. А якщо ми хочемо дозволити редагування запису (наприклад, виправити друкарську помилку в назві), можна зробити title як var. Важливо, щоб це рішення було усвідомленим, а не «поставлю всюди var, аби не думати».

Приклад «усвідомленої мутабельності»:

class Expense(
    val id: Int,
    var title: String,
    val amount: Int,
    val category: String = "other"
)

fun main() {
    val e = Expense(id = 1, title = "Cofee", amount = 350) // друкарська помилка :)
    e.title = "Coffee"
    println(e.title) // Coffee
}

Тут var title читається як обіцянка класу: «Назву можна виправляти». А val amount — як обіцянка: «Суму не чіпаємо, це факт витрати». Саме це й означає «конструктор — частина API»: через val/var ви повідомляєте правила використання вашого типу.

7. Коли параметр не повинен бути властивістю

Іноді в конструктор хочеться передати щось тимчасове. Наприклад, «сирий текст», який потрібен лише для того, щоб обчислити нормалізоване значення. Або, скажімо, ви приймаєте рядок " Food " і хочете зберігати "food".

Сьогодні ми поки не робимо init (це буде в наступній лекції), але важливо зрозуміти ідею: інколи параметр конструктора — це просто матеріал, з якого ви робите стан. А сам матеріал зберігати не потрібно.

Ось мікроприклад на зрозумілій темі:

class Category(raw: String) {
    val name: String = raw.trim().lowercase()
}

fun main() {
    val c = Category("  FoOd  ")
    println(c.name) // food
}

Чому raw не val raw? Бо нам не потрібно зберігати «брудне введення». Ми хочемо зберігати вже підготовлене значення, придатне для логіки.

Цей підхід добре поєднується з ідеєю з документації: параметр без val/var не є властивістю і сам по собі не живе «всередині обʼєкта».

8. Практика і корисні нюанси

Коли потрібен constructor, а коли ні

Іноді ви побачите два варіанти запису:

class Person constructor(name: String)

і

class Person(name: String)

Різниця не в змісті, а в синтаксисі. Kotlin дозволяє опустити ключове слово constructor, якщо в конструктора немає анотацій і модифікаторів видимості. Це нормальна практика: більшість класів пишуть без constructor.

У нашому курсі в 99 % випадків ми писатимемо компактно:

class Expense(val id: Int, val title: String, val amount: Int)

А слово constructor знадобиться пізніше, коли почнуться більш «дорослі» випадки (наприклад, модифікатори видимості конструктора). Але це вже не тема цієї лекції.

Схема: primary constructor як контракт

Корисно подумки уявляти, що створення обʼєкта — це міні‑потік даних: зовнішній світ передає значення → клас приймає їх за контрактом → обʼєкт зʼявляється в програмі.

Ось проста схема:

flowchart LR
    A["Код ззовні
Expense(...)"] --> B["Primary constructor
(параметри)"] B --> C["Властивості обʼєкта
(val/var)"] C --> D["Обʼєкт готовий до використання"]

У цій картині primary constructor — це місце, де ви визначаєте контракт: які дані потрібні, які необовʼязкові (значення за замовчуванням), які стануть частиною стану (val/var), а які — просто вхідними.

Міні‑крок: список витрат на обʼєктах

Щоб не залишати все на рівні «теорії про API», зробімо мікрокрок у нашому застосунку: у main() створимо кілька витрат і виведемо їх. Жодних складних команд — просто відчуємо, що модель стала читабельною.

class Expense(
    val id: Int,
    val title: String,
    val amount: Int,
    val category: String = "other"
)

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

    expenses.add(Expense(id = 1, title = "Coffee", amount = 350, category = "food"))
    expenses.add(Expense(id = 2, title = "Taxi", amount = 900, category = "transport"))
    expenses.add(Expense(id = 3, title = "Notebook", amount = 120)) // category за замовчуванням

    for (e in expenses) {
        println("#${e.id}: ${e.title} — ${e.amount} (${e.category})")
        // #1: Coffee — 350 (food)
        // #2: Taxi — 900 (transport)
        // #3: Notebook — 120 (other)
    }
}

Тут одразу видно, що primary constructor справді «працює як API»: він визначає, які поля є у витрати, як вони називаються і які значення можна не передавати. А іменовані аргументи перетворюють створення на майже самодокументований код.

9. Типові помилки

Помилка № 1: плутати параметр конструктора з властивістю й забувати val/var.
Це виглядає так: ви пишете class Expense(id: Int, title: String), потім намагаєтеся звернутися e.title, і компілятор каже: «нема такого». Насправді все чесно: title був лише вхідним параметром, а не частиною стану. Якщо значення має жити всередині обʼєкта — оголошуйте val title: String прямо в конструкторі або присвойте його властивості в тілі класу.

Помилка № 2: робити всі властивості var, бо «раптом знадобиться».
Це одна з найдорожчих звичок. Коли ви ставите var, ви ніби лишаєте двері відчиненими: «це можна змінювати будь-коли». Потім виявляється, що десь значення змінили, логіка «поїхала», і ви шукаєте винуватця як детектив із лупою. Починайте з val і переводьте у var лише те, що справді має змінюватися за змістом.

Помилка № 3: надто багато параметрів у primary constructor і «магічні» позиції аргументів.
Якщо конструктор вимагає 6–8 параметрів, і ви викликаєте його позиційно (наприклад, A(1, "x", "y", 10, 20, true)), код стає схожим на заклинання, яке не можна чіпати: інакше викличеться демон. На рівні цієї лекції найпростіший порятунок — активніше використовувати іменовані аргументи та значення за замовчуванням, щоб обовʼязковими залишалися тільки справді потрібні речі.

Помилка № 4: погані імена параметрів у конструкторі.
Конструктор — це частина публічного інтерфейсу. Якщо ви називаєте параметри a, b, x1, s2, то ви буквально змушуєте читача вгадувати, що передавати. Особливо боляче стає, коли ви використовуєте іменовані аргументи: Expense(a = 1, b = "Coffee", x1 = 350). Імена параметрів мають відображати сенс, бо вони житимуть у коді створення обʼєктів.

Помилка № 5: не використовувати значення за замовчуванням там, де вони природні, і через це плодити складність.
Коли параметр часто однаковий (наприклад, категорія "other" або прапорець isActive = true), зручніше дати йому значення за замовчуванням у primary constructor, ніж змушувати кожен виклик повторювати одне й те саме. Kotlin підтримує це напряму: значення за замовчуванням у primary constructor працюють так само, як значення за замовчуванням у функціях.

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