1. Погружаемся в null
Когда вы видите String?, мозг новичка часто читает это как: «там может быть null, значит нужно поставить ?. и ехать дальше». Но для дизайна программы полезнее другое чтение: String? — это обещание автора функции/поля, что отсутствие значения является нормальным, ожидаемым исходом. И вот тут начинается взрослая жизнь: если null — нормальный исход, его смысл должен быть понятен так же, как смысл true/false или 0/1.
Давайте закрепим мысль на простом примере. В Kotlin в учебных примерах часто показывают парсер: если строка не является числом, возвращаем null. Это очень честный контракт: «не смог распарсить — вот сигнал, без исключений и мистики».
fun parsePort(raw: String): Int? {
val trimmed = raw.trim()
return trimmed.toIntOrNull()
}
fun main() {
println(parsePort("8080")) // 8080
println(parsePort("oops")) // null
}
Здесь null означает конкретную вещь: «строка не содержит корректное целое число». Это не ошибка системы, не баг, не “что-то пошло не так” — это обычная ситуация на границе ввода.
Теперь представьте, что вы сделали так:
fun loadConfigText(): String? {
// ... что-то читаем
return null
}
И всё. А что означает null? Конфиг не найден? Нет прав? Файл пустой? Вы забыли дописать функцию? Это уже плохой контракт: он заставляет вызывающего угадывать.
Ключевой критерий: если вы ставите ?, вы обязаны ответить на вопрос “почему значения может не быть?” так, чтобы это не выглядело как “потому что мне лень”.
2. Где null обычно оправдан: границы программы
Обычно null — отличный инструмент на границах системы. Там, где вы встречаетесь с непредсказуемым миром: пользовательским вводом, аргументами командной строки, данными из сети, чтением из файла, поиском по ключу (когда элемент реально может отсутствовать). На границе мир имеет право принести вам пустоту, странную строку и «а я вообще ничего не ввёл». И вот там null уместен.
В Kotlin даже базовые операции коллекций намекают на это подходом: например, чтение по ключу из Map возвращает V?, потому что ключа может не существовать, и это нормальный сценарий. В таком месте null читается как «не найдено», а не как «сломалось».
Давайте пример ближе к нашему практическому консольному приложению (условно назовём его ExpenseTracker: маленький учёт расходов в CLI). Допустим, у нас есть список расходов, и мы ищем расход по id. Вполне нормально вернуть Expense?: расход мог быть удалён, id мог быть неправильным, или пользователь мог ошибиться.
data class Expense(
val id: Int,
val title: String,
val amount: Int
)
fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
return expenses.find { it.id == id }
}
fun main() {
val expenses = listOf(
Expense(id = 1, title = "Coffee", amount = 300),
Expense(id = 2, title = "Book", amount = 1200),
)
val e = findExpenseById(expenses, 2)
println(e?.title) // Book
}
Обратите внимание: контракт читается идеально. Expense? здесь означает «может не найтись». И это не “ошибка”: это часть предметной области.
3. Где null начинает мешать: внутри модели и бизнес-логики
Внутри программы (после того как данные уже распознаны и проверены) null часто превращается в “налёт хаоса”. Причина простая: null начинает протекать через слои кода. Одно nullable-поле тянет за собой второе, потом третье, затем появляются ?. в форматировании, ?: в подсчётах, if (x != null) в трёх разных местах, а в конце — философский вопрос «а что вообще считается корректным объектом?».
Представим плохую версию нашей модели расхода:
data class ExpenseDraft(
val title: String?,
val amount: Int?
)
fun main() {
val draft = ExpenseDraft(title = null, amount = null)
println("title=${draft.title}, amount=${draft.amount}") // title=null, amount=null
}
Снаружи кажется удобно: «можно создать хоть что-то». Но дальше вы захотите:
- вывести расход пользователю,
- посчитать сумму,
- отсортировать,
- сгруппировать.
И везде вы будете думать: “а тут может быть null?”. Это как носить зонт дома на кухне “на всякий случай”: формально можно, но готовить становится неудобно.
Важная мысль сегодняшней лекции: nullable-поля внутри “нормальной” модели часто означают, что вы смешали разные стадии данных. Есть “сырой ввод” и есть “валидная сущность”. Если вы смешали их в одном типе, null быстро расползётся.
4. Два смысла null: «нет данных» и «ошибка»
Эта часть особенно важна, потому что именно тут начинаются самые противные баги — не те, которые падают сразу, а те, которые тихо портят логику.
null обычно используют в двух разных ситуациях:
| Ситуация | Что происходит по смыслу | Нормально ли вернуть null? | Пример |
|---|---|---|---|
| Отсутствие значения | Значение может не существовать и это ожидаемо | Да | “не найдено по id”, “пустой ввод”, “не распарсилось число” |
| Ошибка/невозможность | Мы хотели значение, но не получили из-за проблемы | Опасно | “не удалось прочитать файл”, “ошибка сети”, “сломалась логика” |
Проблема в том, что обе ситуации технически выглядят одинаково: “вернули null”. Но по смыслу они разные как “в магазине нет хлеба” и “магазин сгорел”.
Давайте специально сделаем плохой пример и посмотрим, почему он плохой:
fun loadUserName(userId: Int): String? {
// Представим, что тут могла быть ошибка базы данных,
// но мы просто вернули null
return null
}
Вызывающий код теперь не отличит:
- пользователя с таким id реально нет (нормально),
- пользователь есть, но база упала (уже не нормально),
- разработчик забыл реализовать функцию (тоже не нормально).
И дальше, как правило, кто-то пишет:
val name = loadUserName(10) ?: "UNKNOWN"
println("Hello, $name") // Hello, UNKNOWN
И вот оно — “тихий баг”: программа продолжила жить, но смысл данных испорчен. Это опаснее, чем падение, потому что падение хотя бы честно кричит: «мне плохо». А "UNKNOWN" может улететь в отчёт, в экспорт, в итоговые суммы и дальше по цепочке.
Сегодняшняя цель — научиться видеть такие места и не использовать null как универсальную “затычку для всего”.
5. Практика: как держать null под контролем
Критерий: “Могу ли я объяснить null одной фразой?”
Чтобы не превращать дизайн в философию, полезен очень прикладной тест.
Если функция/поле имеет тип T?, попробуйте дописать к нему комментарий в одну строку без смущения. Не абзац, не “ну вообще там…”, а одну фразу.
Например, хорошие варианты:
- findExpenseById(...) : Expense? — “возвращает расход или null, если не найден”.
- parseAmount(...) : Int? — “возвращает сумму или null, если строка не является целым числом”.
- normalizeTitle(...) : String? — “возвращает строку без пробелов или null, если после trim строка пустая”.
А вот плохие варианты, которые должны насторожить:
- “возвращает null, если что-то пошло не так”
- “возвращает null в случае ошибки”
- “может вернуть null, я не уверен”
Если вы ловите себя на таких формулировках — почти наверняка вы пытаетесь спрятать в null то, что по смыслу является ошибкой или отдельным состоянием.
ExpenseTracker: делаем контракты читаемыми
Сейчас мы не будем добавлять “продвинутые” конструкции и новые типы результатов (это отдельная тема следующих лекций дня). Вместо этого потренируемся правильно выбирать T или T? и делать контракт понятным.
Нормализация пользовательского ввода: String? уместен
Пользователь может ввести пустую строку, строку из пробелов или вообще «нажать Enter и ничего не сказать». Это классическая граница. Тут null честен.
fun normalizeNonEmptyText(raw: String): String? {
val s = raw.trim()
return if (s.isEmpty()) null else s
}
fun main() {
println(normalizeNonEmptyText(" hello ")) // hello
println(normalizeNonEmptyText(" ")) // null
}
null здесь означает “после нормализации текста нет”. Это отсутствие данных, а не ошибка.
Парсинг числа: Int? уместен
Kotlin прямо подталкивает к этому стилю: toIntOrNull() возвращает nullable, потому что распарсить можно не всегда. Это пример “ожидаемого провала”, и он хорош.
fun parseAmount(raw: String): Int? {
val s = raw.trim()
return s.toIntOrNull()
}
fun main() {
println(parseAmount("1200")) // 1200
println(parseAmount("12o0")) // null
}
Поиск по коллекции: Expense? уместен
Это “не найдено”. Нормальная история.
fun removeExpenseById(expenses: MutableList<Expense>, id: Int): Boolean {
val found = expenses.find { it.id == id } ?: return false
expenses.remove(found)
return true
}
fun main() {
val expenses = mutableListOf(
Expense(1, "Coffee", 300),
Expense(2, "Book", 1200),
)
println(removeExpenseById(expenses, 2)) // true
println(removeExpenseById(expenses, 99)) // false
}
Здесь я специально сделал так, чтобы наружу не утекал Expense?. Функция “удалить” сама внутри использует nullable-поиск, но наружу отдаёт простой и понятный результат: Boolean (“удалили / не удалили”). Это тоже часть культуры: nullable держим ближе к месту, где он появился, а наружу возвращаем более удобный контракт, если можем.
Антипаттерн: nullable везде “чтобы компилятор меньше ругался”
Очень соблазнительно: компилятор ругается, что вы обращаетесь к nullable — и вы такие: “ладно, сделаю всё nullable, и пусть мир горит”. На короткой дистанции кажется, что стало легче. На длинной — это превращает код в квест “найди место, где забыли ?.”.
Чтобы почувствовать разницу, сравните два подхода форматирования:
fun formatExpenseBad(title: String?, amount: Int?): String {
val safeTitle = title ?: "UNKNOWN"
val safeAmount = amount ?: 0
return "$safeTitle: $safeAmount"
}
fun main() {
println(formatExpenseBad(null, null)) // UNKNOWN: 0
}
С одной стороны, программа не падает. С другой — данные уже подменены. 0 — это не “нет суммы”, это “сумма ноль”, и отчёт теперь может стать ложным.
Если в вашем приложении 0 — это допустимый расход (например, промокод, скидка, возврат), вы больше не отличите “пусто” от “ноль”. И это как раз пример, почему null — это контракт смысла, а не “просто отсутствие значения”.
Сентинел-значения вместо null: чем они опасны
Иногда разработчики говорят: “Я не люблю null, давайте вместо него использовать -1”. Такое значение называют сентинелом: специальное число/строка, которое означает “ничего нет”.
Это может работать, но тут есть риск: вы добавляете в код скрытую договорённость. Если вы забыли проверить -1 в одном месте — логика ломается, но компилятор вам не поможет, потому что тип всё ещё Int.
Давайте маленький пример, где ошибка выглядит “прилично”, но смысл уже неправильный:
fun parseAmountOrMinusOne(raw: String): Int {
return raw.trim().toIntOrNull() ?: -1
}
fun main() {
val amount = parseAmountOrMinusOne("oops")
println(amount + 100) // 99 (и это выглядит как будто всё нормально)
}
Вот почему Kotlin продвигает nullable: Int? заставляет вас обработать случай “нет значения” явно, а -1 позволяет случайно забыть.
Это не значит, что сентинелы всегда зло. Это значит, что если вы их используете, вы обязаны сделать это контрактом, а не “тихой хитростью”.
Один «шлюз» обработки вместо размазывания null
В хорошем коде часто видно стиль: null живёт в одном месте, рядом с вводом или поиском, и дальше либо превращается в конкретное действие, либо отсекается. В плохом коде null просачивается во все уровни: доменная модель, отчёты, форматирование, сортировки.
Схематично это выглядит так:
flowchart LR
A["Внешний мир (ввод, поиск, данные)"] -->|может быть null| B["Шлюз: нормализация/проверка"]
B -->|не-null| C["Внутренняя логика (модель, расчёты)"]
B -->|нет значения| D["Понятная реакция (сообщение/ветка/выход)"]
Идея в том, чтобы у вас в коде было меньше мест, где вы каждый раз изобретаете “а что делать с null?”. Пусть это решается в одном понятном месте.
Например, при разборе команды:
data class AddExpenseCommand(val title: String, val amount: Int)
fun parseAddCommand(line: String): AddExpenseCommand? {
val parts = line.trim().split(" ")
if (parts.size < 3) return null
val title = parts[1].trim().ifEmpty { return null }
val amount = parts[2].trim().toIntOrNull() ?: return null
return AddExpenseCommand(title = title, amount = amount)
}
fun main() {
println(parseAddCommand("add coffee 300")) // AddExpenseCommand(title=coffee, amount=300)
println(parseAddCommand("add coffee oops")) // null
}
Тут null означает конкретную вещь: “команда некорректна”. И важно, что null не просочился дальше. Мы не сделали AddExpenseCommand(title: String?, amount: Int?). Мы либо вернули нормальную команду, либо null как сигнал “не распознали”.
Nullable-ресивер в extension-функциях: локализуем проверки
Иногда хочется сделать маленькую утилиту, которая умеет работать даже если значение null. Kotlin позволяет писать extension-функции на nullable-типе (Any?, String? и т.д.). Это полезно, когда вы хотите спрятать повторяющуюся проверку в одно место и не писать её в каждом вызове. В официальной документации Kotlin это тоже показано: extension на Any? может вернуть строку "null" вместо падения, и внутри тела после проверки this == null компилятор уже понимает, что this не null.
Для нашего приложения можно сделать что-то совсем простое — безопасное отображение текста:
fun String?.toUiText(): String {
return this?.trim()?.takeIf { it.isNotEmpty() } ?: "<empty>"
}
fun main() {
val a: String? = " hello "
val b: String? = " "
val c: String? = null
println(a.toUiText()) // hello
println(b.toUiText()) // <empty>
println(c.toUiText()) // <empty>
}
Обратите внимание на тонкую грань: это уместно в UI/выводе, но не всегда уместно в расчётах и хранении данных. Внутри модели "<empty>" был бы странным “сентинелом”, а вот для печати в консоль — нормальный, осознанный выбор.
6. Типичные ошибки при работе с null
Ошибка №1: делать поля модели nullable “на всякий случай”.
Чаще всего это происходит из желания “быстрее создать объект”. В итоге объект становится лёгким в создании, но тяжёлым в использовании: в каждом месте приходится помнить про ?. и придумывать, что делать с отсутствием. Обычно это признак того, что в один тип вы смешали “сырой ввод” и “валидную сущность”.
Ошибка №2: возвращать null и для “не найдено”, и для “ошибка”.
Такой контракт заставляет вызывающий код угадывать причину, а значит он либо начнёт “молчаливо чинить” всё через ?:, либо начнёт падать !!, либо просто будет печатать "UNKNOWN" и портить данные. Если вы ловите себя на фразе “вернём null, если что-то пошло не так”, это почти всегда сигнал, что контракт туманный.
Ошибка №3: лечить дизайн null-контрактов оператором !!.
!! иногда нужен, но в реальном проекте он часто превращается в “я устал думать”. Проблема в том, что вы не решили вопрос смысла: вы просто перенесли падение в рантайм. В контексте “nullable как контракт” это выглядит так: “контракт говорит, что значения может не быть, но я делаю вид, что оно есть”.
Ошибка №4: заменять null на сентинелы (0, -1, "UNKNOWN") без строгой договорённости.
Если вы подставляете значение “по умолчанию”, вы меняете смысл данных. Иногда это уместно (особенно для UI), но если это делается без единой политики и без понимания предметной области, вы получаете тихие логические ошибки: суммы считаются “почти правильно”, отчёты выглядят “почти нормально”, а пользователь потом спрашивает, почему у него расход "UNKNOWN: 0".
Ошибка №5: размазывать null по пайплайну вместо одного шлюза обработки.
Когда null проходит через 5–10 функций, вы теряете контроль: где именно он появился и что он означает. Намного устойчивее, когда null живёт рядом с источником (ввод/поиск) и быстро превращается в понятное решение: “переспроси”, “не найдено”, “команда некорректна”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ