Tratamento de Erros e Exceções em Swift: Um Guia Detalhado
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты — капитан космического корабля. Твой корабль летит к далёкой звезде, и вдруг на приборной панели загорается красная лампочка: «Ошибка двигателя!». Что ты будешь делать?
Вариант первый: ты паникуешь, выбегаешь в коридор и кричишь «Всё пропало!». Корабль падает, экипаж в панике. Это — необработанная ошибка. Программа просто «падает» (крашится), и пользователь видит только чёрный экран.
Вариант второй: ты спокойно смотришь на панель, нажимаешь кнопку «Переключиться на резервный двигатель», и полёт продолжается. Экипаж даже не заметил проблемы. Это — обработка ошибки. Программа перехватила проблему, сделала что-то умное и продолжила работать.
Вариант третий: ты понимаешь, что двигатель сломался окончательно, но у тебя есть аварийный план. Ты включаешь сигнал бедствия, направляешь корабль к ближайшей планете и мягко садишься. Это — корректное завершение с уведомлением пользователя.
В программировании ошибки случаются постоянно: файл не найден, интернет пропал, пользователь ввёл букву вместо числа. Хорошая программа не падает в таких ситуациях — она обрабатывает ошибки и либо исправляет их, либо сообщает пользователю, что пошло не так.
Важно: Обработка ошибок — это не про то, как писать код без ошибок. Это про то, как сделать программу устойчивой к неизбежным сбоям.
Теория: как это работает изнутри
В Swift обработка ошибок построена на четырёх ключевых понятиях: типы ошибок, бросание ошибок, ловля ошибок и очистка ресурсов. Давай разберём каждое.
Типы ошибок (Error)
В Swift ошибка — это просто значение, которое соответствует протоколу Error. Протокол — это как «удостоверение личности»: если тип данных подписан под протокол Error, Swift понимает, что это значение можно использовать как ошибку.
Обычно ошибки описывают с помощью перечислений (enum). Почему? Потому что ошибки — это конечный набор возможных проблем. Например, для автомата с газировкой ошибки могут быть такими:
- Нет денег
- Нет напитка
- Закончились стаканчики
Каждая ситуация — отдельный случай, и enum идеально подходит для этого.
Бросание ошибок (throws)
Когда функция может «сломаться», она помечается ключевым словом throws. Это как табличка на двери: «Осторожно! Здесь может произойти ошибка». Если функция помечена throws, она обязана либо вернуть результат, либо «бросить» (выбросить) ошибку.
Чтобы бросить ошибку, используется ключевое слово throw. В момент throw выполнение функции немедленно прекращается, и управление передаётся обратно вызывающему коду.
Ловля ошибок (do-catch)
Когда ты вызываешь функцию, которая может бросить ошибку, ты должен быть готов её поймать. Для этого используется конструкция do-catch. Внутри блока do ты вызываешь «опасную» функцию, а в catch описываешь, что делать с ошибкой.
Если ты не хочешь обрабатывать ошибку, можно использовать try? (получить nil в случае ошибки) или try! (сказать «я уверен, что ошибки не будет», но если она случится — программа упадёт).
Очистка ресурсов (defer)
Представь, что ты открыл дверь, зашёл в комнату, а потом забыл её закрыть. В программировании это называется «утечка ресурсов». Блок defer гарантирует, что какой-то код выполнится в любом случае — даже если произошла ошибка. Это как автоматический доводчик двери.
Синтаксис в Swift: все формы и варианты
Давай разберём все элементы синтаксиса по порядку. Вот полная таблица всех конструкций:
| Конструкция | Назначение | Пример |
|---|---|---|
enum ... : Error |
Объявление типа ошибки | enum FileError: Error { case notFound } |
func ... throws |
Функция, которая может бросить ошибку | func readFile() throws -> String |
throw |
Бросание ошибки | throw FileError.notFound |
do { ... } catch |
Ловля ошибок | do { try readFile() } catch { print(error) } |
try |
Вызов бросающей функции | try readFile() |
try? |
Игнорирование ошибки (возвращает nil) | let result = try? readFile() |
try! |
Принудительный вызов (опасно!) | let result = try! readFile() |
defer |
Гарантированное выполнение кода | defer { closeFile() } |
catch let error as Type |
Ловля ошибки определённого типа | catch let error as FileError |
catch where |
Ловля с условием | catch where error == .notFound |
Подробный разбор do-catch
Конструкция do-catch может иметь несколько блоков catch. Swift проверяет их по порядку и выполняет первый подходящий. Если ни один не подошёл, ошибка «пролетает» дальше — в вызывающую функцию.
do {
// Пробуем выполнить опасный код
let result = try dangerousFunction()
print("Успех: (result)")
} catch ErrorType.specificError {
// Ловим конкретную ошибку
print("Случилась конкретная проблема")
} catch {
// Ловим любую другую ошибку
print("Что-то пошло не так: (error)")
}
Варианты try
У try есть три формы, и важно понимать разницу:
| Форма | Поведение при ошибке | Когда использовать |
|---|---|---|
try |
Ошибка передаётся в catch | Когда нужно обработать ошибку |
try? |
Возвращает nil | Когда ошибка не критична |
try! |
Программа падает (crash) | Только когда 100% уверен |
Предупреждение: try! — это как прыжок с парашютом без проверки, раскрылся ли он. Используй только в учебных примерах или когда точно знаешь, что ошибки быть не может.
Примеры кода — от простого к сложному
Пример 1: Простейшая ошибка — деление на ноль
// Создаём тип ошибки для математических операций
enum MathError: Error {
case divisionByZero // Ошибка: деление на ноль
}
// Функция безопасного деления
func safeDivide(_ a: Double, _ b: Double) throws -> Double {
// Проверяем, не равен ли делитель нулю
guard b != 0 else {
// Если равен — бросаем ошибку
throw MathError.divisionByZero
}
// Если всё хорошо — возвращаем результат
return a / b
}
// Пробуем разделить
do {
let result = try safeDivide(10, 0) // Попытка деления на ноль
print("Результат: (result)")
} catch {
print("Ошибка: деление на ноль!")
}
// Вывод: Ошибка: деление на ноль!
Объяснение: Мы создали перечисление MathError с одним случаем — деление на ноль. Функция safeDivide помечена throws, потому что может не сработать. Внутри мы проверяем условие и бросаем ошибку. В do-catch мы ловим эту ошибку и выводим сообщение.
Пример 2: Несколько типов ошибок
// Ошибки для банкомата
enum BankError: Error {
case insufficientFunds(needed: Double) // Не хватает денег
case invalidAmount // Неверная сумма
case accountFrozen // Счёт заморожен
}
struct BankAccount {
var balance: Double
var isFrozen: Bool
// Метод может бросить ошибку
mutating func withdraw(_ amount: Double) throws {
// Проверка: счёт не заморожен?
guard !isFrozen else {
throw BankError.accountFrozen
}
// Проверка: сумма положительная?
guard amount > 0 else {
throw BankError.invalidAmount
}
// Проверка: хватает ли денег?
guard amount <= balance else {
throw BankError.insufficientFunds(needed: amount - balance)
}
// Если все проверки пройдены — снимаем деньги
balance -= amount
print("Снято (amount). Остаток: (balance)")
}
}
// Использование
var account = BankAccount(balance: 1000, isFrozen: false)
do {
try account.withdraw(1500) // Пытаемся снять больше, чем есть
} catch BankError.insufficientFunds(let needed) {
print("Не хватает (needed) рублей")
} catch BankError.invalidAmount {
print("Сумма должна быть положительной")
} catch BankError.accountFrozen {
print("Счёт заморожен")
} catch {
print("Неизвестная ошибка: (error)")
}
// Вывод: Не хватает 500.0 рублей
Объяснение: Здесь у нас три разных типа ошибок. В блоке catch мы можем ловить каждую отдельно и давать пользователю конкретное сообщение. Обрати внимание на insufficientFunds(needed: Double) — ошибка может содержать дополнительные данные.
Пример 3: Использование try? для игнорирования ошибки
// Функция, которая может не сработать
func parseNumber(from text: String) throws -> Int {
guard let number = Int(text) else {
throw NSError(domain: "Parsing", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Не число"])
}
return number
}
// Используем try? — если ошибка, получим nil
let result1 = try? parseNumber(from: "123") // Optional(123)
let result2 = try? parseNumber(from: "abc") // nil
// Удобно для проверки с guard
guard let number = try? parseNumber(from: "42") else {
print("Не удалось распарсить")
return
}
print("Число: (number)") // Вывод: Число: 42
Объяснение: try? превращает ошибку в nil. Это удобно, когда мы не хотим писать целый блок do-catch, а просто хотим проверить, получилось ли что-то. В сочетании с guard это даёт очень чистый код.
Пример 4: Defer — гарантированная очистка
// Функция работы с файлом
func processFile(named filename: String) throws {
print("Открываем файл: (filename)")
// defer выполнится в любом случае — и при успехе, и при ошибке
defer {
print("Закрываем файл: (filename)")
}
// Проверка: существует ли файл?
guard filename.hasSuffix(".txt") else {
throw NSError(domain: "File", code: 404,
userInfo: [NSLocalizedDescriptionKey: "Файл не найден"])
}
print("Обрабатываем файл...")
// Здесь могла бы быть реальная обработка
print("Файл обработан успешно")
}
// Тест 1: успешная обработка
do {
try processFile(named: "data.txt")
} catch {
print("Ошибка: (error)")
}
// Вывод:
// Открываем файл: data.txt
// Обрабатываем файл...
// Файл обработан успешно
// Закрываем файл: data.txt
print("---")
// Тест 2: ошибка
do {
try processFile(named: "data.pdf")
} catch {
print("Ошибка: (error)")
}
// Вывод:
// Открываем файл: data.pdf
// Закрываем файл: data.pdf
// Ошибка: Файл не найден
Объяснение: defer — это блок, который выполняется перед выходом из функции, независимо от того, произошла ошибка или нет. В нашем примере файл закрывается в любом случае. Это критически важно для работы с файлами, сетью, базами данных — любыми ресурсами, которые нужно освобождать.
Пример 5: Цепочка бросающих функций
// Ошибки для работы с пользователями
enum UserError: Error {
case notFound
case banned
case underage
}
// Функция поиска пользователя
func findUser(id: Int) throws -> String {
guard id > 0 else {
throw UserError.notFound
}
return "Пользователь (id)"
}
// Функция проверки возраста
func checkAge(user: String, age: Int) throws {
guard age >= 18 else {
throw UserError.underage
}
print("(user) прошёл проверку возраста")
}
// Функция проверки бана
func checkBan(user: String) throws {
let bannedUsers = ["Пользователь 666"]
guard !bannedUsers.contains(user) else {
throw UserError.banned
}
print("(user) не в бане")
}
// Объединённая функция регистрации
func registerUser(id: Int, age: Int) throws {
// Цепочка вызовов — каждая функция может бросить ошибку
let user = try findUser(id: id)
try checkAge(user: user, age: age)
try checkBan(user: user)
print("Пользователь зарегистрирован!")
}
// Пробуем зарегистрировать
do {
try registerUser(id: 1, age: 20)
} catch UserError.notFound {
print("Пользователь не найден")
} catch UserError.underage {
print("Пользователь слишком молод")
} catch UserError.banned {
print("Пользователь забанен")
} catch {
print("Неизвестная ошибка")
}
// Вывод:
// Пользователь 1 прошёл проверку возраста
// Пользователь 1 не в бане
// Пользователь зарегистрирован!
Объяснение: Функции могут вызывать друг друга, и ошибка «всплывает» наверх. В registerUser мы вызываем три функции, каждая из которых может бросить ошибку. Если любая из них упадёт, выполнение прервётся, и мы попадём в соответствующий catch.
Пример 6: Обработка ошибок в замыканиях
// Функция, которая принимает замыкание, которое может бросить ошибку
func performOperation(_ operation: () throws -> Void) {
do {
try operation()
print("Операция выполнена успешно")
} catch {
print("Операция провалилась: (error)")
}
}
// Простое замыкание без ошибки
performOperation {
print("Простое действие")
}
// Вывод: Простое действие
// Вывод: Операция выполнена успешно
// Замыкание с ошибкой
performOperation {
throw NSError(domain: "Test", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Тестовая ошибка"])
}
// Вывод: Операция провалилась: Тестовая ошибка
// Более сложный пример — работа с массивом
let numbers = ["1", "2", "три", "4"]
performOperation {
for num in numbers {
// Если не число — бросим ошибку
guard let _ = Int(num) else {
throw NSError(domain: "Array", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Элемент '(num)' не число"])
}
}
}
// Вывод: Операция провалилась: Элемент 'три' не число
Объяснение: Замыкания (closures) тоже могут бросать ошибки. В этом примере функция performOperation принимает замыкание, которое может бросить ошибку, и сама обрабатывает её внутри. Это удобно для создания гибких API.
Пример 7: Собственный тип ошибки с вложенными случаями
// Сложная иерархия ошибок для сетевого запроса
enum NetworkError: Error {
case connectionFailed(reason: String)
case timeout(seconds: Int)
case serverError(code: Int, message: String)
case noData
// Человекочитаемое описание
var description: String {
switch self {
case .connectionFailed(let reason):
return "Ошибка соединения: (reason)"
case .timeout(let seconds):
return "Таймаут через (seconds) секунд"
case .serverError(let code, let message):
return "Ошибка сервера (code): (message)"
case .noData:
return "Сервер не вернул данные"
}
}
}
// Функция имитации сетевого запроса
func fetchData(from url: String) throws -> String {
// Имитация разных ошибок
if url.isEmpty {
throw NetworkError.connectionFailed(reason: "Пустой URL")
}
if url == "timeout" {
throw NetworkError.timeout(seconds: 30)
}
if url.hasPrefix("error") {
throw NetworkError.serverError(code: 500, message: "Internal Server Error")
}
if url == "empty" {
throw NetworkError.noData
}
return "Данные с (url)"
}
// Обработка с использованием description
do {
let data = try fetchData(from: "error_test")
print(data)
} catch let error as NetworkError {
print("Сетевая ошибка: (error.description)")
} catch {
print("Неизвестная ошибка: (error)")
}
// Вывод: Сетевая ошибка: Ошибка сервера 500: Internal Server Error
// Использование switch для детальной обработки
do {
let data = try fetchData(from: "timeout")
print(data)
} catch let error as NetworkError {
switch error {
case .timeout(let seconds):
print("Подождали (seconds) секунд и отменили запрос")
case .serverError(let code, _):
if code == 503 {
print("Сервер временно недоступен, пробуем позже")
} else {
print("Критическая ошибка сервера")
}
default:
print(error.description)
}
}
// Вывод: Подождали 30 секунд и отменили запрос
Объяснение: Здесь мы создали полноценный тип ошибки с описанием и возможностью детальной обработки через switch. Это профессиональный подход — каждая ошибка содержит достаточно информации, чтобы программа могла принять правильное решение.
Сравнение с Python: чем похоже и чем отличается
Если ты уже знаком с Python, тебе будет проще понять Swift. Давай сравним подходы:
| Аспект | Swift | Python |
|---|---|---|
| Объявление ошибки | Любой тип, подписанный под Error (обычно enum) | Класс, наследующий от Exception |
| Бросание ошибки | throw |
raise |
| Пометка функции | func foo() throws — обязательно |
Не требуется, любая функция может бросить |
| Ловля ошибки | do { try ... } catch |
try: ... except |
| Игнорирование ошибки | try? (возвращает nil) |
pass в except (не рекомендуется) |
| Гарантированная очистка | defer |
finally |
| Типизация ошибок | Строгая — компилятор проверяет | Динамическая — ошибка может быть любой |
| Производительность | Высокая — без накладных расходов | Есть накладные расходы на try-except |
Ключевые отличия
1. Обязательность throws. В Swift функция, которая может бросить ошибку, обязана это указать. В Python любая функция может бросить любую ошибку в любой момент. Это делает Swift более безопасным — ты всегда знаешь, откуда может прилететь ошибка.
2. Типизация ошибок. В Swift ошибки типизированы. Ты можешь ловить конкретные типы ошибок, и компилятор проверит, что ты ничего не пропустил. В Python ошибки — это просто объекты классов, и ты можешь поймать всё подряд с помощью except Exception.
3. Производительность. В Swift бросание ошибки — это не создание объекта в куче (heap), а просто возврат специального значения. Это очень быстро. В Python создание исключения — дорогая операция.
Совет: Если ты переходишь с Python на Swift, запомни главное правило: в Swift ты всегда должен явно обрабатывать ошибки или помечать функцию как throws. Компилятор не даст тебе «забыть» про ошибку.
Типичные ошибки и подводные камни
1. Забыл обработать ошибку
// Ошибка: вызов throws функции без try
let result = readFile() // Ошибка компиляции!
// Правильно:
let result = try readFile()
Почему это происходит: Swift требует явного указания try при вызове бросающей функции. Это сделано специально, чтобы ты не пропустил потенциальную ошибку.
2. Использование try! без уверенности
// Опасный код
let data = try! String(contentsOf: URL(string: "https://example.com")!)
// Если сайт недоступен — программа упадёт!
// Безопасная альтернатива
guard let data = try? String(contentsOf: url) else {
print("Не удалось загрузить данные")
return
}
3. Пустой catch без обработки
do {
try dangerousFunction()
} catch {
// Пустой catch — ошибка проглочена!
// Пользователь не узнает о проблеме
}
// Правильно: хотя бы логировать
do {
try dangerousFunction()
} catch {
print("Ошибка: (error)")
// Или показать пользователю
}
4. Неправильный порядок catch
do {
try someFunction()
} catch {
print("Любая ошибка")
} catch SpecificError.something {
// Этот блок никогда не выполнится!
// Общий catch должен быть последним
}
5. Забыл про defer при работе с ресурсами
// Плохо: файл не закроется при ошибке
func readFileBad() throws -> String {
let file = openFile() // Открыли файл
let data = try processFile(file) // Если ошибка — файл останется открыт
closeFile(file)
return data
}
// Хорошо: defer гарантирует закрытие
func readFileGood() throws -> String {
let file = openFile()
defer { closeFile(file) } // Закроется в любом случае
return try processFile(file)
}
6. Слишком общий catch
// Плохо: ловим всё подряд
do {
try operation()
} catch {
// Непонятно, что пошло не так
showError("Что-то пошло не так")
}
// Хорошо: конкретные ошибки
do {
try operation()
} catch NetworkError.timeout {
showError("Проверьте интернет")
} catch NetworkError.serverError {
showError("Сервер временно недоступен")
} catch {
showError("Неизвестная ошибка: (error)")
}
Лучшие практики и советы профессионалов
1. Создавай свои типы ошибок
Не используй встроенные типы вроде NSError для своих ошибок. Создавай перечисления с понятными названиями. Это делает код самодокументируемым.
// Плохо
throw NSError(domain: "MyApp", code: 1, userInfo: nil)
// Хорошо
enum ValidationError: Error {
case emptyField
case invalidEmail
case passwordTooShort(minLength: Int)
}
2. Используй associated values для дополнительной информации
Ошибки могут содержать данные: что именно пошло не так, какие значения ожидались, что было получено.
enum LoginError: Error {
case wrongCredentials(username: String)
case accountLocked(minutesLeft: Int)
case tooManyAttempts(maxAttempts: Int)
}
3. Не злоупотребляй try?
try? удобен, но он «проглатывает» информацию об ошибке. Используй его только когда тебе действительно всё равно, что пошло не так.
4. Всегда обрабатывай ошибки на границе системы
Ошибки должны обрабатываться там, где программа взаимодействует с внешним миром: пользовательский ввод, сетевые запросы, файловая система. Внутренние функции могут просто бросать ошибки наверх.
5. Используй defer для парных операций
Если ты что-то открываешь/создаёшь/начинаешь — обязательно закрой/удали/закончи в defer. Это железное правило.
6. Документируй бросаемые ошибки
/// Загружает данные с сервера
/// - Parameter url: URL для загрузки
/// - Throws: `NetworkError.connectionFailed` если нет соединения
/// `NetworkError.timeout` если превышен таймаут
/// `NetworkError.serverError` если сервер вернул ошибку
/// - Returns: Данные в виде строки
func fetchData(from url: URL) throws -> String {
// ...
}
7. Не используй ошибки для управления потоком
Ошибки — для исключительных ситуаций. Не бросай ошибку там, где можно просто вернуть nil или использовать Optional.
// Плохо: ошибка для обычной ситуации
func findUser(id: Int) throws -> User {
guard let user = database.find(id: id) else {
throw UserError.notFound
}
return user
}
// Хорошо: Optional для обычного отсутствия
func findUser(id: Int) -> User? {
return database.find(id: id)
}
Вопросы для самопроверки
-
Вопрос: Каким ключевым словом помечается функция, которая может бросить ошибку?
Ответ:
throws. Пример: