Зачем указывать, какая именно ошибка может прилететь
Когда функция объявлена с обычным 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; - если оба условия выполнены (символов достаточно и есть цифра) — верните длину пароля.
Если пароль одновременно и короткий, и без цифр, важен порядок проверки: сначала проверяйте длину, и только затем наличие цифры.