JavaRush /Курси /Kotlin SELF /Чому компілятор забороняє небезпечний код

Чому компілятор забороняє небезпечний код

Kotlin SELF
Рівень 11 , Лекція 2
Відкрита

1. null — це не «порожньо», а «немає об’єкта»

Коли ви тільки починаєте програмувати, дуже хочеться, щоб «відсутність» поводилася як звичайне значення. Наприклад, щоб null був чимось на кшталт порожнього рядка "" або числа 0. Але null у Kotlin (і загалом на JVM) означає інше: об’єкта немає, а отже, у нього немає ані властивостей, ані методів. Це як намагатися зателефонувати людині, у якої… немає телефону. Навіть номер не «порожній» — телефона фізично не існує.

Під час виконання програми проблема виглядає так: якщо ви спробуєте звернутися до властивості або методу в null, отримаєте помилку виконання (на JVM — типовий NullPointerException). Kotlin намагається не допустити цю ситуацію заздалегідь — ще на етапі компіляції.

Щоб відчути різницю, порівняймо «порожній рядок» і «рядка немає»:

fun main() {
    val s1: String = ""
    val s2: String? = null

    println(s1.length) // 0
    println(s2)        // null
}

У s1 об’єкт-рядок існує — просто його довжина нульова. А в s2 об’єкта немає взагалі, тож говорити про «довжину» безглуздо.

Небезпека з’являється під час доступу через крапку .

У Kotlin (як і в багатьох мовах) крапка . означає: «візьми в об’єкта його властивість або виклич його метод». Це настільки звично, що ми робимо так автоматично: name.length, text.uppercase(), number.toString(). І доки тип не nullable, усе чесно: об’єкт є — отже, до нього можна звернутися.

Але щойно зліва від крапки опиняється значення типу T?, з’являється розвилка: об’єкт може існувати, а може бути null. А крапка . — штука безжальна: вона не вміє «ввічливо спитати», чи існує об’єкт. Вона просто намагається звернутися.

Ось простий приклад, який Kotlin не дасть скомпілювати:

fun main() {
    val text: String? = null

    println(text.length) // Помилка компіляції: text може бути null
}

Чому компілятор проти? Тому що рядок text за контрактом може бути null, а в null немає length. І якби Kotlin промовчав, програма мала б шанс упасти під час виконання.

Важливо вловити думку: небезпека з’являється не через сам null, а через операцію «звернутися до члена» (.), яка передбачає наявність об’єкта.

Тип T? — це «тут два сценарії»

У Kotlin тип String? — це як табличка на дверях: «у приміщенні може бути людина, а може нікого не бути». Якщо ви хочете з кимось поговорити (викликати метод або взяти властивість), потрібно спочатку переконатися, що хтось там є. Kotlin змушує вас це зробити, бо інакше ви отримаєте помилку під час виконання — і часто в найнедоречніший момент.

Типовий сценарій, який виглядає «невинно», але насправді небезпечний, пов’язаний із розбором чисел:

fun main() {
    val a: Int? = "6".toIntOrNull()
    val b: Int? = "7".toIntOrNull()

    println(a * b) // Помилка компіляції: a і b можуть бути null
}

Тут компілятор бачить таке: a — не Int, а Int?. Отже, замість «точно число» ми маємо «або число, або відсутність числа». А арифметика визначена для чисел, але не для «можливо відсутніх чисел». Тому множення заборонене, доки ви не вирішите, що робити у випадку null.

2. Звідки беруться nullable в консольних задачах

Уведення користувача і toIntOrNull()

Зараз у нас немає ані колекцій, ані класів, ані складних API. Але навіть у маленьких консольних програмах null уже з’являється постійно — через введення даних. Користувач може ввести що завгодно: «сорок двааа», порожній рядок, самі пробіли або раптову філософію замість віку.

Саме тому в попередніх лекціях з’явилося toIntOrNull(): воно чесно каже: «якщо це схоже на число — поверну число, інакше поверну null».

Подивімося на мінімальний приклад:

fun main() {
    val text = readln().trim()
    val age: Int? = text.toIntOrNull()

    println(age) // якщо ввели "42" -> 42, якщо "oops" -> null
}

Тут ризик не в тому, що age дорівнює null. Ризик починається в момент, коли ми захочемо робити з age щось «як із числом»: порівнювати, додавати, множити. Компілятор зупиняє нас раніше, ніж програма встигне впасти, і вимагає: «обробіть варіант null».

Як nullable «розповзається» по коду

На практиці неприємне відчуття виникає так: ви зробили все правильно, взяли toIntOrNull(), отримали Int? — і… раптом половина операцій стала «не можна». Це не «шкідливість Kotlin», а логіка: якщо значення може бути відсутнім, майже будь-який наступний крок зобов’язаний урахувати цю відсутність.

Наприклад, ви хочете порахувати, скільки років буде людині за рік:

fun main() {
    val ageText = readln().trim()
    val age: Int? = ageText.toIntOrNull()

    val nextYearAge = age + 1 // Помилка: age може бути null
}

Компілятор ніби каже: «Я не проти математики, я проти математики з порожнечею». І якщо подумати по-людськи, він має рацію: якщо вік не розпарсився, що означає «додати 1»?

Тут важливо навчитися ставити собі прикладне запитання: що має робити програма, якщо значення немає? Варіантів зазвичай два:

  • або ми вважаємо введення неправильним і повідомляємо про помилку;
  • або ми обираємо якусь поведінку за замовчуванням (але тут потрібна обережність, бо «вік за замовчуванням 0» — дивна ідея в реальному житті).

У цій лекції ми розв’яжемо проблему першим способом: явною перевіркою та зрозумілим повідомленням.

3. Як захиститися без спеціальних операторів для null

Явна перевірка if (x == null)

Зараз ми ще не вводимо спеціальні оператори для null (вони будуть у наступній лекції). Тому діємо максимально прямо й «по-чесному»: пишемо if, перевіряємо null і в кожній гілці робимо осмислену дію.

Ось приклад, де ми або друкуємо результат, або пояснюємо помилку:

fun main() {
    val aText = readln().trim()
    val bText = readln().trim()

    val a: Int? = aText.toIntOrNull()
    val b: Int? = bText.toIntOrNull()

    if (a != null && b != null) {
        println(a * b) // якщо ввели 6 і 7 -> 42
    } else {
        println("Потрібно ввести два цілі числа.")
    }
}

Зверніть увагу на важливу деталь: ми не намагаємося «протягнути» null далі по програмі. Натомість ми якомога ближче до місця використання вирішуємо, що робити.

Ранній return тримає код пласким

Коли перевірок стає більше, код легко перетворюється на «драбину з if», де все зсувається вправо, ніби файл поступово сповзає зі столу. Тому корисний прийом — ранній вихід: якщо дані погані, ми швидко завершуємо сценарій (або виходимо з функції), а «основна» логіка залишається без зайвих рівнів вкладеності.

Поки ми пишемо в main, це виглядає так:

fun main() {
    val ageText = readln().trim()
    val age: Int? = ageText.toIntOrNull()

    if (age == null) {
        println("Вік має бути числом.")
        return
    }

    println("За рік вам буде ${age + 1}.") // якщо ввели 20 -> За рік вам буде 21.
}

Зауважте, як це читається: спочатку «охоронна перевірка», потім нормальна логіка. І компілятор теж задоволений, бо після return у нас залишається сценарій, де вік точно є.

Міні-анкета: «м’який» вік без падінь

Щоб приклади не були набором окремих шматочків, продовжімо розвивати одну й ту саму ідею застосунку: маленька консольна анкета. Раніше вона могла просто питати імʼя та вік. Тепер зробімо її стійкішою: вік можуть увести некоректно, і програма не повинна падати.

Зробімо маленьку функцію, яка друкує «гарне привітання», якщо вік коректний, або пояснює помилку, якщо ні. (Функції в нас уже були в попередніх лекціях, тож нічого надприродного.)

fun greetUser(name: String, ageText: String) {
    val age: Int? = ageText.trim().toIntOrNull()
    if (age == null) {
        println("Привіт, $name! Я не зрозумів, який у вас вік: '$ageText'.")
        return
    }
    println("Привіт, $name! Вам $age років.") // наприклад: Привіт, Олексій! Вам 25 років.
}

fun main() {
    val name = readln().trim()
    val ageText = readln()
    greetUser(name, ageText)
}

4. Пам’ятка і схема: як думає компілятор

Що компілятор вимагатиме довести

Іноді допомагає сприймати T? як тип «із розвилкою». Тоді компілятор не «заважає», а просто змушує обрати маршрут.

Невеличка табличка, щоб закріпити логіку:

Що у вас у руках Що ви хочете зробити Чому (не)безпечно Що вимагатиме компілятор
String
text.length
об’єкт рядка точно є нічого, це безпечно
String?
text.length
text може бути null, у null немає length обробити null (наприклад, if)
Int?
age + 1
age може бути відсутнім, «додати 1 до відсутності» не визначено обробити null
Int? після перевірки
age + 1
у поточній гілці значення є компілятор дозволить

Окремо корисно пам’ятати: Kotlin не вгадує за вас зміст. Якщо ви самі не вирішите, що робити при null, мова не буде вигадувати «ну нехай буде 0». Вона чесно попросить вас написати це явно.

Схема: отримали T? → компілятор гальмує → обираємо поведінку

Іноді студентам легше запам’ятати не правила, а маршрут. Ось маршрут у вигляді простої блок-схеми:

flowchart TD
    A["Отримали значення типу T?"] --> B["Хочемо викликати метод/взяти властивість (.) або виконати операцію"]
    B --> C{"Значення може бути null?"}
    C -->|Так| D["Компілятор забороняє пряме використання"]
    D --> E["Пишемо обробку: if (x == null) ... else ..."]
    C -->|Ні| F["Операція безпечна, компілятор дозволяє"]

Так, це виглядає як бюрократія. Але на практиці це економить години налагодження, бо «впало десь там» перетворюється на «не компілюється ось тут». А це зазвичай набагато приємніше.

5. Типові помилки

Помилка № 1: сприймати повідомлення компілятора як «мова зламалася», а не як підказку про логіку.
Коли Kotlin пише, що не можна викликати .length у String?, він не прискіпується до синтаксису. Він буквально повідомляє: «ви ще не визначилися, що робити, якщо рядка немає». Найкращий підхід — не сперечатися з компілятором, а відповісти на запитання: «яка поведінка правильна при null саме в цьому місці програми?»

Помилка № 2: намагатися протягнути null далі й «розібратися потім».
Часто хочеться зробити так: «нехай age буде Int?, а далі я якось…» — і от далі все починає сипатися помилками типів. Зазвичай простіше й чистіше обробити null ближче до джерела: розпарсили → перевірили → або працюємо з нормальним значенням, або завершуємо сценарій/повідомляємо про помилку.

Помилка № 3: робити nullable «про всяк випадок», а потім дивуватися заборонам.
Якщо змінна за змістом має існувати (наприклад, імʼя користувача в анкеті), не потрібно оголошувати її як String? «на майбутнє». Nullable-типи варто вводити там, де відсутність значення реально можлива й логічно допустима. Інакше ви самі собі ускладнюєте життя, а компілятор чесно починає вимагати обробки там, де її не має бути.

Помилка № 4: плутати «помилка розбору» і «значення 0».
Іноді студент думає, що toIntOrNull() у разі помилки поверне 0. Але 0 — це нормальне число, а null — це «числа немає». Якщо змішати ці смисли, програма почне приймати неправильне введення за валідне (наприклад, вік «котик» перетвориться на 0), і ви отримаєте логічну помилку, яку компілятор уже не впіймає.

Помилка № 5: зробити перевірку, але не змінити поведінку.
Буває код у стилі: «якщо age == null, то… нічого» — і програма йде далі та знову впирається в заборони або в необхідність дивних дій. Перевірка на null має вести до рішення: або ми припиняємо виконання поточного сценарію (ранній return), або задаємо окрему гілку поведінки. Якщо рішення немає, перевірка перетворюється на декоративну наліпку «обережно», яка нічого не робить.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ