Помилки та Попередження
Поговоримо про досить важливу річ у 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
.
Чим же вони відрізняються?
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
також має свої підвити (класи, що його спадкують) і ми можемо створити свою помилку. Але спочатку
подивимось на структуру:
У класі є два цікавих для нас поля:
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).