1. null — это не “пусто”, а “нет объекта”
Когда вы только начинаете программировать, очень хочется, чтобы “отсутствие” вело себя как обычное значение: например, чтобы null был чем-то вроде пустой строки "" или числа 0. Но null в Kotlin (и вообще на JVM) означает другое: объекта нет, а значит у него нет ни свойств, ни методов. Это как пытаться позвонить человеку, у которого… нет телефона. Даже номер не “пустой”, а телефона физически не существует.
На уровне выполнения программы проблема выглядит так: если вы попытаетесь обратиться к свойству или методу у null, получится ошибка выполнения (на JVM — типичный NullPointerException). Kotlin пытается не допустить эту ситуацию заранее, ещё на этапе компиляции.
Чтобы почувствовать разницу, сравним “пустую строку” и “строки нет”:
fun main() {
val s1: String = ""
val s2: String? = null
println(s1.length) // 0
println(s2) // null
}
У s1 объект-строка существует, просто длина нулевая. У s2 объекта нет вообще — поэтому говорить про “длину” бессмысленно.
Опасность появляется при доступе через точку .
В Kotlin (как и во многих языках) точка . означает: “возьми у объекта его свойство или вызови его метод”. Это настолько привычно, что мы делаем так автоматически: name.length, text.uppercase(), number.toString(). И пока тип не nullable, всё честно: объект есть, значит к нему можно обратиться.
Но как только слева от точки оказывается значение типа T?, появляется развилка: объект может существовать, а может быть null. А точка . — штука безжалостная: она не умеет “вежливо спросить”, существует ли объект. Она просто пытается обратиться.
Вот простой пример, который Kotlin не даст скомпилировать:
fun main() {
val text: String? = null
println(text.length) // Ошибка компиляции: text может быть null
}
Почему компилятор против? Потому что строка text по контракту может быть null, а у null нет length. И если бы Kotlin промолчал, программа имела бы шанс упасть во время выполнения.
Важно уловить мысль: опасность появляется не из-за самого null, а из-за операции “обратиться к члену” (.), которая подразумевает наличие объекта.
Тип T? — это “тут два сценария”
В Kotlin тип String? — это как табличка на двери: “в помещении может быть человек, а может никого не быть”. Если вы хотите с кем-то поговорить (вызвать метод или взять свойство), нужно сначала убедиться, что кто-то там есть. Kotlin заставляет вас это сделать, потому что иначе вы получите ошибку в рантайме, причём часто в самый неподходящий момент.
Типичный сценарий, который выглядит “невинно”, но на самом деле опасен, связан с парсингом чисел:
fun main() {
val a: Int? = "6".toIntOrNull()
val b: Int? = "7".toIntOrNull()
println(a * b) // Ошибка компиляции: a и b могут быть null
}
Здесь компилятор видит следующее: a — не Int, а Int?. Значит, вместо “точно число” у нас “или число, или отсутствие числа”. А арифметика определена для чисел, но не для “возможно отсутствующих чисел”. Поэтому умножение запрещено, пока вы не решите, что делать в случае null.
2. Откуда берутся nullable в консольных задачах
Ввод пользователя и toIntOrNull()
Сейчас у нас нет ни коллекций, ни классов, ни сложных API. Но даже в маленьких консольных программах null уже появляется постоянно — из-за ввода данных. Пользователь может ввести что угодно, включая “сорок двааа”, пустую строку, пробелы и внезапную философию вместо возраста.
Именно поэтому в предыдущих лекциях появилось toIntOrNull(): оно говорит честно — “если это похоже на число, верну число, иначе верну null”.
Посмотрим на минимальный пример:
fun main() {
val text = readln().trim()
val age: Int? = text.toIntOrNull()
println(age) // если ввели "42" -> 42, если "oops" -> null
}
Здесь риск не в том, что age равен null. Риск начинается в момент, когда мы захотим делать с age что-то “как с числом”: сравнивать, прибавлять, умножать. Компилятор останавливает нас раньше, чем программа успеет упасть, и требует: “обработай вариант null”.
Как nullable “расползается” по коду
На практике неприятное ощущение появляется так: вы сделали всё правильно, взяли toIntOrNull(), получили Int?, и… вдруг половина операций стала “нельзя”. Это не “вредность Kotlin”, а логика: если значение может отсутствовать, почти любой следующий шаг обязан учитывать это отсутствие.
Например, вы хотите посчитать, сколько лет будет человеку через год:
fun main() {
val ageText = readln().trim()
val age: Int? = ageText.toIntOrNull()
val nextYearAge = age + 1 // Ошибка: age может быть null
}
Компилятор как бы говорит: “Я не против математики, я против математики с пустотой”. И если подумать как человек, он прав: если возраст не распарсился, что значит “прибавить 1”?
Здесь важно научиться задавать себе прикладной вопрос: что должна делать программа, если значения нет? Вариантов обычно два:
- либо мы считаем ввод неправильным и сообщаем об ошибке;
- либо мы выбираем какое-то поведение по умолчанию (но это уже требует аккуратности, потому что “дефолтный возраст 0” — странная идея в реальной жизни).
В этой лекции мы решим проблему первым способом: явной проверкой и понятным сообщением.
3. Как защититься без специальных операторов для null
Явная проверка if (x == null)
Сейчас мы ещё не вводим специальные операторы для null (они будут в следующей лекции), поэтому действуем максимально прямо и “по-честному”: пишем if, проверяем null, и в каждой ветке делаем осмысленное действие.
Вот пример, где мы либо печатаем результат, либо объясняем ошибку:
fun main() {
val aText = readln().trim()
val bText = readln().trim()
val a: Int? = aText.toIntOrNull()
val b: Int? = bText.toIntOrNull()
if (a != null && b != null) {
println(a * b) // если ввели 6 и 7 -> 42
} else {
println("Нужно ввести два целых числа.")
}
}
Обратите внимание на важную деталь: мы не пытаемся “протащить” null дальше по программе. Мы максимально близко к месту использования решаем, что делать.
Ранний return держит код плоским
Когда проверок становится больше, код легко превращается в “лестницу из if”, где всё уезжает вправо, как будто файл постепенно сползает со стола. Поэтому полезный приём — ранний выход: если данные плохие, мы быстро завершаем сценарий (или возвращаемся из функции), а “основная” логика остаётся без лишних уровней вложенности.
Пока мы пишем в main, это выглядит так:
fun main() {
val ageText = readln().trim()
val age: Int? = ageText.toIntOrNull()
if (age == null) {
println("Возраст должен быть числом.")
return
}
println("Через год вам будет ${age + 1}.") // при вводе 20 -> Через год вам будет 21.
}
Заметьте, как читабельно: сначала “охранная проверка”, затем нормальная логика. И компилятор тоже доволен, потому что после return у нас остаётся сценарий, где возраст точно есть.
Мини‑анкета: “мягкий” возраст без падений
Чтобы примеры не были набором отдельных кусочков, давайте продолжим развивать одну и ту же идею приложения: маленькая консольная анкета. До этого она могла просто спрашивать имя и возраст. Теперь мы сделаем её устойчивее: возраст может быть введён некорректно, и программа не должна падать.
Сделаем маленькую функцию, которая печатает “красивое приветствие”, если возраст корректен, или объясняет ошибку, если нет. (Функции у нас уже были в предыдущих лекциях, так что ничего сверхъестественного.)
fun greetUser(name: String, ageText: String) {
val age: Int? = ageText.trim().toIntOrNull()
if (age == null) {
println("Привет, $name! Я не понял возраст: '$ageText'.")
return
}
println("Привет, $name! Тебе $age лет.") // например: Привет, Alex! Тебе 25 лет.
}
fun main() {
val name = readln().trim()
val ageText = readln()
greetUser(name, ageText)
}
4. Памятка и схема: как думает компилятор
Что компилятор потребует доказать
Иногда помогает воспринимать T? как тип “с развилкой”. Тогда компилятор не “мешает”, а просто заставляет выбрать маршрут.
Небольшая табличка, чтобы закрепить логику:
| Что у вас в руках | Что вы хотите сделать | Почему опасно/неопасно | Что потребует компилятор |
|---|---|---|---|
|
|
объект строки точно есть | ничего, это безопасно |
|
|
text может быть null, у null нет length | обработать null (например, if) |
|
|
age может отсутствовать, “прибавить 1 к отсутствию” не определено | обработать null |
| Int? после проверки | |
в текущей ветке значение есть | компилятор разрешит |
Отдельно полезно помнить, что Kotlin не угадывает за вас смысл. Если вы сами не решите, что делать при null, язык не будет придумывать “ну пусть будет 0”. Он честно попросит вас написать это явно.
Схема: получили T? → компилятор тормозит → выбираем поведение
Иногда студентам легче запомнить не правила, а маршрут. Вот маршрут в виде простой блок‑схемы:
flowchart TD
A["Получили значение типа T?"] --> B["Хотим вызвать метод/взять свойство (.) или сделать операцию"]
B --> C{"Значение может быть null?"}
C -->|Да| D["Компилятор запрещает прямое использование"]
D --> E["Пишем обработку: if (x == null) ... else ..."]
C -->|Нет| F["Операция безопасна, компилятор разрешает"]
Да, это выглядит как бюрократия. Но на практике это экономит часы отладки, потому что “упало где-то там” превращается в “не компилируется вот здесь”, а это обычно намного приятнее.
5. Типичные ошибки
Ошибка №1: воспринимать сообщение компилятора как “язык сломался”, а не как подсказку о логике.
Когда Kotlin пишет, что нельзя вызвать .length у String?, он не придирается к синтаксису. Он буквально сообщает: “ты ещё не определился, что делать, если строки нет”. Лучший подход — не спорить с компилятором, а ответить на вопрос: “какое поведение верное при null именно в этом месте программы?”
Ошибка №2: пытаться протащить null дальше и “разобраться потом”.
Часто хочется сделать так: “пусть age будет Int?, а дальше я как-нибудь…” — и вот дальше всё начинает сыпаться ошибками типов. Обычно проще и чище обработать null ближе к источнику: распарсили → проверили → либо работаем с нормальным значением, либо завершаем сценарий/сообщаем об ошибке.
Ошибка №3: делать nullable “на всякий случай”, а потом удивляться запретам.
Если переменная по смыслу обязана существовать (например, имя пользователя в анкете), не нужно объявлять её как String? “на будущее”. Nullable-типы надо вводить там, где отсутствие значения реально возможно и логически допустимо. Иначе вы сами себе усложняете жизнь, а компилятор честно начинает требовать обработки там, где её не должно быть.
Ошибка №4: путать “ошибка парсинга” и “значение 0”.
Иногда студент думает, что toIntOrNull() при ошибке вернёт 0. Но 0 — это нормальное число, а null — это “числа нет”. Если смешать эти смыслы, программа начнёт принимать неверный ввод за валидный (например, возраст “котик” превратится в 0), и вы получите логическую ошибку, которую компилятор уже не поймает.
Ошибка №5: писать проверку, но не менять поведение.
Бывает код в стиле: “если age == null, то… ничего” и программа идёт дальше и снова упирается в запреты или в необходимость странных действий. Проверка на null должна вести к решению: либо мы прекращаем выполнение текущего сценария (ранний return), либо задаём отдельную ветку поведения. Если решения нет, проверка превращается в декоративную наклейку “осторожно”, которая ничего не делает.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ