1. Вступ
Коли ви тільки починаєте, дуже спокусливо зробити модель «на всі випадки життя»: name: String?, category: String?, amount: Int? — а там розберемося. У цей момент ви ніби купуєте собі свободу… але в кредит і під величезні відсотки. Бо кожен наступний шматочок коду буде змушений «платити» перевірками на null: під час виведення, сортування, підрахунків, форматування повідомлень і навіть у найпростіших умовах. Kotlin, звісно, чесно попереджає: nullable-типи потребують явної обробки.
Як null «розповзається» програмою
Уявіть, що в нашому консольному застосунку (ми продовжуватимемо практичний приклад — умовний трекер витрат) ви зберігаєте витрати так:
data class BadExpense(
val amountCents: Int?,
val category: String?,
val note: String?
)
Спершу здається: «зручно, можна створити обʼєкт навіть із некоректного введення». Та вже за кілька функцій починається:
- «а як підсумовувати Int??»
- «а що робити, якщо category == null?»
- «а чи можна друкувати note?»
- «а сортування за category узагалі можливе?»
І ви починаєте перетворювати код на ліс із ?:, ?.let {}, if (x == null) return ....
Головна думка лекції
Nullable зовні, строгі типи всередині.
Тобто null допустимий на межі (введення, зовнішні дані), але після нормалізації/валідації ми маємо отримати внутрішні обʼєкти без nullable-полів.
2. Де null допустимий, а де ні
Щоб проєктувати без nullable-полів, спершу потрібно домовитися, де null узагалі допустимий. Зробімо це максимально прагматично: не «за філософією», а «щоб код не хворів».
У реальних застосунках межа null майже завжди проходить там, де дані приходять ззовні: користувач увів рядок, прийшов HTTP-запит, прочитали файл, отримали дані з бази. Сьогодні ми не чіпаємо файли/мережу/БД, але принцип той самий: зовнішнє введення непередбачуване, а внутрішня модель має бути передбачуваною.
Ось проста схема, яку корисно тримати в голові:
flowchart LR
A["Сирий ввід (String? / String)"] --> B["Нормалізація (trim, lowercase, split)"]
B --> C["Парсинг + валідація (toIntOrNull, правила)"]
C --> D["Внутрішня модель (без null)"]
Табличка «що можна, що не можна»
| Шар програми | Які типи там допустимі | Що вважаємо нормою |
|---|---|---|
| Введення (CLI) | String, інколи String? | Введення може бути порожнім або некоректним |
| Нормалізація | String? на короткій ділянці | «Привели до форми», але ще не впевнені |
| Парсинг/валідація | Result<T>, sealed-результат, інколи T? | Помилки мають бути явними |
| Внутрішня модель (domain) | Тільки не-null (String, Int, enum, …) | Якщо обʼєкт створено — він коректний |
3. Внутрішня модель: обʼєкт завжди валідний
Зараз ми спроєктуємо строгу модель витрати для нашого консольного трекера. Сенс простий: якщо в списку лежить Expense, то в нього є сума, категорія й нотатка (або принаймні порожня нотатка — але це рішення має бути усвідомленим).
Ми будемо використовувати data class, тому що це зручна форма моделі даних: Kotlin автоматично генерує корисні методи на кшталт toString(), equals(), copy() і деструктурування.
Категорія як enum, а не String
Якщо категорія — рядок, ви дуже швидко отримаєте "Food", "food", "FOOD", "food", "Food", "FoOd" і загадкову "Foood" (це не нова категорія, а друкарська помилка людини о 3-й ночі).
Тому в строгій моделі ми зробимо категорії як перелічення:
enum class Category {
FOOD, TRANSPORT, FUN, OTHER
}
Строгий Expense без nullable-полів
Зробимо модель витрати. Суму зберігатимемо в центах (ціле число), тому що Double для грошей — це окремий жанр горору.
data class Expense(
val id: Int,
val amountCents: Int,
val category: Category,
val note: String
) {
init {
require(id > 0) { "id має бути додатним" }
require(amountCents > 0) { "amountCents має бути додатним" }
require(note.isNotBlank()) { "note не має бути порожнім" }
}
}
Тут ми використовуємо require(...) як fail-fast: якщо хтось усередині програми спробує створити «неправильний» Expense, краще впасти одразу зі зрозумілим повідомленням, ніж тягнути помилку далі.
Важливо: require — це не «валідація користувацького введення» (бо користувацьке введення ми хочемо обробляти мʼяко). Це захист інваріантів моделі: «такий обʼєкт не має існувати всередині програми».
4. Сире введення: окремий тип із null
Тепер зробимо те, заради чого й зібралися: введемо окремий тип для сирого введення. Воно може бути некоректним, порожнім, не розпарсеним або частковим. І це нормально, бо це ще не модель, а заготовка.
Хитрість (дуже корисна в реальних проєктах): не намагатися одразу створити Expense із введення. Натомість варто створити Raw… або …Input, а потім окремою функцією перетворити це на строгу модель.
RawExpenseInput: дані «як є»
data class RawExpenseInput(
val amount: String?,
val category: String?,
val note: String?
)
Виглядає надто просто? Чудово. Наша мета — не «розумний клас», а чесна коробка з тим, що прийшло ззовні.
Нормалізація: приводимо рядки до притомного вигляду
Нормалізація — це ніби «вмити дані й причесати», але ще не «вірити їм». Наприклад, якщо рядок складається лише з пробілів, ми вважаємо це відсутністю значення.
fun normalizeText(raw: String?): String? {
val s = raw?.trim()
return s?.takeIf { it.isNotEmpty() }
}
Зверніть увагу: normalizeText повертає String?. Це нормально: вона все ще працює на межі. Але далі ми перетворюватимемо цей String? на явний результат.
5. Парсинг: sealed-результат замість null
Тепер ключовий момент: як повернути результат парсингу/валідації так, щоб:
- усередині моделі не було null,
- код, що викликає, розумів, що пішло не так,
- ми не «глушили» помилки println-ами десь глибоко всередині.
Для навчального проєкту дуже зручно зробити sealed class результату: або Ok, або Error (можна назвати Invalid, Missing тощо).
Результат парсингу: Ok/Error
sealed class ParseExpenseResult {
data class Ok(val draft: ExpenseDraft) : ParseExpenseResult()
data class Error(val message: String) : ParseExpenseResult()
}
А що таке ExpenseDraft? Це «внутрішньо валідна заготовка витрати», але без id (id ми зазвичай видаємо під час додавання до списку/репозиторію).
data class ExpenseDraft(
val amountCents: Int,
val category: Category,
val note: String
)
ExpenseDraft уже строгий: без nullable-полів. Це дуже зручно — далі логіка працює лише з не-null даними.
Парсинг суми: з рядка в Int, у центах
Суму для простоти вводитимемо у франках/доларах як ціле число «в центах», наприклад 199 = 199 cents. (Так, це не надто дружньо до користувача, зате для навчання — чудово: ми уникаємо дійсних чисел.)
fun parseAmountCents(text: String): Int? {
val x = text.toIntOrNull()
return x?.takeIf { it > 0 }
}
Тут Int? — допустимо, бо це локальна функція межі: «не вдалося розпарсити» або «не пройшло правило».
Парсинг категорії: з рядка в Category
fun parseCategory(text: String): Category? {
return when (text.trim().lowercase()) {
"food" -> Category.FOOD
"transport" -> Category.TRANSPORT
"fun" -> Category.FUN
"other" -> Category.OTHER
else -> null
}
}
null тут означає «не розпізнали категорію». Це нормальний контракт на межі.
Збираємо ExpenseDraft із RawExpenseInput
Тепер обʼєднуємо все. Це і є «шлюз», який не пускає null усередину.
fun parseExpenseDraft(raw: RawExpenseInput): ParseExpenseResult {
val amountText = normalizeText(raw.amount)
?: return ParseExpenseResult.Error("Сума відсутня")
val amount = parseAmountCents(amountText)
?: return ParseExpenseResult.Error("Сума має бути додатним цілим числом")
val categoryText = normalizeText(raw.category)
?: return ParseExpenseResult.Error("Категорія відсутня")
val category = parseCategory(categoryText)
?: return ParseExpenseResult.Error("Невідома категорія: $categoryText")
val note = normalizeText(raw.note)
?: return ParseExpenseResult.Error("Нотатка відсутня")
return ParseExpenseResult.Ok(
ExpenseDraft(amountCents = amount, category = category, note = note)
)
}
Зверніть увагу на стиль: багато ранніх return. Це робить код «пласким»: немає гігантських вкладених if, і кожна помилка повертається з конкретною причиною. Kotlin-підхід «nullable-значення → перевірка → ранній вихід» — цілком нормальна практика.
6. CLI-сценарій: додаємо витрату без null
Зараз поєднаємо все в мінісценарій. У нас є список витрат, команда add, і користувач вводить дані. Ми спеціально зробимо приклад компактним, щоб ви могли зібрати все в голові.
Проста генерація id
fun nextId(expenses: List<Expense>): Int {
val maxId = expenses.maxOfOrNull { it.id } ?: 0
return maxId + 1
}
maxOfOrNull повертає nullable, бо список може бути порожнім — це нормальна «nullable-межа колекції». Ми акуратно перетворюємо це на 0 через Elvis ?:, і далі знову живемо з Int, а не з Int?.
Перетворюємо ExpenseDraft на Expense
Тут Expense уже може застосувати свої require(...) в init. Але в нормальному сценарії до цього не дійде: ми вже все перевірили під час парсингу.
fun createExpense(expenses: List<Expense>, draft: ExpenseDraft): Expense {
return Expense(
id = nextId(expenses),
amountCents = draft.amountCents,
category = draft.category,
note = draft.note
)
}
Розбір команди add із рядка
Припустімо, формат команди такий:
add <amountCents> <category> <note...>
Наприклад:
add 199 food coffee
add 500 transport taxi to airport
Зробимо простий парсер (без майбутніх тем на кшталт Regex-валідації з дня 27). Нехай note — це все, що після перших двох токенів.
fun rawInputFromAddCommand(line: String): RawExpenseInput {
val parts = line.trim().split(" ")
val amount = parts.getOrNull(1)
val category = parts.getOrNull(2)
val note = parts.drop(3).joinToString(" ").takeIf { it.isNotBlank() }
return RawExpenseInput(amount = amount, category = category, note = note)
}
getOrNull — дуже чесний спосіб сказати: «елемент може бути відсутнім». Знову ж: nullable живе на межі, а далі ми будемо його «дотискати» в ParseExpenseResult.
Команда add: повний сценарій
fun handleAdd(expenses: MutableList<Expense>, line: String) {
val raw = rawInputFromAddCommand(line)
when (val parsed = parseExpenseDraft(raw)) {
is ParseExpenseResult.Error -> println("ПОМИЛКА: ${parsed.message}")
is ParseExpenseResult.Ok -> {
val expense = createExpense(expenses, parsed.draft)
expenses.add(expense)
println("Додано: $expense") // Додано: Expense(id=1, amountCents=199, category=FOOD, note=coffee)
}
}
}
Зверніть увагу: всередині handleAdd узагалі немає String?, Int?, і тим більше немає Expense?. Ми працюємо або з Error(message), або з Ok(draft) — і це дуже розвантажує мозок.
Порожній рядок замість null: чому сторожове значення небезпечне
Іноді хочеться сказати: «А давайте замість note: String? зберігатимемо note: String, але якщо користувач нічого не ввів — покладемо порожній рядок». Формально це прибирає nullable. Але зʼявляється нова проблема: порожній рядок починає означати «немає нотатки», і про це доводиться памʼятати всюди.
Сторожове (сентинел) значення (типу "", 0, "UNKNOWN") інколи доречне, але лише якщо ви готові зафіксувати його як контракт і обробляти в одному-двох місцях, а не розмазувати по проєкту.
У нашому прикладі ми вибрали інший контракт: note обовʼязковий і не може бути порожнім, це перевіряється на межі, а всередині моделі захищається require(note.isNotBlank()).
Якщо вам справді потрібно, щоб «нотатка була необовʼязковою», краще зробити це не сторожовим значенням, а окремим варіантом результату або окремим полем на рівні сирого введення. Наприклад, RawExpenseInput.note може бути null, але під час складання ExpenseDraft ви можете ухвалити рішення: або це помилка (як у нас), або підставити осмислений дефолт.
7. Типові помилки
Помилка № 1: робити nullable-поля в доменній моделі «про всяк випадок».
Це майже завжди призводить до того, що null починає брати участь у кожній операції: підрахунках, виведенні, фільтрації, сортуванні. У підсумку ви пишете не «облік витрат», а «облік того, де ще може бути null». Набагато вигідніше визнати, що некоректними бувають вхідні дані, а не внутрішня модель: вводимо Raw…, валідуємо, і лише потім створюємо строгий обʼєкт.
Помилка № 2: повертати з парсингу Expense? і друкувати причину помилки через println усередині.
Так код, що викликає, отримує «ну, або є обʼєкт, або немає», але не розуміє, чому його немає. А причина помилки відлітає в консоль десь глибоко, змішуючись з іншими повідомленнями. sealed class результату (Ok/Error) або Result<T> розвʼязують це чистіше: причина стає частиною значення, а не побічним ефектом.
Помилка № 3: змішувати «помилку введення» і «внутрішню поломку» в одному стилі.
Користувач може ввести "abc" замість числа — це очікувана ситуація, і її варто повернути як Error("Сума має бути..."). А от спроба створити Expense(amountCents = -10) із уже перевірених даних — це радше баг програми, і там доречний require(...) у моделі (fail-fast).
Помилка № 4: «ми ж уже перевірили», а потім усе одно використовувати !!.
!! зазвичай зʼявляється, коли ви не домовилися, де саме проходить межа null. У нашому підході межа проходить у парсингу: поки ми в Raw… і нормалізації — nullable допустимий, але після ParseExpenseResult.Ok усередині вже не повинно бути причин для !!. Якщо рука тягнеться до !!, це хороший сигнал: або ви пропустили валідацію, або змішали шари.
Помилка № 5: робити в Ok-гілці sealed-результату nullable-поля («Ok, але value може бути null»).
Це хитрий спосіб повернути null «у масці». У підсумку у вас зʼявляється Ok(value: T?), і ви знову тягнете невизначеність усередину. Якщо результат — Ok, він має містити строге значення без null, інакше вся ідея строгої моделі ламається.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ