Пропустити до головного контенту

Помилки та Попередження

Поговоримо про досить важливу річ у Kotlin – помилки. Ми вже стикалися з ними при розгляді деяких тем, тому розберім же їх:

Визначення

Помилка – це стан деякої функціональності або програми, при якій неможлива робота цієї функціональності/програми. Помилки поділяються на компіляційні (помилки від компілятора на момент компіляції) та виконаційні (помилки під час виконання програми). Виконаційні, своєю чергою, поділяються на «виключення» (Exceptions) та «помилки» (Errors).

Компіляційні помилки

Почнімо з компіляційних помилок. Коли ж вони виникають? Найбанальніший приклад – це коли ви неправильно використовуєте ключові слова, або взагалі їх не використовуєте, або пишете те, чого навіть не існує на мові Kotlin. Наприклад, написавши наступне:

fun foo(bar) {
println(bar)
}

Ми отримаємо наступну помилку:

e: Main.kt: (1, 9): A type annotation is required on a value parameter

Як ми можемо побачити, в помилці вказано файл, де сталась помилка, та щось у дужках. Насправді перше число в дужках відповідає лінії в коді, де сталась помилка, а друге число – порядковий номер символу, де сталась помилка.

Далі йде повідомлення про помилку, в нашому випадку це помилка про те, що ми не вказали тип для параметра.

Згадайте

До речі, ми вже стикались з компіляційними помилками, наприклад у темі про математичні операції, коли у нас була нестикування типів:

fun main() {
val number: Int = readln() // функція повертає не Int, а String
}

І ми мали помилку (якщо повністю):

Main.kt: (2, 24): Type mismatch: inferred type is String but Int was expected

Попередження

Також компілятор може показувати не лише помилки, а й, наприклад деякі попередження. Але коли?

Вони з'являються, коли код не є помилковим, але все ж має деякі прогалини, які можна виправити. Наприклад:

fun foo() { // Function "foo" is never used 
println("foo")
}

fun main() {
val a = readln() // Variable 'a' is never used
println("Hello, World!")
}

Виведе наступні попередження:

Main.kt: (1, 5): Function "foo" is never used 
Main.kt: (6, 9): Variable 'a' is never used

Тобто компілятор трішки здивований, навіщо ми маємо функції та змінні, які не використовуємо, що не є помилкою, але викликає запитання.

Попередження, як і помилки, мають вказаний файл, лінію, порядковий номер символу та повідомлення.

Їх можна ігнорувати (код і з ними працювати буде), але якщо ви впевнені, що все так, як ви хочете.

Виконаційні помилки

Тепер же перейдім до виконаційних помилок, що стаються під час виконання програми. Взагалі, всі ці помилки будуються від одної головної абстракції (класу) – Throwable. Сам же Throwable має два основних спадкоємців – Exception та Error.

структура throwable Чим же вони відрізняються?

Error

Перший тип виконаційних помилок, що ми розберемо – це Error. Що ж воно таке?

Насправді Error – це щось схоже з компіляційною помилкою (зазвичай цю помилку не кидає код, який ви пишете чи використовуєте), але пов'язаною зі середою виконування (наприклад: Java Virtual Machine, JS, Native), коли ви досягаєте якогось ліміта або порушуєте якісь правила середи виконування.

Прикладом Error можна назвати, наприклад, – OutOfMemoryError – помилка, яка з'являється коли у віртуальної машини, чи деінде, невистачає пам'яті для якоїсь операції чи від якоїсь операції. Наприклад, якщо ви завантажите в пам'ять, умовний рядок вагою декілька гігабайт.

Exception

А тепер розберім інший тип виконаційних помилок, що вже більш пов'язаний з реальним кодом – Exception. З чим же його їдять?

Згадайте

В одній з тем ми вже стикались з подібною помилкою, згадайте Index оператор, коли ми намагались отримати елемент, якого не існує:

fun main() {
val string = "123"
println(string[3]) // такого елемента не існує
}

Де після спроби ми мали наступну помилку (на JVM):

Exception in thread "main" java.lang.StringIndexOutOfBoundsException: String index out of range: 3
at java.base/java.lang.StringLatin1.charAt(StringLatin1.java:47)
at java.base/java.lang.String.charAt(String.java:693)
at MainKt.main(MainKt.kt:3)
at MainKt.main(MainKt.kt)

Разом з повідомленням, до помилки додається стек викликів (розповім про це трішки нижче), де ми можемо побачити, де саме помилка сталась.

Подібні типи помилок зазвичай неможливо перевірити на момент компіляції програми (або перевірка занадто складна, як у часі, так і в реалізації).

Прикладом таких помилок можна назвати StringOutOfBoundsException (хоча в прикладі вище це можливо було б перевірити на момент компіляції, що робити, якщо у нас довільний рядок, який вводить тей же користувач?), NumberFormatException (коли ми намагаємось перевести рядок у число, але рядок не є числом, або він неправильного формату) та багато інших, які можна переглянути тут.

Власні помилки

Як я вже казав, Throwable поділяється на Exception та Throwable. А як ми зрозуміли в попередній підтемі, Exception також має свої підвити (класи, що його спадкують) і ми можемо створити свою помилку. Але спочатку подивимось на структуру: структура Exception У класі є два цікавих для нас поля:

  • message: String – помилка, яка показується розробнику
  • cause: Throwable – деталі помилки (що розробник зробив не так), необов'язково вказувати.

Давайте для наочності зробимо якийсь приклад:

class ExampleException : Exception("Example message")

* Повідомлення з помилкою ми передаємо в конструктор класу Exception.

Для того, щоб викинути цю помилку, ми використовуємо ключове слово throw:

fun main() {
throw ExampleException() // передаємо екземпляр класа, що реалізує Throwable
}

І отримуємо при запуску помилку:

Exception in thread "main" ExampleException: Example message
at MainKt.main(Main.kt:2)
at MainKt.main(Main.kt)

Запобігання помилок

Ми дізнались, як працюють помилки, але як їх запобігати? Розберімось.

Обидва приклади Exception помилок були викликані тим, що у нас несумісні з запитом дані. Як же подібні й не тільки запобігати? Найочевиднішим варіантом, звісно, буде банальна перевірка вхідних даних. З StringIndexOutOfBoundsException боротись можна простою перевіркою:

val string = readln()

// indicies – це послідовність чисел, де лежать вже індекси, що існують
// якщо рядок пустий, то й послідовність буде пуста й не матиме 0
if(0 in string.indicies)
println(string[0].toUpperCase()) // виводимо перший символ, але з великої

// або використовуючи String.size
// `String.size: Int` – фізичний розмір рядка (не плутати з індексами)
// Якщо в рядку немає символів (він пустий), то size дорівнює нулю.
// Якщо в рядку є один символ, то size дорівнює одному.
if(string.size != 0)
println(string[0].toUpperCase())

// або використовуючи String.lastIndex
// `String.lastIndex: Int` – зберігає останній записаний індекс у послідовність.
// Якщо в рядку немає символів (він пустий), то lastIndex дорівнює мінус одному.
// Така ж поведінка є і у `String.indexOf` – якщо символ не знайдено, повертається -1.
// Якщо в рядку є один символ, то lastIndex дорівнює 0.
if(string.lastIndex != -1)
println(string[0].toUpperCase())


Нові функції

Ми бачимо тут нові функції, дізнаймось що вони роблять:

  • String.indicies – це послідовність чисел, де лежать вже індекси, що існують.
  • String.size: Int – фізичний розмір рядка (не плутати з індексами). Якщо в рядку немає символів (він пустий), то size дорівнює нулю. Якщо в рядку є один символ, то size дорівнює одному тощо.
  • String.lastIndex: Int – зберігає останній записаний індекс у послідовність (наш рядок). Якщо в рядку немає символів (він пустий), то lastIndex дорівнює мінус одному. Якщо в рядку є один символ, то lastIndex дорівнює 0 тощо.
Цікаво знати

Функція String.indexOf(Char) також повертає -1, якщо не знайшла відповідного символу в рядку, тобто:

val string = "abcfe"
println(string.indexOf("d"))

Виведе наступне:

-1
буде помилкою

Враховуючи, що функція String.indexOf(Char) та змінна String.lastIndex (та їм подібні) може повертати -1, може виникнути наступна помилка:

fun main() {
val string = "abcfe"
println(string.substring(string.indexOf("d"), string.lastIndex)) // StringIndexOutOfBoundException
}

Виведе наступну помилку:

Exception in thread "main" java.lang.StringIndexOutOfBoundsException: begin -1, end 4, length 5
at java.base/java.lang.String.checkBoundsBeginEnd(String.java:3319)
at java.base/java.lang.String.substring(String.java:1874)
at MainKt.main(Main.kt:3)
at MainKt.main(Main.kt)

Тобто, немає елементу, який би відповідав індексу -1.

Тому правильний бойовий приклад:

fun main() {
val input = readln()
println(capitalize(string))
}

/**
* Робить першу літеру рядка великою зберігаючи увесь рядок.
*/
fun capitalize(string: String) {
return when {
// перевіряємо, чи є хоча б щось у рядку
string.size == 0 -> "" // size повертає фізичну величину масиву (не плутати з індексом)
// перевіряємо, чи рядок має лише одну літеру
string.size == 1 -> string[0].toUpperCase()
// рядок має точно 2 або більше літери, можна спокійно брати перший символ
// та обрізати рядок починаючи з другого символу
else -> string[0].toUpperCase() + string.substring(1, string.lastIndex)
}
}

Перевірки, перевірки та перевірки! Розраховувати можна лише на свій код, а не на правильного користувача.

try-catch оператор

Для деяких ситуацій, де ми не можемо напряму перевірити (або якщо це завжди потрібно), ми можемо використати оператор try-catch. Але що цей оператор робить?

Як можна помітити за назвою, ми щось пробуємо та ловимо. Розумієте аналогію?

try-catch оператор ловить помилки, та оброблює їх, наступним чином:

fun main() {
try {
println("Введіть число:")
val number = readln().toInt() // toInt() може викинути NumberFormatException
} catch(nfe: NumberFormatException) { // оброблюємо конкретну помилку
println("Неправильно введене число.")
main() // запускаємо програму до того моменту, поки користувач не введе число
}
}

Тобто у нас є блок try {} та catch(Exception) {}, у першому пишемо код, який може видавати помилку, а в другому код на випадок, якщо помилка все ж станеться.

порада

До речі, в цьому операторі можна перераховувати скільки заманеться помилок наступним чином:

try {
// код, який може викликати NumberFormatException або StringIndexOutOfBoundsException
val input = readln()
val firstSymbol = input[0] // може викликати StringIndexOutOfBoundsException
val number = input.toInt() // може викликати NumberFormatException
} catch(nfe: NumberFormatException) {
// якщо була NumberFormatException помилка
println("неправильно введене число")
} catch(ie: StringIndexOutOfBoundsException) {
// якщо була StringIndexOutOfBoundsException помилка
println("рядок пустий")
} catch(e: Exception) {
// будь-яка інша помилка
} catch(e: Error) {
// помилка від віртуальної машини
}

Зазвичай можна обійтись без цього метода, якщо не враховувати такі речі, як toInt, які власноруч перевіряти не варто.

Цікаво знати

До речі, ви можете використовувати try-catch, як вираз, наприклад:

val number: Int = try {
readln().toInt()
} catch(nfe: NumberFormatException) {
0 // число за замовчуванням
}

Але є ще один підхід, який виключає пряме використання подібного оператору

Null oriented design

Але є спосіб, яким можна обійтись для більшості подібних помилок – null oriented design (дизайн, що орієнтується на null). Ми вже розглянули, що таке null та оператори для нього. Тож розберемо ситуації, коли він дійсно корисний. Але як цей підхід працює?

До тих функцій, що ми знаємо (наприклад, toInt()) зазвичай додаються функції, що повертають null, якщо операція не є успішною, наприклад:

fun main() {
// отримуємо число від користувача, а якщо воно неправильне – виходимо з програми
val number = readln().toIntOrNull() ?: return println("Неправильно введене число")
// або, наприклад задати значення за замовчуванням:
val age = readln().toIntOrNull() ?: 0

if(age < 18)
println("вам не дозволено вхід")
else println("вам дозволено вхід")
}

Тобто для деяких випадків є функції, що повертають null. За дизайном, у Kotlin, подібні функції закінчуються на ..orNull(). Таких функцій досить багато, наприклад для запобігання StringIndexOutOfBoundsException можна використати наступну функцію:

fun main() {
val input = readln()
// беремо першу літеру та робимо з неї велику за допомогою
// safe-call оператора
val firstLetter = input.getOrNull(0)?.toUpperCase() ?: return println("рядок пустий")
}

Рекомендую використовувати цей підхід при написанні вашого коду, щоб ваш код був безпечнішим.

підказка

Щоб знаходити подібні функції слід використовувати підказки в вашій IDE або в ній же, наприклад, переходити до вихідного коду класу (який використовуєте) та дивитись, які функції там є. Це можна зробити наводячи на потрібний клас / функцію за допомогою Ctrl (command на Mac) + B або натискати ліву кнопку миші разом з Ctrl (command на Mac). Також, лише на Windows, можна натискати на колесико миші.

Висновок

У цій темі ми розібрали різні за типом помилки, тож підіб'ємо підсумки:

  • Помилки поділяють на компіляційні та виконаційні.
  • Виконаційні помилки мають різні різновиди: Error – помилка від віртуальної машини, Exception – помилка з коду.
  • Throwable, разом з Error та Exception можна спадкувати та робити свої помилки, але рекомендую дотримуватись семантики цих помилок.
  • Запобігати помилок можна за допомогою try-catch (обробки помилок) та підходу, коли подібні помилки виключаються зі списку можливих (null oriented design).