$ sudo teach IT

Зачем указывать, какая именно ошибка может прилететь

Когда функция объявлена с обычным throws, она сообщает только одно: «здесь может что-то пойти не так». А что именно — приходится смотреть в документации или в теле функции: конкретный тип ошибки нигде не написан в сигнатуре. В большинстве случаев это не мешает, но иногда хочется получить от компилятора чёткий контракт: «эта функция бросает ошибки только вот такого типа, и никакого другого» — и чтобы компилятор сам это проверял.

Для этого в Swift есть типизированные ошибки — форма throws, где после слова в скобках указывается конкретный тип ошибки.

Синтаксис throws(E)

Когда разбирали протокол Error и обработку ошибок, функция объявлялась просто с throws. На самом деле это сокращение: полная форма — throws(any Error), то есть «бросает ошибку какого-то типа, соответствующего Error, но заранее неизвестно какого». Вместо any Error можно указать конкретный тип, если он у вас один:

enum FileError: Error {
    case notFound
}

func loadUnsafe() throws(any Error) -> Int {
    throw FileError.notFound
}

func loadStrict() throws(FileError) -> Int {
    throw .notFound
}

loadUnsafe и обычная функция с throws без уточнения — это одно и то же. А вот loadStrict объявлена так, что бросить может только FileError и ничего больше — это и есть типизированная ошибка. Обратите внимание: раз тип ошибки уже известен из сигнатуры, внутри функции можно писать сокращённо throw .notFound вместо throw FileError.notFound — совсем как с опционалами и enum, где тип выводится из контекста.

Что даёт узкий тип в catch

Главный практический эффект — в блоке catch. Когда ошибка типизирована, переменная error внутри catch имеет не общий тип any Error, а сразу конкретный enum. Значит, можно писать switch по нему без приведения типов, и компилятор проверит, что вы разобрали все случаи:

enum AgeError: Error {
    case negative
    case tooOld
}

func validate(_ age: Int) throws(AgeError) -> Int {
    if age < 0 { throw .negative }
    if age > 150 { throw .tooOld }
    return age
}

func describe(_ age: Int) -> String {
    do {
        let n = try validate(age)
        return "ok: \(n)"
    } catch {
        switch error {
        case .negative:
            return "отрицательный возраст"
        case .tooOld:
            return "слишком большой возраст"
        }
    }
}

Внутри catch переменная error уже имеет тип AgeError, поэтому switch сразу знает про оба кейса и не требует ветки default: если позже вы добавите в AgeError новый кейс, компилятор укажет именно на этот switch, и вы не забудете его обработать. С обычным throws так не получится: там error имел бы тип any Error, и пришлось бы сначала привести его к нужному enum через as?, и никакой проверки на исчерпанность вариантов компилятор бы не дал.

Когда узкий тип мешает

Расплата за строгий контракт — негибкость при совмещении разных источников ошибок. Если функция объявлена как throws(AppError), она физически не может пробросить наружу ошибку другого типа без преобразования, даже если внутри вызвала функцию с другим типизированным throws:

enum FileError: Error {
    case notFound
}

func readConfig() throws(FileError) -> String {
    throw .notFound
}

enum AppError: Error {
    case badInput
}

func run() throws(AppError) {
    let text = try readConfig() // ошибка компиляции
    print(text)
}

Компилятор откажется собирать такой код: FileError нельзя молча превратить в AppError. Чтобы это исправить, ошибку нужно явно поймать и преобразовать:

enum AppError: Error {
    case badInput
    case file(FileError)
}

func run() throws(AppError) {
    do {
        let text = try readConfig()
        print(text)
    } catch {
        throw .file(error)
    }
}

Это лишний код ради каждого источника ошибок, и чем больше в проекте разных типизированных throw-функций, вызывающих друг друга, тем больше таких обёрток приходится писать. Поэтому большая часть стандартной библиотеки Swift и типичного прикладного кода по-прежнему использует обычный throws — и это осознанный выбор, а не недосмотр. Типизированные ошибки лучше всего подходят для узких, самодостаточных функций с одним понятным набором проблем: разбор конкретного формата, валидация конкретной формы данных — там, где заранее известно, что других ошибок не будет и не появится.

rethrows: кратко

Отдельно стоит слово rethrows — оно не задаёт тип ошибки, а говорит другое: «эта функция бросает ошибку, только если её бросит переданное ей замыкание». rethrows часто встречается у функций высшего порядка вроде map, которые сами по себе ничего не бросают, но принимают потенциально бросающее замыкание:

func applyTwice(_ value: Int, _ transform: (Int) throws -> Int) rethrows -> Int {
    let first = try transform(value)
    return try transform(first)
}

Если передать в applyTwice обычное непробрасывающее замыкание, вызывать результат можно без try. А если замыкание способно бросить ошибку — вызов тоже придётся пометить try. rethrows не связан с типизированными ошибками напрямую и типа ошибки не сообщает: он лишь избавляет от необходимости писать отдельно версию функции для бросающих и небросающих замыканий.

Частые ошибки

  • Пытаться пробросить ошибку чужого типа из функции с узким throws(E) напрямую, без do/catch и явного преобразования — компилятор такой код не примет.
  • Расставлять throws(E) везде подряд «для строгости», хотя функция вызывает несколько разных источников ошибок — тогда обёрток становится больше, чем пользы от точного типа.
  • Думать, что throws(E) — это что-то совсем другое от привычного throws: на самом деле обычный throws — это просто throws(any Error), а не отдельный, более слабый механизм.
  • Путать rethrows с типизированными ошибками: rethrows говорит о том, бросает ли функция ошибку вообще (в зависимости от переданного замыкания), а не о том, какого она типа.
  • Забывать про сокращённый синтаксис throw .случай внутри типизированной функции и продолжать писать полное имя типа ошибки — лишний, но безобидный код.

Резюме

  • throws(E) сообщает компилятору и другим разработчикам, что функция бросает ошибки только конкретного типа E, а не произвольную ошибку.
  • Обычный throws — это сокращение для throws(any Error), а не отдельный от типизированных ошибок механизм.
  • Внутри catch для типизированной ошибки переменная error сразу имеет конкретный тип enum, поэтому switch по ней проверяется на полноту без приведения типов.
  • Функция с узким throws(E) не может молча пробросить ошибку другого типа — её нужно поймать и явно преобразовать, и это может добавить лишнего кода при смешивании нескольких источников ошибок.
  • rethrows — отдельная вещь: она означает «бросает, только если бросит переданное замыкание», и типа ошибки не уточняет.

Проверьте себя

3 вопроса

Проверка пароля с типизированной ошибкой

Реализуйте функцию checkPassword, которая проверяет пароль и типизированно сообщает, что с ним не так.

Тип ошибки PasswordError уже объявлен в заготовке с двумя кейсами: tooShort и noDigit.

Реализуйте func checkPassword(_ password: String) throws(PasswordError) -> Int:

  • если в пароле меньше 6 символов — бросьте .tooShort;
  • если символов достаточно, но среди них нет ни одной цифры — бросьте .noDigit;
  • если оба условия выполнены (символов достаточно и есть цифра) — верните длину пароля.

Если пароль одновременно и короткий, и без цифр, важен порядок проверки: сначала проверяйте длину, и только затем наличие цифры.