JavaRush /Курсы /Kotlin SELF /Nullable-типы T? и значение null

Nullable-типы T? и значение null

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

1. Зачем нужен null

Программисты любят определенность: если у нас есть переменная name, то там всегда должно быть имя. Но реальная жизнь (и реальные пользователи) любят ломать эту романтичную картину. Кто-то не ввёл никнейм, кто-то пропустил возраст, кто-то нажал Enter «просто чтобы посмотреть, что будет». И вот у вас появляется задача: как хранить «неизвестно»?

Вариант «ну пусть будет пустая строка ""» иногда работает, но часто ломает смысл. Пустая строка — это тоже значение, просто «строка длины 0». А иногда нам нужно хранить именно отсутствие значения: «поля не было», «человек не указал», «мы не смогли посчитать». Для этого в Kotlin есть специальное значение — null.

Представьте, что вы заполняете анкету. Поле «Отчество» можно не заполнять. Если вы не заполнили, это не значит, что ваше отчество — пустая строка. Это значит, что информации нет. Именно для таких ситуаций и нужен null.

null — это не "", не 0 и не false

Важно привыкнуть к мысли: null — это не «пустое значение», а отсутствие значения как факт. Пустая строка "" — строка. Ноль 0 — число. false — логическое значение. А null — это «ничего», «не задано», «не существует».

Почему это различие реально важно? Потому что иначе вы начинаете путать смыслы и писать странные условия. Например, если возраст не введён, вы можете выбрать стратегию «считаем, что возраст 0». Но тогда программа начнёт вести себя так, как будто пользователь — новорождённый. Смешно, но только первые два раза.

Сравним несколько «похожих, но не одинаковых» вариантов:

Значение Типичный смысл
""
текст есть, но он пустой
"   "
текст есть, но состоит из пробелов (обычно его ещё чистят trim())
"0"
текст "0" (это не число!)
0
число ноль
null
значения нет вообще

И тут 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
}

Обратите внимание на два момента. Во-первых, nicknamevar, потому что мы хотим сначала хранить отсутствие значения, а потом присвоить нормальную строку. Во-вторых, println печатает null как текст null — это нормально, но это ещё не «красивый вывод». Красиво мы научимся делать позже; сейчас нам важно понять типы.

Сравним String и String? в одной маленькой таблице:

Тип Можно присвоить "hi" Можно присвоить null Что означает
String
да нет «строка обязательно есть»
String?
да да «строка может отсутствовать»

Ещё один важный вывод: 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-значением так же, как с обычным. Это может раздражать первые полчаса, но потом вы начинаете понимать, что компилятор просто не даёт вам наступить на грабли, которые люди в других языках считают “нормой жизни”.

1
Задача
Kotlin SELF, 11 уровень, 1 лекция
Недоступна
Профиль игрока
Профиль игрока
1
Задача
Kotlin SELF, 11 уровень, 1 лекция
Недоступна
Регистрация пользователя
Регистрация пользователя
1
Задача
Kotlin SELF, 11 уровень, 1 лекция
Недоступна
Счётчик коробок
Счётчик коробок
1
Задача
Kotlin SELF, 11 уровень, 1 лекция
Недоступна
Умножение заказа
Умножение заказа
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ