JavaRush /Курсы /Swift SELF /Проброс ошибки вверх — где ловить, где пробрасывать

Проброс ошибки вверх — где ловить, где пробрасывать

Swift SELF
29 уровень , 3 лекция
Открыта

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 ...) и уже там делать человеческий текст.

1
Задача
Swift SELF, 29 уровень, 3 лекция
Недоступна
Квадрат датчика
Квадрат датчика
1
Задача
Swift SELF, 29 уровень, 3 лекция
Недоступна
Деление пайплайном
Деление пайплайном
1
Задача
Swift SELF, 29 уровень, 3 лекция
Недоступна
Команда терминала
Команда терминала
1
Задача
Swift SELF, 29 уровень, 3 лекция
Недоступна
Профиль пользователя
Профиль пользователя
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ