JavaRush /Курсы /Kotlin SELF /Nullable как контракт: где null оправдан, а где мешает

Nullable как контракт: где null оправдан, а где мешает

Kotlin SELF
41 уровень , 0 лекция
Открыта

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 живёт рядом с источником (ввод/поиск) и быстро превращается в понятное решение: “переспроси”, “не найдено”, “команда некорректна”.

1
Задача
Kotlin SELF, 41 уровень, 0 лекция
Недоступна
Чистый комментарий
Чистый комментарий
1
Задача
Kotlin SELF, 41 уровень, 0 лекция
Недоступна
Парсинг положительного целого числа без “ошибки как null”
Парсинг положительного целого числа без “ошибки как null”
1
Задача
Kotlin SELF, 41 уровень, 0 лекция
Недоступна
TASK 03: Поиск по Map как “может отсутствовать”
TASK 03: Поиск по Map как “может отсутствовать”
1
Задача
Kotlin SELF, 41 уровень, 0 лекция
Недоступна
TASK 04: Extension на nullable‑receiver для “текста в UI”
TASK 04: Extension на nullable‑receiver для “текста в UI”
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ