JavaRush /Курси /Kotlin SELF /Проєктуємо модель без nullable-полів

Проєктуємо модель без nullable-полів

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

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, інакше вся ідея строгої моделі ламається.

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