1. Зачем нужен null
Программисты любят определенность: если у нас есть переменная name, то там всегда должно быть имя. Но реальная жизнь (и реальные пользователи) любят ломать эту романтичную картину. Кто-то не ввёл никнейм, кто-то пропустил возраст, кто-то нажал Enter «просто чтобы посмотреть, что будет». И вот у вас появляется задача: как хранить «неизвестно»?
Вариант «ну пусть будет пустая строка ""» иногда работает, но часто ломает смысл. Пустая строка — это тоже значение, просто «строка длины 0». А иногда нам нужно хранить именно отсутствие значения: «поля не было», «человек не указал», «мы не смогли посчитать». Для этого в Kotlin есть специальное значение — null.
Представьте, что вы заполняете анкету. Поле «Отчество» можно не заполнять. Если вы не заполнили, это не значит, что ваше отчество — пустая строка. Это значит, что информации нет. Именно для таких ситуаций и нужен null.
null — это не "", не 0 и не false
Важно привыкнуть к мысли: null — это не «пустое значение», а отсутствие значения как факт. Пустая строка "" — строка. Ноль 0 — число. false — логическое значение. А null — это «ничего», «не задано», «не существует».
Почему это различие реально важно? Потому что иначе вы начинаете путать смыслы и писать странные условия. Например, если возраст не введён, вы можете выбрать стратегию «считаем, что возраст 0». Но тогда программа начнёт вести себя так, как будто пользователь — новорождённый. Смешно, но только первые два раза.
Сравним несколько «похожих, но не одинаковых» вариантов:
| Значение | Типичный смысл |
|---|---|
|
текст есть, но он пустой |
|
текст есть, но состоит из пробелов (обычно его ещё чистят trim()) |
|
текст "0" (это не число!) |
|
число ноль |
|
значения нет вообще |
И тут Kotlin делает очень правильный ход: он не позволяет вам «случайно» смешивать эти смыслы. Если переменная может быть null, это должно быть видно прямо в её типе. В Kotlin nullable-типы явно помечаются вопросительным знаком ? в конце имени типа.
2. Nullable и non-nullable типы
Non-nullable типы T
Почти все типы, с которыми вы работали раньше, на самом деле были non-nullable: String, Int, Boolean. То есть переменная такого типа не имеет права содержать null. И это не «строгость ради строгости», а фундаментальная защита: если тип String, значит у него точно есть строковые свойства и методы (например, длина). Kotlin может на это опираться.
Посмотрим на минимальный пример:
fun main() {
val title: String = "Kotlin"
println(title) // Kotlin
title = null // Ошибка компиляции: String не допускает null
}
Здесь title — обычная строка. Kotlin гарантирует, что title всегда содержит строку, а не «ничего». Поэтому если вы попробуете присвоить null, компилятор остановит вас раньше, чем программа успеет запуститься.
Почему это круто? Потому что ошибки вида «ой, тут оказалось null» — одни из самых неприятных: они всплывают в рантайме (когда пользователь уже запускает программу), а не при компиляции (когда вы ещё можете спокойно всё поправить).
Nullable-типы T?: как объявлять и читать
Когда вы понимаете, что значение может отсутствовать, вы должны честно сказать об этом Kotlin. Делается это просто: добавляем ? к типу. String? читается как «строка или null», Int? — «целое число или null».
Вот базовый пример:
fun main() {
var nickname: String? = null
println(nickname) // null
nickname = "neo"
println(nickname) // neo
}
Обратите внимание на два момента. Во-первых, nickname — var, потому что мы хотим сначала хранить отсутствие значения, а потом присвоить нормальную строку. Во-вторых, println печатает null как текст null — это нормально, но это ещё не «красивый вывод». Красиво мы научимся делать позже; сейчас нам важно понять типы.
Сравним String и String? в одной маленькой таблице:
| Тип | Можно присвоить "hi" | Можно присвоить null | Что означает |
|---|---|---|---|
|
да | нет | «строка обязательно есть» |
|
да | да | «строка может отсутствовать» |
Ещё один важный вывод: nullable-тип — это не «строка с какими-то бонусами». Это другой тип. Поэтому Kotlin не даёт «делать вид», что String? — это String.
3. Nullable-результаты функций
Контракт T? в сигнатуре
До этого вы привыкли, что функция либо возвращает нормальное значение, либо вы как-то “сами разберётесь” в теле функции. Но с null появляется очень полезная дисциплина: если функция может не дать результат, это отражается в возвращаемом типе.
Это похоже на честный контракт. Если написано Int, вызывающий код вправе ожидать число всегда. Если написано Int?, вызывающий код обязан помнить: «могу получить число, а могу получить null».
Самый знакомый вам пример — парсинг:
fun parseAge(text: String): Int? = text.toIntOrNull()
fun main() {
println(parseAge("42")) // 42
println(parseAge("oops")) // null
}
Здесь происходит важная вещь: функция parseAge не обязана «выдумывать» возраст при ошибке. Она честно говорит: «если получится — верну Int, если нет — верну null». И это не “плохая новость”. Это просто ветка реальности, которая теперь явно видна по типу.
Кстати, именно поэтому названия вроде ...OrNull в стандартной библиотеке Kotlin — не случайность, а подсказка: «возможен null».
toIntOrNull() как первый практический пример Int?
Вы уже встречали toIntOrNull(), но сегодня полезно взглянуть на него не как на «спасатель от падений», а как на функцию с очень понятным контрактом: она возвращает Int?. То есть либо число, либо null.
Сделаем маленький фрагмент, максимально похожий на то, что вы реально пишете в консольных задачах:
fun main() {
print("Введите возраст: ")
val input = readln().trim()
val age: Int? = input.toIntOrNull()
println("age = $age") // например: age = 25 или age = null
}
Пока мы ничего “умного” не делаем — просто читаем, чистим пробелы и пытаемся распарсить. Самое важное здесь: переменная age имеет тип Int?, потому что мы получаем результат от функции, которая может вернуть null.
4. Мини-приложение: профиль пользователя
Чтобы null не оставался абстрактной философией, давайте начнём собирать маленькое консольное приложение, которое мы сможем постепенно улучшать в следующих темах (но сегодня — строго в рамках типов). Идея простая: пользователь вводит профиль. Часть полей обязательная, часть — нет. Например, имя обязательно, а никнейм и возраст — по желанию.
Здесь важно правильно выбрать, где будет String, а где String?. Это хорошая привычка: по умолчанию делать типы не nullable, и добавлять ? только там, где отсутствие значения — реальная и осмысленная ситуация.
Обязательные и необязательные строки
Начнём с самого простого: читаем имя (обязательное) и никнейм (можно пропустить). Чтобы у нас появился null, договоримся так: если пользователь ввёл пустую строку после trim(), будем считать, что значения нет.
fun main() {
print("Имя (обязательно): ")
val name: String = readln().trim()
print("Никнейм (можно пусто): ")
val nickInput = readln().trim()
val nickname: String? = if (nickInput == "") null else nickInput
println("name = $name") // например: name = Alice
println("nickname = $nickname") // например: nickname = null
}
Обратите внимание: мы пока не «улучшаем UX» и не валидируем имя как следует. Нам сейчас важнее увидеть типовую модель: обязательное поле — String, необязательное — String?.
Необязательный возраст: Int?
Теперь добавим возраст. Он тоже необязательный, но ещё и должен быть числом. То есть «пусто» и «не число» — оба случая должны привести к null. Это удобно выразить через toIntOrNull():
fun main() {
print("Возраст (можно пусто): ")
val ageInput = readln().trim()
val age: Int? = if (ageInput == "") null else ageInput.toIntOrNull()
println("age = $age") // например: age = 30 или age = null
}
Да, здесь есть лёгкая “косметическая шероховатость”: если введут "abc", то toIntOrNull() вернёт null, и вы не отличите «пусто» от «ошибка». Но для сегодняшней лекции это даже полезно: мы тренируемся видеть, что оба сценария приводят к отсутствию результата, и это выражено типом Int?.
Собираем профиль целиком
Теперь соберём всё вместе и добавим вывод профиля. Поскольку мы пока не изучали удобные инструменты обращения к nullable-значениям, будем печатать “как есть”, чтобы наблюдать null явно:
fun main() {
print("Имя (обязательно): ")
val name: String = readln().trim()
print("Никнейм (можно пусто): ")
val nickInput = readln().trim()
val nickname: String? = if (nickInput == "") null else nickInput
print("Возраст (можно пусто): ")
val ageInput = readln().trim()
val age: Int? = if (ageInput == "") null else ageInput.toIntOrNull()
println("Профиль: имя=$name, ник=$nickname, возраст=$age")
// например: Профиль: имя=Alice, ник=null, возраст=28
}
И вот здесь вы получаете главный практический результат лекции: вы не «придумываете» значения вместо пользователя и не делаете вид, что всё заполнено. Вы храните реальную картину мира в типах. Если никнейм не введён — это null. Если возраст не получился — это null. Это честно, предсказуемо и очень удобно для дальнейшей логики.
Чтобы закрепить навык “читать типы глазами”, попробуйте мысленно проговорить вслух:
- val name: String — «строка всегда есть».
- val nickname: String? — «строка может отсутствовать».
- val age: Int? — «число может отсутствовать».
Да, это звучит как упражнение из языковой школы, но это реально помогает. Программирование — это в том числе умение читать соглашения, а типы в Kotlin — одно из самых важных соглашений.
5. Типичные ошибки
Ошибка №1: путать null и "" (пустую строку).
Очень легко начать хранить отсутствие значения как пустую строку, а потом внезапно обнаружить, что вы не можете отличить «пользователь ничего не ввёл» от «пользователь реально хотел пустое значение» (да, иногда и такое бывает). Лучше заранее договориться: если поле опциональное, отсутствие — это null, а пустая строка — это всё равно строка.
Ошибка №2: делать всё nullable “на всякий случай”.
Новичку кажется безопасным объявить все строки как String?, чтобы “точно не упасть”. Проблема в том, что вы размазываете неопределённость по всему коду: теперь каждую строку нужно обрабатывать как потенциально отсутствующую, даже если по смыслу она обязательная (например, имя команды или название меню). Хорошая привычка в Kotlin — считать значения non-nullable по умолчанию и добавлять ? только там, где это действительно отражает реальность.
Ошибка №3: ожидать, что toIntOrNull() вернёт 0 при ошибке.
Некоторые подсознательно ждут, что при неудачном парсинге “ну будет ноль”. Но контракт у toIntOrNull() другой: он возвращает null, если строка не является числом. И это логично: 0 — конкретное число и может быть реальным вводом пользователя, а null — честный сигнал «не получилось».
Ошибка №4: пытаться вернуть null из функции, не указав ? в типе результата.
Если вы объявили fun parseAge(...): Int, то вернуть null нельзя — и компилятор вас остановит. Это не придирка, а помощь: сначала определяем контракт (результат обязателен или нет), а потом пишем реализацию. Если “может не получиться”, возвращаемый тип должен быть Int? (или другой T?).
Ошибка №5: считать, что null — это “просто ещё одно значение” и ничего не меняется.
Формально да, null — значение. Но семантически оно означает отсутствие. Поэтому как только null появляется в типе, он начинает влиять на весь код вокруг: Kotlin не позволит вам обращаться с nullable-значением так же, как с обычным. Это может раздражать первые полчаса, но потом вы начинаете понимать, что компилятор просто не даёт вам наступить на грабли, которые люди в других языках считают “нормой жизни”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ