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)
}
Это не скомпилируется, потому что a — Int?. А 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 не изменится. Если x — var, если вы уходите далеко от проверки, или если значение может поменяться, smart cast ломается. В таких случаях помогает либо ?./?:, либо «снимок» в val.
Ошибка №5: использовать ?. как будто он «подставляет дефолт».
?. не подставляет ничего. Он просто возвращает null, если слева null. Если вам нужен дефолт (например, длина 0), это уже работа ?:. Путаница этих ролей приводит к цепочкам вида val x = text?.length и неожиданным null дальше по программе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ