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, иначе вся идея строгой модели ломается.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ