🎯 Kotlin

Fehler- und Ausnahmebehandlung in Kotlin: Eine ausführliche Anleitung

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты — капитан космического корабля. Твой маршрут проложен, двигатели работают, всё идёт по плану. Но вдруг на пути встречается метеоритный дождь. Или заканчивается топливо. Или отказывает система навигации. Что ты будешь делать? Если просто проигнорировать проблему — корабль разобьётся. Если запаниковать и нажать все кнопки подряд — станет только хуже.

В программировании происходит то же самое. Твой код — это идеальный маршрут. Но реальный мир неидеален: пользователь может ввести букву вместо числа, файл может не найтись, интернет может отключиться. Если программа не умеет обрабатывать такие ситуации — она «разобьётся» (аварийно завершится) или, что ещё хуже, продолжит работать с неверными данными и выдаст чушь.

Обработка ошибок и исключений — это система аварийных протоколов на твоём корабле. Вместо того чтобы разбиться, программа:

  • Замечает проблему (исключительную ситуацию)
  • Переключается на запасной план (обработчик)
  • Сообщает экипажу (пользователю или разработчику) о проблеме
  • Пытается продолжить полёт (выполнение программы)
💡 Важно: Исключение — это не обязательно «ошибка» в смысле «ты написал плохой код». Это любая нештатная ситуация, которую программа не может игнорировать. Например, деление на ноль — это исключение. Отсутствие файла на диске — тоже исключение. Даже если ты написал идеальный код, внешние условия могут создать исключение.

В Kotlin, как и в большинстве современных языков, для этого существует специальный механизм — исключения (exceptions). Мы будем учиться их ловить, создавать свои и правильно реагировать на любые нештатные ситуации.

Теория: как это работает изнутри

Давай заглянем «под капот» и разберёмся, что происходит в памяти компьютера, когда возникает исключение.

Стек вызовов (Call Stack)

Представь, что программа — это стопка бумаг. Каждый раз, когда одна функция вызывает другую, мы кладём новый лист сверху. Когда функция завершается — лист убирается. Этот «стопка» называется стеком вызовов.

Пример: функция main() вызывает calculate(), которая вызывает divide(). Стек выглядит так (снизу вверх):

// Стек вызовов:
// 1. divide(a, b)
// 2. calculate()
// 3. main()

Как путешествует исключение

Когда в функции divide() происходит деление на ноль, происходит следующее:

  1. Создание объекта исключения: JVM (Java Virtual Machine, на которой работает Kotlin) создаёт специальный объект — экземпляр класса ArithmeticException. В этом объекте хранится вся информация: тип ошибки, сообщение, и, самое главное, трассировка стека (stack trace) — «фотография» стека вызовов в момент ошибки.
  2. Поиск обработчика: Программа начинает «разворачивать» стек в обратном порядке. Она идёт из divide() в calculate(), потом в main(). На каждом уровне она проверяет: «Есть ли здесь код, который готов обработать именно такое исключение?»
  3. Если обработчик найден: Выполнение переходит в блок catch. Программа не погибает, а продолжает работу с этого места.
  4. Если обработчик НЕ найден: Исключение «вылетает» из main() в JVM. JVM делает единственное, что может — аварийно завершает программу и печатает страшную красную ошибку в консоль.
⚠️ Важное следствие: Если ты не обработаешь исключение в своей функции, оно автоматически «всплывёт» в функцию, которая тебя вызвала. И так до самого верха. Это называется проброс исключения.

Иерархия исключений

Все исключения в Kotlin — это классы, которые наследуются от общего предка Throwable. У него два основных наследника:

  • Exception — исключения, которые мы можем и должны обрабатывать (например, ошибка ввода-вывода, деление на ноль).
  • Error — критические ошибки, которые обычно не обрабатывают (например, закончилась память). Их ловить бессмысленно — программу всё равно нужно перезапускать.

Зная иерархию, мы можем ловить исключения с разной степенью точности: от самого конкретного (ArithmeticException) до самого общего (Exception или даже Throwable).

Синтаксис в Kotlin: все формы и варианты

Kotlin предлагает несколько конструкций для работы с исключениями. Давай разберём каждую.

1. Базовая конструкция try-catch

Это основа основ. Ты говоришь программе: «Попробуй выполнить этот код. Если что-то пойдёт не так — вот запасной план».

try {
    // Опасный код, который может выбросить исключение
    val result = 10 / 0
    println("Результат: $result") // Эта строка НЕ выполнится
} catch (e: ArithmeticException) {
    // Обработчик для ArithmeticException
    println("Ошибка! Деление на ноль: ${e.message}")
}
// Программа продолжает работу здесь
println("Программа завершена успешно")

2. Несколько блоков catch

Один блок try может иметь несколько catch для разных типов исключений. Kotlin проверит их по порядку и выполнит первый подходящий.

try {
    val number = readLine()!!.toInt()
    val result = 100 / number
    println("Результат: $result")
} catch (e: NumberFormatException) {
    println("Ошибка: введено не число!")
} catch (e: ArithmeticException) {
    println("Ошибка: деление на ноль!")
} catch (e: Exception) {
    // "Ловушка" для всех остальных исключений
    println("Неизвестная ошибка: ${e.message}")
}
💡 Совет: Всегда располагай более конкретные исключения выше, а общие — ниже. Если поставить catch (e: Exception) первым, он «перехватит» все исключения, и до более точных обработчиков дело никогда не дойдёт.

3. Блок finally

Блок finally выполняется всегда, независимо от того, было исключение или нет. Даже если в try или catch есть return — finally выполнится перед выходом из функции.

fun readFile() {
    val scanner = Scanner(File("data.txt"))
    try {
        while (scanner.hasNext()) {
            println(scanner.nextLine())
        }
    } catch (e: FileNotFoundException) {
        println("Файл не найден!")
    } finally {
        scanner.close() // Закроем файл в любом случае
        println("Ресурс освобождён")
    }
}

4. try как выражение

В Kotlin try — это не просто инструкция, а выражение. Оно может возвращать значение! Это очень удобно.

val number = try {
    readLine()!!.toInt() // Если успешно — результат присвоится в number
} catch (e: NumberFormatException) {
    0 // Если ошибка — присвоится 0
}
println("Введённое число (или 0): $number")

5. Конструкция require и check

Это специальные функции-«проверяльщики». Они выбрасывают исключение, если условие не выполнено. require — для проверки аргументов функции, check — для проверки состояния программы.

fun setAge(age: Int) {
    require(age >= 0) { "Возраст не может быть отрицательным: $age" }
    // Если age < 0, вылетит IllegalArgumentException
    println("Возраст установлен: $age")
}

fun processData(data: List<String>?) {
    checkNotNull(data) { "Данные не должны быть null!" }
    // Если data == null, вылетит IllegalStateException
    println("Обрабатываем ${data.size} элементов")
}

6. Создание своих исключений

Ты можешь создать собственный класс исключения, унаследовав его от Exception (или любого другого). Это полезно, когда стандартные исключения не описывают твою ситуацию.

// Создаём своё исключение
class InsufficientFundsException(
    val balance: Double,
    val amount: Double
) : Exception("Недостаточно средств. Баланс: $balance, запрошено: $amount")

fun withdraw(balance: Double, amount: Double): Double {
    if (amount > balance) {
        throw InsufficientFundsException(balance, amount)
    }
    return balance - amount
}

fun main() {
    try {
        val newBalance = withdraw(100.0, 150.0)
        println("Новый баланс: $newBalance")
    } catch (e: InsufficientFundsException) {
        println("Ошибка: ${e.message}")
        println("Вам не хватает ${e.amount - e.balance} рублей")
    }
}

7. Оператор Elvis и безопасный вызов

Хотя это не про исключения в классическом смысле, эти операторы помогают избежать самой частой ошибки в Kotlin — NullPointerException.

val name: String? = null

// Безопасный вызов — если name == null, длина не вычисляется
val length = name?.length ?: 0 // Elvis: если слева null, берём 0

// Вместо этого (опасно!):
// val length = name!!.length // Выбросит NullPointerException

Примеры кода — от простого к сложному

Пример 1: Деление на ноль (самое простое)

fun main() {
    println("Введите делимое:")
    val dividend = readLine()!!.toInt()
    println("Введите делитель:")
    val divisor = readLine()!!.toInt()

    try {
        val result = dividend / divisor
        println("Результат: $result")
    } catch (e: ArithmeticException) {
        println("Ошибка: деление на ноль невозможно!")
    }
    // Объяснение:
    // 1. Читаем два числа от пользователя
    // 2. Пытаемся их разделить
    // 3. Если divisor == 0, возникает ArithmeticException
    // 4. Блок catch перехватывает её и выводит сообщение
    // 5. Программа не падает, а продолжает работу
}

Пример 2: Преобразование строки в число

fun main() {
    print("Введите число: ")
    val input = readLine()

    try {
        val number = input!!.toInt()
        println("Вы ввели число: $number")
    } catch (e: NumberFormatException) {
        println("Ошибка: '$input' не является числом!")
    } catch (e: NullPointerException) {
        println("Ошибка: ввод был пустым!")
    }
    // Объяснение:
    // 1. readLine() может вернуть null, если ввод прерван
    // 2. Оператор !! (not-null assertion) выбросит NullPointerException, если null
    // 3. toInt() выбросит NumberFormatException, если строка не число
    // 4. Два catch обрабатывают разные ситуации
}

Пример 3: Работа с файлами

import java.io.File
import java.io.FileNotFoundException
import java.util.Scanner

fun main() {
    print("Введите имя файла: ")
    val fileName = readLine() ?: "default.txt"

    try {
        val file = File(fileName)
        val scanner = Scanner(file)
        
        println("Содержимое файла '$fileName':")
        while (scanner.hasNextLine()) {
            println(scanner.nextLine())
        }
        scanner.close()
    } catch (e: FileNotFoundException) {
        println("Файл '$fileName' не найден!")
        println("Проверьте, что файл существует в текущей директории.")
    } catch (e: SecurityException) {
        println("Нет прав на чтение файла '$fileName'!")
    }
    // Объяснение:
    // 1. Просим пользователя ввести имя файла
    // 2. Пытаемся открыть файл и прочитать его
    // 3. FileNotFoundException — если файла нет
    // 4. SecurityException — если нет прав доступа
    // 5. В обоих случаях программа даёт понятное объяснение
}

Пример 4: Валидация данных с require

fun createUser(name: String, age: Int, email: String) {
    // Проверяем имя
    require(name.isNotBlank()) { "Имя не может быть пустым!" }
    require(name.length >= 2) { "Имя должно содержать минимум 2 символа!" }
    
    // Проверяем возраст
    require(age in 0..150) { "Возраст должен быть от 0 до 150 лет!" }
    
    // Проверяем email (упрощённо)
    require(email.contains("@")) { "Email должен содержать @" }
    require(email.contains(".")) { "Email должен содержать точку!" }
    
    println("Пользователь $name (возраст: $age, email: $email) создан!")
}

fun main() {
    // Тестируем с разными данными
    try {
        createUser("Анна", 25, "anna@mail.com") // Всё хорошо
        createUser("", 25, "anna@mail.com")     // Ошибка: пустое имя
    } catch (e: IllegalArgumentException) {
        println("Ошибка создания пользователя: ${e.message}")
    }
    // Объяснение:
    // 1. require() проверяет условие
    // 2. Если условие ложно — выбрасывает IllegalArgumentException
    // 3. Сообщение в {} становится текстом ошибки
    // 4. Это удобно для валидации входных данных
}

Пример 5: Собственное исключение для банковского счёта

// Создаём своё исключение
class InsufficientFundsException(
    val balance: Double,
    val withdrawal: Double
) : Exception("Недостаточно средств. Баланс: $balance, запрошено: $withdrawal")

class BankAccount(private var balance: Double) {
    
    fun deposit(amount: Double) {
        require(amount > 0) { "Сумма пополнения должна быть положительной!" }
        balance += amount
        println("Пополнено на $amount. Баланс: $balance")
    }
    
    fun withdraw(amount: Double) {
        require(amount > 0) { "Сумма снятия должна быть положительной!" }
        
        if (amount > balance) {
            throw InsufficientFundsException(balance, amount)
        }
        
        balance -= amount
        println("Снято $amount. Баланс: $balance")
    }
    
    fun getBalance(): Double = balance
}

fun main() {
    val account = BankAccount(1000.0)
    
    try {
        account.withdraw(500.0)   // Успешно
        account.withdraw(700.0)   // Ошибка: недостаточно средств
    } catch (e: InsufficientFundsException) {
        println("Ошибка: ${e.message}")
        println("Вам не хватает ${e.withdrawal - e.balance} рублей")
    } catch (e: IllegalArgumentException) {
        println("Ошибка ввода: ${e.message}")
    }
    
    println("Итоговый баланс: ${account.getBalance()}")
    // Объяснение:
    // 1. Создаём класс исключения с дополнительными полями
    // 2. В исключении храним баланс и запрошенную сумму
    // 3. В catch можем получить доступ к этим полям
    // 4. Это позволяет дать пользователю точную информацию
}

Пример 6: try как выражение для безопасного ввода

fun main() {
    println("Введите три числа через Enter:")
    
    // Используем try как выражение для каждого числа
    val num1 = try {
        readLine()!!.toInt()
    } catch (e: Exception) {
        println("Ошибка при вводе первого числа. Используем 0.")
        0
    }
    
    val num2 = try {
        readLine()!!.toInt()
    } catch (e: Exception) {
        println("Ошибка при вводе второго числа. Используем 0.")
        0
    }
    
    val num3 = try {
        readLine()!!.toInt()
    } catch (e: Exception) {
        println("Ошибка при вводе третьего числа. Используем 0.")
        0
    }
    
    val sum = num1 + num2 + num3
    println("Сумма введённых чисел: $sum")
    // Объяснение:
    // 1. try возвращает значение: либо результат из try, либо из catch
    // 2. Если пользователь ввёл не число — catch возвращает 0
    // 3. Программа не падает, а продолжает работу с запасным значением
    // 4. Это очень удобно для интерактивных программ
}

Пример 7: Многоуровневая обработка с finally

import java.io.File
import java.io.FileNotFoundException
import java.io.IOException

fun processFile(fileName: String): String {
    var content = ""
    var file: File? = null
    
    try {
        file = File(fileName)
        content = file.readText()
        println("Файл '$fileName' успешно прочитан")
        return content
    } catch (e: FileNotFoundException) {
        println("Файл '$fileName' не найден!")
        throw e // Пробрасываем исключение дальше
    } catch (e: IOException) {
        println("Ошибка ввода-вывода: ${e.message}")
        throw e
    } finally {
        println("Блок finally выполняется всегда")
        println("Освобождаем ресурсы, если нужно...")
        // Здесь можно закрыть файл, если бы мы открывали его вручную
    }
}

fun main() {
    val fileName = "test.txt"
    
    try {
        val data = processFile(fileName)
        println("Длина содержимого: ${data.length}")
    } catch (e: FileNotFoundException) {
        println("Не удалось обработать файл: файл не существует")
    } catch (e: IOException) {
        println("Не удалось обработать файл: проблема с чтением")
    }
    // Объяснение:
    // 1. processFile() ловит исключение, логирует его и пробрасывает дальше
    // 2. finally выполняется в любом случае — даже если исключение проброшено
    // 3. main() ловит уже проброшенное исключение
    // 4. Такая многоуровневая обработка позволяет:
    //    - На нижнем уровне: залогировать и освободить ресурсы
    //    - На верхнем уровне: принять решение, что делать пользователю
}

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python, тебе будет легче освоить исключения в Kotlin. Давай сравним ключевые моменты.

Характеристика Kotlin Python
Базовый синтаксис try { ... } catch (e: Exception) { ... } try: ... except Exception as e: ...
Блок finally Есть, работает так же Есть, работает так же
try как выражение Есть! Можно присвоить результат try переменной Нет, try — только инструкция
Проверяемые исключения Нет (как и в Python) Нет
Создание своих исключений Наследование от Exception Наследование от Exception
Оператор throw throw MyException() raise MyException()
Блок else Нет Есть (выполняется, если не было исключения)
NullPointerException Особая роль, есть операторы для защиты (?., ?:) Нет такого понятия (всё — объекты)
Множественные catch Несколько блоков catch Несколько except или кортеж в одном
Проброс исключения throw e (в блоке catch) raise (в блоке except)
💡 Главное отличие: В Kotlin try — это выражение, которое возвращает значение. В Python такого нет. Это делает код на Kotlin более лаконичным, особенно при обработке ввода данных.

Типичные ошибки и подводные камни

Даже опытные программисты иногда допускают эти ошибки. Лучше знать о них заранее.

1. Слишком общий catch

// ПЛОХО: ловим всё подряд
try {
    // какой-то код
} catch (e: Exception) {
    println("Что-то пошло не так")
}
// ПРОБЛЕМА: мы не знаем, что именно пошло не так
// Может быть, это баг в нашей логике, а мы его просто скрыли

Решение: Ловить конкретные исключения. Если нужно общее — хотя бы логировать полную информацию.

2. Игнорирование исключения в catch

// ПЛОХО: пустой catch
try {
    // опасный код
} catch (e: Exception) {
    // ничего не делаем — просто "проглатываем" ошибку
}
// ПРОБЛЕМА: программа продолжает работу с неверными данными
// Пользователь думает, что всё хорошо, а на самом деле — нет

Решение: Всегда хотя бы логировать исключение или сообщать пользователю.

3. Неправильный порядок catch

// ПЛОХО: общее исключение перед конкретным
try {
    // код
} catch (e: Exception) {
    println("Общая ошибка")
} catch (e: ArithmeticException) {
    println("Деление на ноль") // Этот код НИКОГДА не выполнится!
}

Решение: Конкретные исключения — выше, общие — ниже.

4. Забыли про finally для ресурсов

// ПЛОХО: не закрываем файл при ошибке
fun readFile(path: String) {
    val reader = File(path).bufferedReader()
    val firstLine = reader.readLine() // Если здесь исключение — reader не закроется
    println(firstLine)
    reader.close()
}

Решение: Использовать finally или функцию use() (встроенная в Kotlin).

5. Злоупотребление оператором !!

// ПЛОХО: везде ставим !!
val name: String? = getUserName()
println(name!!.length) // Если name == null — программа упадёт

Решение: Использовать безопасные вызовы (?.) и оператор Elvis (?:).

6. Исключения в блоке finally

// ПЛОХО: finally может выбросить своё исключение
try {
    // код
} finally {
    throw RuntimeException("Ошибка в finally") // Перезапишет исходное исключение!
}

Решение: В finally не должно быть кода, который может выбросить исключение. Если нужно — оберни в try-catch внутри finally.

Лучшие практики и советы профессионалов

За годы работы с Kotlin сообщество выработало правила, которые делают код надёжнее и понятнее.

1. Используй try как выражение

Это делает код чище и уменьшает количество вложенных блоков.

// Вместо:
var number: Int
try {
    number = readLine()!!.toInt()
} catch (e: NumberFormatException) {
    number = 0
}

// Делай так:
val number = try {
    readLine()!!.toInt()
} catch (e: NumberFormatException) {
    0
}

2. Создавай свои исключения для бизнес-логики

Стандартные исключения (IllegalArgumentException, IllegalStateException) хороши для общих случаев. Но для специфики твоего приложения создавай свои классы.

3. Используй require и check для валидации

Это самодокументируемый код. Другие разработчики сразу увидят, какие условия должны выполняться.

4. Не используй исключения для управления потоком

// ПЛОХО: исключение как часть логики
try {
    while (true) {
        println(readLine()!!.toInt())
    }
} catch (e: NumberFormatException) {
    // Завершаем цикл при вводе не-числа
}
// ПРОБЛЕМА: исключения — дорогая операция, их не стоит использовать для обычного управления

Решение: Используй обычные условия (if, when) для управления потоком.

5. Всегда логируй исключения

Даже если ты обрабатываешь исключение и программа продолжает работу, запиши информацию о нём. Это поможет при отладке.

6. Используй use() для автоматического закрытия ресурсов

// Вместо try-catch-finally для файлов:
File("data.txt").bufferedReader().use { reader ->
    println(reader.readText())
}
// use() автоматически закроет ресурс, даже если было исключение

7. Документируй исключения в комментариях

/**
 * Делит одно число на другое.
 * @param dividend делимое
 * @param divisor делитель
 * @return результат деления
 * @throws ArithmeticException если divisor равен нулю
 * @throws IllegalArgumentException если divisor равен null
 */
fun safeDivide(dividend: Int, divisor: Int?): Int {
    requireNotNull(divisor) { "Делитель не может быть null" }
    return dividend / divisor
}

Вопросы для самопроверки

  1. Вопрос: Что произойдёт, если в блоке try возникнет исключение, но подходящий catch не будет найден?

    Ответ: Исключение «всплывёт» вверх по стеку вызовов. Если его не поймать нигде — программа аварийно завершится, и в консоль будет выведена трассировка стека.

  2. Вопрос: В каком порядке должны располагаться блоки catch?

    Ответ: От более конкретных к более общим. Сначала — исключения-наследники, потом — родительские классы. Иначе до конкретных обработчиков дело никогда не дойдёт.

  3. Вопрос: Выполнится ли блок finally, если в try или catch есть оператор return?

    Ответ: Да. finally выполняется всегда, даже перед return. Это гарантирует освобождение ресурсов.

  4. Вопрос: Чем отличается require() от check()?

    Ответ: require() выбрасывает IllegalArgumentException и используется для