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) // попередження: unnecessary safe call
println(name.length) // 5
}
Попередження тут — не прискіпування. Воно каже: «ви вже в безпечній зоні, можна не ходити в касці».
Інший приклад — коли 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, імʼя — на «анонім», а користувач отримує дивні результати без пояснення. Правильний підхід — спочатку вирішити, що робити за 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 далі в програмі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ