JavaRush /Курси /Kotlin SELF /typealias і value class: читабельність і типобезпечність

typealias і value class: читабельність і типобезпечність

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

1. Вступ

Коли ви пишете невелику програму, цілком нормально, що всюди трапляються String, Int, Long. Але щойно зʼявляється хоча б один мініпроєкт (наприклад, наш консольний облік витрат), відкривається цікава реальність: один і той самий тип даних може означати зовсім різні сутності.

Наприклад, Int може бути і «ID витрати», і «кількістю днів», і «відсотком знижки». А String — і «назвою категорії», і «email», і «командою в CLI». Компілятору байдуже: він бачить просто Int і просто String. І от тут починається класика: «ой, я передав не те, але воно скомпілювалося».

Наша мета сьогодні — дати словам у коді нормальні імена. Іноді — просто заради читабельності. А іноді — так, щоб компілятор став нашим суворим другом і не пропускав нісенітниць.

2. typealias: гарне імʼя без нового типу

Коли ви читаєте сигнатуру функції на кшталт fun addExpense(id: Int, amount: Long, category: String), мозок робить паузу: «А Int — це що? Скільки? ID? Вік кота?». І це не ваша вина: так улаштовані базові типи — вони не зберігають сенсу.

typealias розвʼязує рівно задачу читабельності. Він дозволяє оголосити псевдонім для наявного типу: по суті, ви кажете «нехай String у цьому місці називатиметься CategoryName». Але важливо: це не новий тип, а лише інше імʼя.

Мініприклад typealias на нашому проєкті

Уявімо, що в нашому застосунку «Облік витрат» витрата має:

  • id (поки Int)
  • category (поки String)

Зробімо код трохи більш «людським»:

typealias ExpenseId = Int
typealias CategoryName = String

data class Expense(
    val id: ExpenseId,
    val title: String,
    val category: CategoryName,
    val amountCents: Long,
)

Тепер сигнатури функцій стають приємнішими:

typealias ExpenseId = Int

fun removeExpenseById(expenses: MutableList<Expense>, id: ExpenseId): Boolean {
    val index = expenses.indexOfFirst { it.id == id }
    if (index == -1) return false

    expenses.removeAt(index)
    return true
}

Виглядає охайніше: одразу зрозуміло, що параметр — це саме ідентифікатор витрати.

Головний підступ typealias

Критично важливий факт: typealias сумісний за присвоюванням із початковим типом і з іншими аліасами того самого початкового типу. Kotlin прямо підкреслює різницю: typealias — це просто альтернативне імʼя наявного типу, а не новий тип.

Тобто ось такий код… скомпілюється:

typealias UserId = String
typealias ProductId = String

fun printUser(id: UserId) {
    println("користувач=$id")
}

fun main() {
    val product: ProductId = "P-10"
    printUser(product) // компілюється, бо обидва — String
}

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

Коли typealias доречний

Після цього підступу легко захотіти сказати: «Тоді навіщо він узагалі потрібен?». Потрібен — і часто дуже.

typealias чудово працює, коли:

  • вам потрібно зробити сигнатури функцій читабельними (особливо в утилітах/сервісах);
  • «переплутати значення» або малоймовірно, або не критично;
  • ви не хочете ускладнювати код обгортками й .value.

Наприклад, для «назви категорії» (CategoryName) помилка «переплутали з іншим String» можлива, але часто її все одно доводиться відловлювати логікою (порожній рядок, пробіли, нормалізація регістру). Там типобезпечність не завжди окупається.

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

3. value class: окремий тип поверх одного значення

Тепер — до «важкої артилерії». Якщо typealias — це «перейменувати табличку на дверях», то value class — «збудувати окрему кімнату». На вигляд схоже: і там, і там ви хочете «позначити сенс». Але результат принципово інший.

value class створює справжній новий тип, який компілятор відрізняє від початкового типу й від інших value-класів поверх того самого початкового типу. Саме це дає нам захист від випадкових підстановок.

Kotlin описує такі класи як inline value classes: вони зберігають одне значення, і це значення «вбудовується» у використання (без окремого «товстого обʼєкта» в більшості випадків).

На Kotlin/JVM (а ми зараз саме на JVM) value class зазвичай пишуть з анотацією @JvmInline.

Приклад: ExpenseId як value class

Зробімо ідентифікатор витрати справжнім окремим типом:

@JvmInline
value class ExpenseId(val value: Int)

І тепер модель:

data class Expense(
    val id: ExpenseId,
    val title: String,
    val category: String,
    val amountCents: Long,
)

Що це дає на практиці

Тепер компілятор не дасть переплутати ExpenseId і «просто число»:

@JvmInline
value class ExpenseId(val value: Int)

fun removeExpenseById(expenses: MutableList<Expense>, id: ExpenseId): Boolean = true

fun main() {
    val id = ExpenseId(10)
    // removeExpenseById(mutableListOf(), 10) // не компілюється
    removeExpenseById(mutableListOf(), id)    // гаразд
}

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

4. Обмеження й правила value class

У value-класів є обмеження. Вони зʼявилися не тому, що Kotlin вередує, а тому що така конструкція має залишатися «легкою» й передбачуваною.

Рівно одне поле в primary constructor

Inline value class має містити одну-єдину властивість, ініціалізовану в primary constructor.

Тобто так — можна:

@JvmInline
value class Money(val cents: Long)

А так — не можна:

@JvmInline
value class Money(val cents: Long, val currency: String) // помилка: два поля

Якщо вам потрібно два поля — це вже кандидат на data class, а не на value class.

Можна мати init і перевіряти інваріанти

Дуже приємний плюс: value-клас може мати init, і ви можете одразу захистити сенс типу. А самі перевірки зручно робити через require(...), який ми вже використовували в конструкторах і init у звичайних класах.

Наприклад, «гроші не повинні бути відʼємними»:

@JvmInline
value class Money(val cents: Long) {
    init {
        require(cents >= 0) { "Money must be non-negative: $cents" }
    }
}

Це перетворює «домовленість у голові» на «правило в коді». І це чудово: мозок можна витрачати на задачі, а не на втримування сотні домовленостей.

Чому === для value class не має сенсу

Inline value classes можуть бути представлені як «чисте значення» або як «обгортка» в деяких випадках. Тому посилальна рівність (===) для них заборонена як беззмістовна.

Практичний висновок простий: порівнюйте value class як значення — через ==.

@JvmInline
value class ExpenseId(val value: Int)

fun main() {
    val a = ExpenseId(1)
    val b = ExpenseId(1)

    println(a == b) // true
}

5. Вбудовуємо value class у проєкт «Облік витрат»

Зараз зробимо дуже практичний крок: підсилимо модель так, щоб вона рідше допускала беззмістовні стани.

Робимо типи домену: ExpenseId і Money

@JvmInline
value class ExpenseId(val value: Int)

@JvmInline
value class Money(val cents: Long) {
    init {
        require(cents >= 0) { "Money must be non-negative: $cents" }
    }
}

Оновлюємо модель Expense

Зверніть увагу: гроші робимо типом Money, а категорію поки залишимо рядком (або typealias). Категорія — це радше «текстова мітка», а от гроші й ID особливо легко переплутати.

typealias CategoryName = String

data class Expense(
    val id: ExpenseId,
    val title: String,
    val category: CategoryName,
    val amount: Money,
)

Додаємо невеликий генератор ID

Поки без «складних сховищ» і без тем із майбутніх лекцій — просто акуратно створимо наступний ID на основі поточних витрат.

fun nextExpenseId(expenses: List<Expense>): ExpenseId {
    val max = expenses.maxOfOrNull { it.id.value } ?: 0
    return ExpenseId(max + 1)
}

Так, це неідеально для паралельності, баз даних і реального світу. Але для навчального консольного застосунку — чудово. І головне: видно, де саме ми переходимо до «сирого числа» (.value).

Функція додавання витрати

fun addExpense(
    expenses: MutableList<Expense>,
    title: String,
    category: CategoryName,
    amountCents: Long,
) {
    val expense = Expense(
        id = nextExpenseId(expenses),
        title = title,
        category = category,
        amount = Money(amountCents),
    )
    expenses.add(expense)
}

Зверніть увагу на приємний ефект: конструктор Money(amountCents) сам валідує вхід. Якщо ви спробуєте передати -10, програма впаде одразу й чесно, а не створить «витрату мінус 10 центів» (яка потім ламає звіти й викликає філософське питання: «мені доплатили за купівлю хліба?»).

Друк витрати

fun printExpense(e: Expense) {
    println("#${e.id.value}: ${e.title}")                     // #1: Coffee
    println("Category: ${e.category}")                         // Category: food
    println("Amount: ${e.amount.cents} cents")                 // Amount: 250 cents
}

Тут .value / .cents — плата за типобезпечність. Часто це цілком нормально: ви явно бачите межу між «нашим смисловим типом» і «сирим примітивом».

6. typealias vs value class: таблиця вибору

Коли інструменти зовні схожі, мозок новачка починає панікувати: «що вибирати, щоб не було соромно?». Знімімо тривогу простим порівнянням.

Критерій typealias value class
Створює новий тип? Ні, це псевдонім наявного типу. Так, це окремий тип поверх одного значення.
Захищає від плутанини між UserId і ProductId? Ні Так
Вимагає .value / .cents? Ні Так (зазвичай так, бо є обгортка)
Можна валідувати сенс у init? Ні (це не клас) Так, можна init { require(...) }
Обмеження «одне поле» Не застосовується Так, одне поле в primary constructor
Посилальна рівність === Можна (якщо початковий тип — посилальний), але майже ніколи не потрібно Беззмістовно й заборонено

І дуже практичне правило «на щодень»: якщо ви хочете просто читати код без розшифровки — беріть typealias. Якщо ви хочете, щоб компілятор бив вас по руках за плутанину, — беріть value class.

7. Нюанс API: не перетворюємо код на «музей обгорток»

Є спокуса загорнути у value class взагалі все: Title, CategoryName, Command, Email, CityName… І раптом ваш код стає схожим на музей обгорток, де не можна просто передати рядок без трьох ритуалів.

Психологічно ця спокуса зрозуміла: «раз типобезпечно — значить краще». Але на практиці типобезпечність має окуповувати свою «церемоніальність».

У нашому застосунку справді небезпечні:

  • ID (їх легко переплутати, а помилку складно помітити),
  • гроші/суми (помилка в одиницях або знак «мінус» часто ламає обчислення).

А от «категорія» найчастіше все одно потребує нормалізації (trim, lowercase) і допускає вільне введення користувача. Тому там часто достатньо typealias, а типобезпечність забезпечує не тип, а валідація рядка.

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

Помилка № 1: очікувати, що typealias захистить від плутанини значень.
typealias робить код читабельнішим, але не створює нового типу: усе лишається сумісним із початковим типом, і компілятор не зупинить вас, якщо ви передасте «не той String». Якщо потрібен саме захист — використовуйте value class.

Помилка № 2: намагатися зробити value class із двома (або нулем) полями.
Inline value class має містити рівно одне поле в primary constructor. Якщо вам потрібно більше даних — це вже не value class, а кандидат на data class.

Помилка № 3: забути, що value class — це новий тип, і дивуватися: «чому не компілюється?»
Після переходу з Int на ExpenseId ви більше не зможете викликати функції «старими числами». Це не баг, а сенс зміни: компілятор тепер вимагає явного створення ExpenseId(), щоб ви свідомо сказали: «це саме ID».

Помилка № 4: не валідувати сенс, хоча тип прямо просить про це.
Якщо ви вводите Money, але дозволяєте відʼємні значення, ви втрачаєте половину користі. init { require(...) } — чудовий спосіб вбудувати інваріант прямо в тип.

Помилка № 5: намагатися використовувати === із value class.
Посилальна рівність для inline value classes беззмістовна й заборонена, бо такі значення можуть бути представлені по-різному (як значення або як обгортка). Для порівняння використовуйте == — і зазвичай це рівно те, що вам потрібно за змістом.

Помилка № 6: обгортати все підряд і зробити API незручним.
Якщо ви загорнули у value class кожен рядок і кожне число, у вас зʼявиться купа «шумних» перетворень і .value. Типобезпечність має бути точковою: захищайте ті місця, де помилка справді ймовірна й дорого коштує (ID, гроші, ключі, токени), а не перетворюйте весь код на пакувальний цех.

1
Опитування
`data class` і `value class`, рівень 33, лекція 4
Недоступний
`data class` і `value class`
`data class` і `value class`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ