JavaRush /Курсы /Kotlin SELF /Сообщения компилятора о nullability

Сообщения компилятора о nullability

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

1. Введение

Если вы только начинаете, компилятор легко воспринимается как сварливый преподаватель, который вместо «не так» пишет: «Type mismatch: inferred type is String? but String was expected» — и уходит в закат. Но на самом деле компилятор Kotlin довольно честный: он почти всегда говорит, что именно вы пытаетесь сделать, и почему это опасно. Наша цель — научиться видеть за «страшной фразой» конкретную бытовую ситуацию: «у меня может не быть значения, а я веду себя так, будто оно точно есть».

С сегодняшнего момента мы будем смотреть на сообщения про nullability как на подсказки, а не как на наказание. В IntelliJ IDEA (особенно в режиме K2) это ещё приятнее: IDE подсвечивает проблемное место и часто предлагает быстрые исправления.

Главная идея: T? пытаются использовать как T

Когда вы видите ошибки, связанные с null, полезно держать в голове простую модель: Kotlin делит значения на две «касты». В первой касте живут обычные типы (String, Int) — они обещают: «значение существует». Во второй — nullable (String?, Int?) — они честно предупреждают: «возможно, значения нет».

Если вы попытаетесь обращаться к членам объекта (.length, .uppercase()), передавать значение в функцию, которая ждёт не-null, или делать арифметику, компилятор спросит: «а что будет, если там null?». И пока вы не ответите на этот вопрос кодом, он программу не скомпилирует.

Небольшая «блок-схема мышления»:

flowchart TD
    A[Получили значение типа T?] --> B{Что делаем дальше?}
    B --> C[Проверяем на null и выбираем ветку]
    B --> D[Используем ?. чтобы безопасно обратиться]
    B --> E[Используем ?: чтобы подставить дефолт]
    B --> F[Используем !! и рискуем упасть]
    C --> G[Внутри ветки не-null: работаем как с T]

Это не «четыре разных философии», а четыре разных ответа на один вопрос: как именно мы обрабатываем отсутствие значения.

Таблица-переводчик: сообщение → причина → типовая правка

Сообщения компилятора пугают в основном тем, что выглядят как «письмо из налоговой». Давайте превратим их в нормальную человеческую таблицу. Здесь будут самые частые формулировки, с которыми вы столкнётесь на этом этапе.

Сообщение компилятора (типичное) Что это значит по-человечески На что смотреть в коде Что обычно исправляет
Type mismatch: inferred type is String? but String was expected Вы даёте String? туда, где нужен String Тип слева/справа, тип параметра функции ?: (дефолт), ранний return, проверка if (x == null)
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver Вы зовёте метод/свойство у T? как у T Место, где стоит точка: x.something x?.something, либо if (x != null) x.something
Operator call corresponds to a dot-qualified call ... which is not allowed on a nullable receiver То же самое, но для операторов (+, *) Операция над Int?, Double? Снять nullable: ?:, проверка null, не умножать «возможно-числа»
Smart cast to 'String' is impossible, because ... Компилятор не может гарантировать, что значение не поменяется Обычно var, свойство объекта, или «слишком далеко» от проверки Снимок в val, перенести использование ближе к проверке, ?./?:
Unnecessary safe call on a non-null receiver Вы пишете ?., но слева и так не-null Тип переменной уже T Убрать ?. (это не ошибка, а уборка мусора)

Важно: мы сегодня не «заучиваем текст сообщений». Мы учимся угадывать их смысл по структуре: почти всегда там есть подсказки вроде expected/found, nullable receiver, smart cast impossible.

2. Алгоритм исправления: где вы должны решить судьбу null

Когда вы видите ошибку, хочется сделать самое быстрое: поставить !! и «пусть оно горит». Это человеческое желание, не осуждаем. Но давайте договоримся: !! — это как выбить дверь ногой. Иногда дверь реально заперта, и вы пожарный. Но чаще вы просто забыли ключ в другой куртке.

Вместо этого будем действовать по спокойному алгоритму.

Сначала вы находите, где именно появился nullable. На этом этапе курса самые частые источники — toIntOrNull() и переменные, которым вы явно дали тип String?/Int?. Потом вы отвечаете на вопрос: а что мы хотим сделать, если там null? Вариантов по сути несколько: показать сообщение, подставить дефолт, попросить ввести ещё раз, или аккуратно остановить сценарий.

И уже после этого вы выбираете технику: проверка if, safe call ?., Elvis ?: или, в редком случае, !!.

Чтобы было практично, давайте разберём это на мини-приложении, которое мы будем «доращивать» примерами.

Мини‑приложение: анкета пользователя и где рождаются ошибки

Представим простую программу: она спрашивает имя и возраст, а потом печатает приветствие. На бумаге всё выглядит мило. В реальном коде пользователь вводит «двадцать восемь», лишние пробелы, или просто нажимает Enter из философских соображений.

Начнём с прямолинейного варианта — он почти наверняка приведёт вас к сообщениям компилятора, которые мы сегодня учимся переводить.

fun main() {
    print("Введите имя: ")
    val name = readln().trim()

    print("Введите возраст: ")
    val age = readln().trim().toIntOrNull()

    println("Привет, $name! Тебе $age лет.")
    // Пример вывода: Привет, Alice! Тебе 28 лет.
}

На первый взгляд — нормально. Но тип у age здесь Int?, и это начинает «заражать» остальной код: вы печатаете «тебе null лет», а если дальше захотите сравнивать возраст или делать математику — компилятор начнёт протестовать.

И вот мы как раз переходим к самой сути: сообщения компилятора появляются не «потому что Kotlin вредный», а потому что вы ещё не приняли решение: что делать при ошибочном вводе.

3. Ошибка №1: Type mismatch ... String? vs String

С этим сообщением вы встретитесь очень быстро, когда попытаетесь вернуть nullable-значение там, где обещали не-null.

Например, вы решили написать функцию, которая «нормализует имя»:

fun normalizeName(raw: String?): String {
    return raw?.trim()
}

Это не скомпилируется, и вы увидите ошибку примерно в духе:

Type mismatch: inferred type is String? but String was expected

Почему? Потому что raw?.trim() — это String?. Если raw == null, результат тоже null. А вы в сигнатуре пообещали вернуть String (то есть «строка точно есть»).

Исправление зависит от смысла. Самый простой (и часто правильный для UI/вывода) вариант — дать дефолт через Elvis:

fun normalizeName(raw: String?): String {
    return raw?.trim() ?: "Безымянный герой"
}

fun main() {
    println(normalizeName("  Alice  ")) // Alice
    println(normalizeName(null))        // Безымянный герой
}

Обратите внимание на суть ошибки: компилятор не просит вас «поставить !!». Он просит определиться, что делать, если raw отсутствует.

Тот же принцип работает и с числами:

fun parseAge(raw: String): Int {
    return raw.trim().toIntOrNull()
}

Здесь будет: Type mismatch: inferred type is Int? but Int was expected.

Решение снова про смысл. Если вы считаете, что при ошибке возраста нужно подставить 0 (не всегда логично, но иногда допустимо), то так:

fun parseAge(raw: String): Int {
    return raw.trim().toIntOrNull() ?: 0
}

fun main() {
    println(parseAge("42"))     // 42
    println(parseAge("oops"))   // 0
}

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

4. Ошибка №2: Only safe (?.) or non-null asserted (!!.) calls are allowed...

Это сообщение обычно возникает, когда вы делаете что-то вроде:

fun main() {
    val nickname: String? = null
    println(nickname.length)
}

У null нет длины (он вообще не объект строки), поэтому Kotlin не даёт вам написать код, который потенциально упадёт.

Расшифровка ошибки: вы пытаетесь вызвать член (length) у значения, которое может отсутствовать. Способы исправления опять зависят от смысла.

Если вам достаточно «ну нет — значит нет», используйте safe call:

fun main() {
    val nickname: String? = null
    val len: Int? = nickname?.length
    println(len) // null
}

Если вам нужен гарантированный Int, добавляйте Elvis:

fun main() {
    val nickname: String? = null
    val len: Int = nickname?.length ?: 0
    println(len) // 0
}

Если вам нужно сделать одну логику при наличии ника, а другую при отсутствии — проверка if часто читабельнее:

fun printNicknameInfo(nickname: String?) {
    if (nickname == null) {
        println("Ника нет") // Ника нет
        return
    }
    println("Ник '$nickname', длина = ${nickname.length}")
}

fun main() {
    printNicknameInfo(null)
    printNicknameInfo("neo")
}

Здесь важно, что после return компилятор понимает: дальше nickname точно не null. Это и есть smart cast в «линейном стиле».

5. Ошибка №3: nullable‑арифметика и операторы

Иногда вы не пишете точку, а пишете a + b, и всё равно получаете сообщение про nullable receiver. Это может удивить: «я же не вызывал метод!». Но в Kotlin многие операторы — это синтаксический сахар над вызовами функций.

Например:

fun main() {
    val a: Int? = "10".toIntOrNull()
    val b: Int = 5

    val sum = a + b
    println(sum)
}

Это не скомпилируется, потому что aInt?. А plus (то, что стоит за +) ожидает слева нормальный Int.

Исправление — снова «снять nullable». Самый прямой вариант: дать дефолт.

fun main() {
    val a: Int = "10".toIntOrNull() ?: 0
    val b: Int = 5

    val sum = a + b
    println(sum) // 15
}

Или, если вы хотите прекращать сценарий при ошибке:

fun main() {
    val aOrNull = "oops".toIntOrNull()
    if (aOrNull == null) {
        println("Это не число") // Это не число
        return
    }

    val sum = aOrNull + 5
    println(sum)
}

Заметьте характерный паттерн: val x = ... ?: return. Он помогает держать код плоским, без лишних вложенных if.

6. Ошибка №4: Smart cast ... is impossible

Это самое обидное сообщение, потому что вы вроде бы всё сделали правильно: проверили на null, а компилятор всё равно говорит «не могу».

Классический пример — var:

fun main() {
    var text: String? = "hi"

    if (text != null) {
        println(text.length) // иногда именно здесь компилятор может начать спорить в более сложных случаях
    }
}

В простом случае компилятор разрешит. Но стоит чуть усложнить картину (например, поменять text между проверкой и использованием), и доверие исчезает:

fun main() {
    var text: String? = "hi"

    if (text != null) {
        text = null
        println(text.length) // уже нельзя
        println(text?.length)   // можно
    }
}

Перевод ошибки smart cast: компилятор не может гарантировать, что значение осталось прежним.

Один из самых простых способов «вернуть доверие» — сделать снимок в val (неизменяемую ссылку) и работать с ним:

fun main() {
    var text: String? = "hi"
    val snapshot: String? = text

    if (snapshot != null) {
        println(snapshot.length) // 2
    }
}

Этот приём выглядит почти смешно («зачем копировать переменную в переменную?»), но по смыслу он мощный: вы фиксируете значение в точке времени и дальше работаете с тем, что не может быть переназначено.

7. Собираем анкету по‑взрослому

Предупреждения компилятора: не «ругается», а помогает

Ошибки (errors) останавливают сборку. Предупреждения (warnings) — нет, но они часто подсказывают, что ваш код стал чище.

Очень типичное: вы уже сделали переменную не-null, но по привычке продолжаете писать ?..

fun main() {
    val name: String = "Alice"
    println(name?.length) 	 // warning: unnecessary safe call
    println(name.length)     // 5
}

Предупреждение здесь — не придирка. Оно говорит: «ты уже в safe зоне, можно не ходить в каске».

Другой пример — когда Elvis бессмысленен:

fun main() {
    val x: Int = 10
    val y = x ?: 0 // это вообще не скомпилируется: x не nullable
    println(x) // 10
}

Здесь компилятор не даст использовать ?:, потому что слева не T?. Это тоже полезная защита: Elvis — это инструмент именно для nullable-ветки.

Анкета: типы понятны, ветки явные

Теперь давайте вернёмся к нашей анкете и сделаем её так, чтобы компилятор был доволен, а поведение при ошибках было предсказуемым.

Пусть у нас будет такое правило: имя должно быть непустым, возраст должен быть числом. Если возраст не число — выводим сообщение и завершаем программу.

fun main() {
    print("Введите имя: ")
    val name = readln().trim()

    if (name.isEmpty()) {
        println("Имя не может быть пустым") // Имя не может быть пустым
        return
    }

    print("Введите возраст: ")
    val age = readln().trim().toIntOrNull() ?: run {
        println("Возраст должен быть числом") // Возраст должен быть числом
        return
    }

    println("Привет, $name! Тебе $age лет.") // Пример: Привет, Alice! Тебе 28 лет.
}

Обратите внимание на две вещи.

Во-первых, age здесь становится Int, а не Int?, потому что ?: либо даёт число, либо завершает программу. Значит, дальше никакая арифметика и сравнения не будут «заражены» nullable.

Во-вторых, мы явно выбрали поведение при ошибке. И именно это компилятор всё время просил сделать, просто выражался канцеляритом.

Когда уместно !! и почему компилятор так настойчиво вас отговаривает

Раз уж мы постоянно говорим «не надо чинить всё через !!», давайте честно: !! существует не для красоты. Он нужен, когда вы осознанно выбираете контракт «если тут null — это ошибка программы».

Например, вы сделали внутреннюю переменную nullable только потому, что так пришло из API, но по логике вашего сценария она обязана быть заполнена к этому месту. Тогда !! превращается в «сторожевого пса»: если логика нарушена, программа падает сразу.

Но в учебных консольных сценариях на вводе пользователя !! почти всегда плохая идея: пользователь не обязан соблюдать ваш контракт, он просто живёт.

Мини-пример (как это выглядит и почему это риск):

fun main() {
    val ageText = readln().trim()
    val age = ageText.toIntOrNull()!!
    println("age = $age")
}

Это компилируется, но если ввод не число — программа аварийно завершится. Компилятор предупреждал, а вы его не послушали. Как минимум, делайте это только тогда, когда падение действительно является желаемым поведением.

8. Типичные ошибки

Ошибка №1: «чинить компиляцию», а не логику.
Часто новичок видит красную подсветку, ставит ?: 0 или !!, и радуется, что «заработало». Но по смыслу программа начинает врать: возраст превращается в 0, имя в «anonymous», а пользователь получает странные результаты без объяснения. Правильный подход — сначала решить, что делать при null, и только потом выбрать оператор.

Ошибка №2: путать null и «пустое значение».
null — это «значения нет», а "" — «строка есть, но пустая». Если вы договорились, что пустая строка недопустима для имени, это не повод делать String?. Это повод сделать проверку isEmpty()/isBlank() и вывести сообщение. Nullable не заменяет валидацию.

Ошибка №3: размазывать обработку null по всему коду.
Если вы получили Int? из toIntOrNull(), а потом протащили его через пять функций, вы получите пять мест, где компилятор будет «припоминать» вам nullable. Намного проще обработать null ближе к месту появления: превратить Int? в Int через ?: или завершить сценарий. Тогда остальной код станет проще.

Ошибка №4: надеяться на smart cast «навсегда».
Проверка if (x != null) делает x безопасным только внутри понятной области и только пока компилятор уверен, что x не изменится. Если xvar, если вы уходите далеко от проверки, или если значение может поменяться, smart cast ломается. В таких случаях помогает либо ?./?:, либо «снимок» в val.

Ошибка №5: использовать ?. как будто он «подставляет дефолт».
?. не подставляет ничего. Он просто возвращает null, если слева null. Если вам нужен дефолт (например, длина 0), это уже работа ?:. Путаница этих ролей приводит к цепочкам вида val x = text?.length и неожиданным null дальше по программе.

1
Задача
Kotlin SELF, 11 уровень, 5 лекция
Недоступна
Имя в чате
Имя в чате
1
Задача
Kotlin SELF, 11 уровень, 5 лекция
Недоступна
Длина никнейма
Длина никнейма
1
Задача
Kotlin SELF, 11 уровень, 5 лекция
Недоступна
Касса на входе
Касса на входе
1
Задача
Kotlin SELF, 11 уровень, 5 лекция
Недоступна
Кеш заметки
Кеш заметки
1
Опрос
Null-safety, 11 уровень, 5 лекция
Недоступен
Null-safety
Null-safety
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ