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", "еда", "Еда", "ЕдА" и загадочную "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 must be positive" }
        require(amountCents > 0) { "amountCents must be positive" }
        require(note.isNotBlank()) { "note must not be blank" }
    }
}

Здесь мы используем 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("Amount is missing")

    val amount = parseAmountCents(amountText)
        ?: return ParseExpenseResult.Error("Amount must be a positive integer")

    val categoryText = normalizeText(raw.category)
        ?: return ParseExpenseResult.Error("Category is missing")

    val category = parseCategory(categoryText)
        ?: return ParseExpenseResult.Error("Unknown category: $categoryText")

    val note = normalizeText(raw.note)
        ?: return ParseExpenseResult.Error("Note is missing")

    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("ERROR: ${parsed.message}")
        is ParseExpenseResult.Ok -> {
            val expense = createExpense(expenses, parsed.draft)
            expenses.add(expense)
            println("Added: $expense") // Added: 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("Amount must be..."). А вот попытка создать 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
Задача
Kotlin SELF, 41 уровень, 4 лекция
Недоступна
Фильтр ввода
Фильтр ввода
1
Задача
Kotlin SELF, 41 уровень, 4 лекция
Недоступна
Категория трат
Категория трат
1
Задача
Kotlin SELF, 41 уровень, 4 лекция
Недоступна
Профиль без null
Профиль без null
1
Задача
Kotlin SELF, 41 уровень, 4 лекция
Недоступна
Добавление книги
Добавление книги
1
Опрос
Null-safety, 41 уровень, 4 лекция
Недоступен
Null-safety
Null-safety углубленно
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ