1. Зачем решать: ловить или пробрасывать
Если вы только начинаете, очень хочется сделать так: «Ой, тут ошибка — сейчас я её поймаю, напечатаю что-нибудь и пойду дальше». И иногда это даже работает… ровно до момента, когда программа становится больше десяти строк, а «что-нибудь» печатается в пяти местах разными словами. В результате пользователь видит странные сообщения, разработчик не понимает, где именно всё сломалось, а сама ошибка теряет смысл.
В этой лекции мы выработаем спокойную модель: ошибка поднимается вверх по вызовам, пока не дойдёт до места, где есть смысл и контекст её обработать. И только там мы принимаем решение: показать сообщение, попросить повторить ввод, прервать сценарий или выбрать запасной путь.
Как работает проброс по стеку вызовов
Когда функция делает throw, это не «вернула ошибку как значение». Это буквально означает: «я прекращаю работу прямо сейчас, и управление возвращается наверх по цепочке вызовов». Все промежуточные функции, которые не перехватывают ошибку, тоже прекращают выполнение, как будто по ним прошёлся невидимый «лифтовый трос» и поднял вас на этаж выше. Это и есть проброс (propagation).
Чтобы увидеть это не как магию, удобно держать в голове простую схему:
flowchart TD
A["runLoop() верхний уровень"] --> B["handleLine(_:)"]
B --> C["parseCommand(_:)"]
B --> D["execute(_:)"]
D --> E["makeBook(_:)"]
E -- "throw BookError" --> D
D -- "throws дальше" --> B
B -- "throws дальше" --> A
A --> F["do/catch ловит"]
Ключевой момент: если где-то внизу случилась ошибка, то код после try в текущей функции не выполнится. Поэтому ошибку нельзя трактовать как «ну не получилось, но я продолжу как ни в чём не бывало». Если вы продолжаете — значит вы обработали ошибку, а не пробросили.
2. Где ловить ошибки: правило контекста
Главное практическое правило дня звучит так: обрабатывать ошибку стоит там, где вы можете принять осмысленное решение. Если функция внизу делает «маленькую техническую штуку» (парсит число, создаёт модель, проверяет формат), у неё обычно нет права решать, что показывать пользователю. Она не знает сценарий, не знает, можно ли повторить ввод, не знает, какие есть альтернативы. Поэтому внизу чаще всего честнее сказать: «я не справилась — вот причина» и бросить ошибку.
А вот ближе к «границе сценария» (например, в обработке команды пользователя) контекст уже есть. Там вы знаете, что делаете: это интерактивный ввод, можно попросить повторить, можно показать подсказку, можно аккуратно завершить программу. Именно поэтому часто получается красивая архитектура даже без сложных слов: внутренние функции бросают, внешний слой ловит.
Чтобы закрепить идею, держите в голове «фразу-проверку»: «Если я поймаю ошибку здесь — я точно знаю, что дальше делать?» Если ответ «ммм… ну выведу что-нибудь» — значит, скорее всего, ловить рано.
Таблица-подсказка: ловим или пробрасываем
Когда вы пишете учебные примеры, кажется, что можно ловить где угодно — всё равно программа маленькая. Но если вы начнёте развивать приложение, очень быстро появится хаос: ошибки теряются, сообщения дублируются, а часть кода молча «глотает» проблему. Чтобы этого избежать, полезно держать маленькую табличку-подсказку.
| Место в коде | Обычно делаем | Почему |
|---|---|---|
| Низкоуровневая функция (парсинг числа, создание модели) | Пробрасываем (throws) | Там нет UX-контекста и политики реакции |
| Средний слой (выполнение команды) | Чаще пробрасываем, иногда переводим | Можно добавить контекст, но не всегда надо решать судьбу сценария |
| Верхняя точка сценария (цикл ввода, обработчик команды) | Ловим (do/catch) и решаем | Тут понятно, что показать и можно ли продолжать |
Если хотите совсем короткое правило: ловим у границы, пробрасываем внутри. Граница — это место, где ваш код «разговаривает» с внешним миром (пользователь, файл, сеть… но сеть и файлы мы сегодня не трогаем).
4. Пример: консольная библиотека
Чтобы разговор не был теоретическим, сделаем маленькое консольное приложение, которое хранит список книг в памяти. Команды будут простые: add, list, help, quit. Да, это ещё не «настоящий CLI-парсер», но нам сейчас важно не это — нам важно, чтобы ошибки могли появляться на разных уровнях и красиво подниматься вверх.
Модель и типы ошибок
Сначала определим модель и ошибки. Заметьте: ошибки — это enum, и они максимально «разделяют причины». Не «failed», а конкретика.
import Foundation
struct Book {
let title: String
let year: Int
}
enum CommandError: Error {
case emptyLine
case unknownCommand(name: String)
case missingArgument(name: String)
}
enum BookError: Error {
case emptyTitle
case invalidYearText(input: String)
}
Команда как тип, а не строка
Теперь добавим команды. Здесь удобно использовать enum (вы уже проходили enum + associated values), потому что команда — это «типизированный кусок смысла», а не просто строка.
enum Command {
case add(title: String, yearText: String)
case list
case help
case quit
}
Парсинг команды: либо команда, либо ошибка
И вот здесь начинается тема лекции: парсинг команды (parseCommand) не печатает, не спрашивает пользователя заново, не решает судьбу мира. Он либо возвращает команду, либо бросает ошибку — и всё.
func parseCommand(_ line: String) throws -> Command {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw CommandError.emptyLine }
let parts = trimmed.split(separator: " ")
let name = String(parts[0])
switch name {
case "list": return .list
case "help": return .help
case "quit": return .quit
case "add":
guard parts.count >= 3 else { throw CommandError.missingArgument(name: "title/year") }
return .add(title: String(parts[1]), yearText: String(parts[2]))
default:
throw CommandError.unknownCommand(name: name)
}
}
Да, тут парсер слегка «туповат» (название книги без пробелов), но это нормально: мы развиваем приложение постепенно. Наш сегодняшний фокус — не кавычки и токенизация, а проброс ошибок.
Создание книги: чистая функция без общения с пользователем
Следующий слой — выполнение команды. Здесь у нас появится функция, которая может создать Book. Создание книги потенциально «ломается» на неверном годе или пустом названии. Вопрос: где это обрабатывать? Если мы сейчас внутри makeBook начнём печатать «Введите год заново», то мы внезапно превратим функцию создания модели в UI-слой. А потом захотим переиспользовать её в другом месте — и она будет разговаривать с пользователем сама. Это как тостер, который не просто жарит хлеб, а ещё и комментирует ваш выбор диеты.
Поэтому makeBook бросает ошибки, а решение оставляет выше.
func makeBook(title: String, yearText: String) throws -> Book {
guard !title.isEmpty else { throw BookError.emptyTitle }
guard let year = Int(yearText) else {
throw BookError.invalidYearText(input: yearText)
}
return Book(title: title, year: year)
}
Функции «середины»: пробрасывают, а не обязаны ловить
Теперь выполнение команды. Здесь мы тоже можем пока не ловить — просто пробросить дальше. Потому что «выполнить команду» — это ещё не место, где мы точно знаем UX-реакцию. Часто да, но давайте сделаем красиво: пусть execute будет чистой логикой, а весь пользовательский вывод — в одном месте.
func execute(_ command: Command, books: inout [Book]) throws {
switch command {
case .add(let title, let yearText):
let book = try makeBook(title: title, yearText: yearText)
books.append(book)
case .list:
for b in books { print("\(b.title) (\(b.year))") }
case .help:
print("Команды: add <title> <year>, list, help, quit")
case .quit:
break
}
}
Обратите внимание на важную деталь: тут try makeBook(...) может бросить BookError, а execute не ловит. Значит execute тоже должен быть throws. Это и есть «проброс вверх» на практике.
Верхняя точка сценария: один понятный do/catch
Теперь мы пришли к месту, которое обычно и является правильной точкой обработки: цикл общения с пользователем. Здесь у нас есть контекст: мы знаем, что ввод интерактивный, что можно показать подсказку, что можно продолжить цикл, а при quit завершить программу. Именно здесь do/catch выглядит естественно: это точка, где мы превращаем ошибку в понятную реакцию.
var books: [Book] = []
while true {
print("> ", terminator: "")
let line = readLine() ?? ""
do {
let command = try parseCommand(line)
if case .quit = command { break }
try execute(command, books: &books)
} catch {
print("Ошибка: \(error)")
}
}
Это уже рабочая версия. Но сейчас вывод "Ошибка: \(error)" будет не самым дружелюбным, потому что error печатается как системное описание типа. В этом месте мы как раз можем сделать ветвление по причинам — и вот тут начинается «вкусная часть»: именно на верхнем уровне удобно различать CommandError и BookError.
do {
let command = try parseCommand(line)
if case .quit = command { break }
try execute(command, books: &books)
} catch let e as CommandError {
print("Команда не принята: \(e)")
} catch let e as BookError {
print("Книга не добавлена: \(e)")
} catch {
print("Неожиданная ошибка: \(error)")
}
Здесь уже видно правило: сначала ловим ожидаемые ошибки конкретных типов, а в конце — общий catch как страховка.
Небольшая ремарка: в Swift можно писать несколько patterns в одном catch через запятую (если это помогает сделать код чище). Эта возможность описана в спецификации языка и работает как «или-или» по шаблонам.
Перевод ошибки при пробросе
Иногда проброс — это не просто «подняли ошибку наверх как есть». Бывает, что нижний уровень бросает слишком «техническую» ошибку, а верхний уровень мыслит предметно. Например, у вас есть функция parseInt, которая бросает ParseIntError.notANumber, но на уровне домена вам важнее говорить «неверный год книги». В таких случаях делают перевод ошибки: ловят одну и бросают другую, более уместную для текущего слоя, не теряя контекст.
Сделаем маленький пример: выделим парсер числа отдельно, а в makeBook переведём ошибку в BookError.
enum ParseIntError: Error {
case notANumber(input: String)
}
func parseInt(_ text: String) throws -> Int {
guard let value = Int(text) else { throw ParseIntError.notANumber(input: text) }
return value
}
А теперь перевод внутри makeBook. Мы не делаем вид, что ошибки не было — мы просто «упаковываем» её в понятный контекст.
func makeBook(title: String, yearText: String) throws -> Book {
guard !title.isEmpty else { throw BookError.emptyTitle }
do {
let year = try parseInt(yearText)
return Book(title: title, year: year)
} catch ParseIntError.notANumber(let input) {
throw BookError.invalidYearText(input: input)
}
}
Почему это относится к теме «где ловить»? Потому что это пример осмысленного места ловли. Здесь, внутри makeBook, у нас уже появился контекст: мы точно знаем, что это не «любое число», а именно год из команды add. Перевод ошибки делает верхний уровень проще: ему не нужно знать, что где-то была ParseIntError.
5. Типичные ошибки при пробросе ошибок вверх
Проброс ошибок кажется простым («ну добавлю throws и готово»), но на практике новички наступают на одни и те же грабли. И, как обычно, грабли победить нельзя — можно только договориться с ними о расписании.
Ошибка №1: ловить ошибку слишком рано и делать print внутри «утилит».
Когда parseCommand или makeBook печатает сообщения пользователю, вы смешиваете слои ответственности. Потом вы захотите переиспользовать эту функцию в другом сценарии (например, в тестах или в другом интерфейсе), а она продолжит печатать тексты в консоль, и вы будете ловить призраков в логах. Лучше пусть утилита бросает ошибку, а печать будет в одном месте.
Ошибка №2: превращать проброс в «проброс без смысла» — throws на всех функциях подряд.
Иногда студент видит, что ошибка может прилететь снизу, и добавляет throws в каждую функцию «на всякий случай». В итоге половина проекта становится throwing без причины, а читать сигнатуры тяжело. throws должен быть честным контрактом: функция реально может завершиться ошибкой, и вызывающий код обязан это учитывать.
Ошибка №3: «съесть» ошибку в пустом catch и продолжить работу как ни в чём не бывало.
Когда вы пишете catch { }, вы фактически говорите: «мне всё равно, что сломалось». Это почти никогда не то, что нужно, особенно в учебном коде. Даже если вы хотите продолжить сценарий, лучше показать понятное сообщение или хотя бы оставить след, иначе программа станет непредсказуемой.
Ошибка №4: переводить ошибку так, что теряется контекст.
Если вы ловите notANumber(input: "19O1") и бросаете просто .invalidYear, вы выбрасываете ценную информацию: что именно ввёл пользователь. Перевод ошибки должен помогать: сохраняйте associated values (например, input) или добавляйте новые детали, иначе диагностика ухудшается.
Ошибка №5: ловить «всё подряд» одним общим catch, хотя нужны разные реакции.
Общий catch полезен как страховка, но если вы одинаково реагируете на «неизвестная команда» и «неверный год», пользователь будет получать одно и то же сообщение на разные ситуации. В местах, где реально важны разные ветки поведения, лучше ловить конкретные ошибки (через catch let e as ...) и уже там делать человеческий текст.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ