Swift 错误处理
程序运行中总会遇到预期之外的情况:文件不存在、网络断开、用户输入格式不对。
Swift 用一套显式的错误处理机制来描述这些可恢复的失败,把「这个操作可能失败」直接写进函数签名,让编译器帮你盯住每一个调用点。
本文覆盖 Error 协议、throw / throws / try / do-catch、try? / try!、defer、rethrows 与 Result,最后说明什么时候该用错误、什么时候用可选类型就够了。
为什么需要错误处理
没有错误处理机制时,函数通常只能靠特殊返回值来报告失败,例如返回 -1、空字符串或者 nil。
这种做法的问题在于:调用方很容易忘记检查,而且「为什么失败」的信息在传递过程中丢掉了。
Swift 的做法是把失败写进函数签名,调用方必须用 try 明确表态,编译器负责检查你是否处理了这个错误。
| 报告失败的方式 | 失败时返回什么 | 调用方需要做什么 | 适用场景 |
|---|---|---|---|
| 特殊返回值 | 约定的哨兵值,如 -1 | 自己记得检查 | 失败含义单一,且哨兵值不会与正常结果冲突 |
| 可选类型 | nil | 用 if let 或 ?? 解包 | 只关心「有没有值」,不关心失败原因 |
| throws 错误 | 抛出一个遵循 Error 的值 | 用 try 处理或继续传播 | 失败原因对调用方有意义,且可能恢复 |
Error 协议与自定义错误类型
Swift 中的错误类型必须遵循 Error 协议。
这个协议没有任何必须实现的要求,它只是一个标记,表示这个类型可以当作错误抛出。
最常用的定义方式是用枚举,因为枚举可以把所有可能的失败原因清楚列出来,再用 switch 或 catch 逐一处理。
用枚举定义错误
下面的例子定义了一个网络错误类型,并用 throws 标记可能失败的函数。
实例
// 遵循 Error 协议即可成为错误类型
enum NetworkError: Error {
case invalidURL(String) // 关联值:出错的地址
case timeout(seconds: Int) // 关联值:超时的秒数
case serverError(code: Int) // 关联值:HTTP 状态码
}
// 可能失败的函数用 throws 标记
func fetchData(from url: String) throws -> String {
if url.isEmpty {
throw NetworkError.invalidURL(url)
}
if url.contains("timeout") {
throw NetworkError.timeout(seconds: 30)
}
return "RUNOOB 首页数据"
}
do {
let data = try fetchData(from: "https://www.runoob.com")
print("获取成功:\(data)")
} catch {
print("获取失败:\(error)")
}
运行结果:
获取成功:RUNOOB 首页数据
函数签名里的 throws 写在参数列表之后、返回箭头之前。
它告诉编译器:调用这个函数必须写 try,否则编译不通过。
带关联值的错误与模式匹配
枚举的关联值让错误可以携带上下文信息,例如出错的具体地址、超时秒数、状态码。
在 catch 中可以用和 switch 一样的模式匹配语法把这些信息取出来。
实例
enum NetworkError: Error {
case invalidURL(String)
case timeout(seconds: Int)
case serverError(code: Int)
}
func fetchData(from url: String) throws -> String {
if url.isEmpty {
throw NetworkError.invalidURL(url)
}
if url.contains("timeout") {
throw NetworkError.timeout(seconds: 30)
}
return "RUNOOB 首页数据"
}
// 按错误类型分别捕获,注意顺序:先具体后笼统
func check(_ url: String) {
do {
let data = try fetchData(from: url)
print("成功:\(data)")
} catch NetworkError.invalidURL(let value) {
print("地址为空:\"\(value)\"")
} catch NetworkError.timeout(let seconds) {
print("超时 \(seconds) 秒")
} catch NetworkError.serverError(let code) {
print("服务器错误,状态码 \(code)")
} catch {
print("其他错误:\(error)")
}
}
check("")
check("https://www.runoob.com/timeout")
check("https://www.runoob.com")
运行结果:
地址为空:"" 超时 30 秒 成功:RUNOOB 首页数据
最后一个不带模式的 catch 相当于 switch 里的 default,用来兜底。如果没有它,Swift 会认为你的 catch 没有覆盖所有错误,编译报错。
用 LocalizedError 提供可读文字
直接打印一个错误值,得到的是 invalidURL("") 这种调试形式,并不适合展示给用户。
遵循 LocalizedError 并实现 errorDescription,就能让 localizedDescription 返回一句人类可读的说明。
实例
enum RunoobError: LocalizedError {
case notFound(name: String)
// 提供给用户看的描述文字
var errorDescription: String? {
switch self {
case .notFound(let name):
return "找不到页面:\(name)"
}
}
}
do {
throw RunoobError.notFound(name: "swift-tutorial")
} catch {
print(error.localizedDescription)
}
运行结果:
找不到页面:swift-tutorial
throw 与 throws
throw 用来抛出一个错误,throws 用来声明「这个函数可能抛出错误」,两者是一对。
只有被标记为 throws 的函数、以及 do 块内部,才能写 throw。
一旦执行到 throw,当前函数会立刻中断,后面的代码不会执行,控制权交回调用方。
实例
enum RunoobError: Error {
case notFound(String)
}
func process(_ step: Int) throws -> String {
print("步骤 \(step) 开始")
if step == 2 {
throw RunoobError.notFound("步骤 \(step)") // 抛出后本函数立即中断
}
print("步骤 \(step) 完成")
return "OK"
}
for step in 1...3 {
do {
print(try process(step))
} catch {
print("中断:\(error)")
}
}
运行结果:
步骤 1 开始
步骤 1 完成
OK
步骤 2 开始
中断:notFound("步骤 2")
步骤 3 开始
步骤 3 完成
OK
从输出可以看到,第 2 步在 throw 处直接中断,后面的「步骤 2 完成」和返回值都没有出现。
但循环本身没有被打断,第 3 步照常执行,因为错误已经被 do-catch 接住了。
try 与 do-catch
调用一个 throws 函数时必须写 try,这是在语法上提醒你「这里可能失败」。
最常见的处理方式是 do-catch:把可能出错的代码放进 do 块,错误在 catch 块里统一处理。
do 块中任意一行抛出错误,都会跳到 catch,do 块中剩下的语句不再执行。
catch 的匹配顺序
catch 从上到下依次匹配,第一个匹配上的分支被执行,后面的分支不再尝试。
因此要把具体错误写在前面,把笼统的 catch 放在最后。
| catch 写法 | 匹配范围 | 说明 |
|---|---|---|
| catch NetworkError.timeout(let s) | 特定枚举成员 | 取出关联值 s,其余错误继续向下匹配 |
| catch is NetworkError | 某一错误类型 | 不关心具体成员,只判断类型 |
| catch let error as NetworkError | 某一错误类型并绑定 | 把错误绑定为具体类型后使用 |
| catch | 所有剩余错误 | 隐式绑定到局部变量 error,必须放最后 |
Swift 的错误不是「异常」。它不会自动向上冒泡到任意位置,只能沿着函数调用链逐层传播,或者被某个 do-catch 就地处理。
try? 与 try!
有些场合并不需要区分失败原因,只想知道「成功还是失败」,这时可以用 try? 把结果变成可选类型。
try? 成功时返回包装好的可选值,失败时返回 nil,原来的错误信息被丢弃。
try! 则是断言「这次调用一定不会失败」,一旦真的失败,程序会直接崩溃。
实例
enum ParseError: Error {
case notANumber(String)
}
func toNumber(_ text: String) throws -> Int {
guard let value = Int(text) else {
throw ParseError.notANumber(text)
}
return value
}
// try? 把结果包装成可选类型,失败时得到 nil
let a = try? toNumber("42")
let b = try? toNumber("runoob")
print(a ?? -1)
print(b == nil)
// try! 断言一定成功,失败会直接崩溃
let c = try! toNumber("100")
print(c)
运行结果:
42 true 100
| 写法 | 失败时的行为 | 适用场景 |
|---|---|---|
| try | 把错误交给上层处理 | 需要区分失败原因,或需要向上传播 |
| try? | 返回 nil | 不关心原因,只关心成功与否 |
| try! | 触发运行时陷阱,程序崩溃 | 只用于逻辑上不可能失败的场合,如测试、常量解析 |
不要把 try! 当作「懒得写 do-catch」的快捷方式。它把编译期的检查换成了运行期的崩溃风险,正式代码里应当谨慎使用。
defer 清理动作
defer 用来声明一段「离开当前作用域前一定执行」的代码,无论函数是正常返回还是抛出错误。
它适合用来配对地做清理:打开文件后关闭、加锁后解锁、开始任务后打印日志。
实例
enum FileError: Error {
case emptyName
}
func readFile(_ name: String) throws -> String {
print("1. 打开文件:\(name)")
defer {
// 无论正常返回还是抛错,这里都会执行
print("3. 关闭文件:\(name)")
}
print("2. 读取内容")
if name.isEmpty {
throw FileError.emptyName
}
return "RUNOOB 文档内容"
}
do {
print(try readFile("runoob.txt"))
} catch {
print("失败:\(error)")
}
运行结果:
1. 打开文件:runoob.txt 2. 读取内容 3. 关闭文件:runoob.txt RUNOOB 文档内容
同一个作用域里可以有多个 defer,它们按声明的逆序执行,也就是后声明的先执行。
实例
func runoobTask() {
defer { print("A") }
defer { print("B") }
defer { print("C") }
print("开始")
}
runoobTask()
运行结果:
开始 C B A
defer 是在离开作用域时执行,不是「函数结束时」执行。写在 if 或循环体里的 defer,会在该块结束时触发。
错误传播与 rethrows
函数自己不处理错误时,可以在签名里声明 throws,把错误原样交给调用方,这叫错误传播。
传播链上的每一层都要写 try,直到某一层用 do-catch 处理掉为止。
实例
enum UserError: Error {
case emptyID(Int)
case notFound(Int)
}
// 底层函数负责发现并抛出错误
func loadName(_ id: Int) throws -> String {
guard id > 0 else { throw UserError.emptyID(id) }
guard id == 1 else { throw UserError.notFound(id) }
return "runoob"
}
// 中间函数不处理,只声明 throws 让错误继续向上传播
func loadProfile(_ id: Int) throws -> String {
let name = try loadName(id)
return "用户:\(name)"
}
// 最外层统一处理
for id in [1, 2, 0] {
do {
print(try loadProfile(id))
} catch UserError.emptyID(let value) {
print("编号无效:\(value)")
} catch {
print("其他错误:\(error)")
}
}
运行结果:
用户:runoob 其他错误:notFound(2) 编号无效:0
rethrows 是给「接受函数参数」的高阶函数用的。
它表示:这个函数本身不产生错误,只有当传入的闭包抛错时,它才把错误继续传出去。
实例
enum ParseError: Error {
case notANumber(String)
}
// rethrows:只有传入的闭包抛错,这个函数才会抛错
func runoobMap(_ values: [Int], transform: (Int) throws -> Int) rethrows -> [Int] {
var result: [Int] = []
for value in values {
result.append(try transform(value))
}
return result
}
// 传入不抛错的闭包:调用时无需 try
let doubled = runoobMap([1, 2, 3]) { $0 * 2 }
print(doubled)
// 传入会抛错的闭包:调用时必须写 try
do {
let checked = try runoobMap([1, 2, 3]) { value in
if value == 2 { throw ParseError.notANumber("\(value)") }
return value
}
print(checked)
} catch {
print("出错:\(error)")
}
运行结果:
[2, 4, 6]
出错:notANumber("2")
rethrows 的好处是保留调用方的灵活性:传入不抛错的闭包时,调用点不用写 try,也就不会被迫进入 do-catch。
用 rethrows 的函数,其闭包参数必须标为 throws,而且函数体内不能自己写 throw。
Result 类型
Result 是一个枚举,把「成功」和「失败」两种结果装进同一个值里(SE-0235,Swift 5 引入)。
它适合用在错误需要被保存、传递、延迟处理的场合,例如异步回调、或者把错误存进数组。
实例
enum ParseError: Error {
case notANumber(String)
}
// 返回 Result 而不是抛错:调用方拿到的是一个值,可以随意传递
func fetchScore(_ name: String) -> Result<Int, ParseError> {
guard let score = Int(name) else {
return .failure(.notANumber(name))
}
return .success(score)
}
// 用 switch 处理两种结果
switch fetchScore("88") {
case .success(let score):
print("分数:\(score)")
case .failure(let error):
print("失败:\(error)")
}
// get() 把 Result 转回 throws 调用
do {
let score = try fetchScore("runoob").get()
print(score)
} catch {
print("get 抛出:\(error)")
}
运行结果:
分数:88
get 抛出:notANumber("runoob")
Result 还提供了一些常用方法,方便在不展开 switch 的情况下变换结果。
| 成员 | 作用 |
|---|---|
| get() | 成功时返回关联值,失败时抛出错误,可接回 throws 世界 |
| map(_:) | 成功时变换关联值,失败时原样传递 |
| flatMap(_:) | 成功时执行一个返回 Result 的闭包,用于串联操作 |
| mapError(_:) | 失败时变换错误类型 |
| case success / failure | 用模式匹配取出对应结果 |
在 async / await 普及之后,异步场景更推荐直接用 async throws 函数,而不是回调里传 Result。但 Result 在需要「把结果存起来」的场合依然不可替代。
与可选类型的选择
可选类型和错误都能表示「没拿到值」,但表达的信息量不同。
官方文档给出的判断依据是:失败原因对调用方有没有意义。
如果只是「查不到」「没有下一个」这类单一含义,可选类型更轻量;如果失败可能有多种原因,而且调用方需要据此做不同处理,就用 throws。
实例
// 只有「找到 / 没找到」两种结果,可选类型就够了
func findUser(_ name: String) -> String? {
return name == "runoob" ? "RUNOOB 管理员" : nil
}
enum LoginError: Error {
case userNotFound(String)
case wrongPassword(String)
}
// 失败原因有区分度,调用方需要分别处理,用 throws
func login(user: String, password: String) throws -> String {
guard user == "runoob" else { throw LoginError.userNotFound(user) }
guard password == "runoob123" else { throw LoginError.wrongPassword(user) }
return "登录成功,欢迎 \(user)"
}
print(findUser("runoob") ?? "查无此人")
print(findUser("guest") ?? "查无此人")
do {
print(try login(user: "runoob", password: "runoob123"))
} catch {
print(error)
}
运行结果:
RUNOOB 管理员 查无此人 登录成功,欢迎 runoob
| 判断依据 | 选可选类型 | 选 throws 错误 |
|---|---|---|
| 失败原因数量 | 只有一种含义 | 多种原因,需要区分 |
| 调用方是否需要恢复 | 不需要,拿不到就算了 | 需要根据原因重试或提示用户 |
| 是否携带上下文 | 不需要额外信息 | 需要地址、状态码、编号等细节 |
| 调用点的负担 | 一个 ?? 或 if let 就够 | 需要 do-catch 或向上传播 |
还有一种情况是「失败属于程序 bug,不可恢复」,例如数组越界、强制解包 nil。
这类问题 Swift 用运行时陷阱(trap)处理,直接终止程序,不进入错误处理流程。
不要用错误处理来掩盖逻辑错误。如果某个条件不满足就说明程序写错了,用 precondition 或 fatalError 更诚实。
常见问题
下面列出初学者在使用错误处理时最容易遇到的几个问题。
为什么 catch 后面必须有一个不带模式的兜底分支
因为 Swift 的错误类型遵循 Error 协议,编译器无法知道所有可能抛出的错误类型,所以必须给一个兜底。
如果确实只处理某一类错误、其他错误要向上传播,就把函数标成 throws,在 do-catch 里只捕获目标错误,其余重新抛出。
do 块里可以有多个 try 吗
可以。任意一个 try 抛错都会跳到 catch,后面还没执行的 try 自然不会执行。
如果想让每个操作独立处理,就为它们分别写 do-catch。
try? 会丢失哪些信息
它丢掉了具体的错误值,只留下「成功或 nil」。
如果后续排查问题需要知道失败原因,就不要用 try?,改用 do-catch 把错误记录下来。
throws 函数的返回值可以直接忽略吗
可以,但会收到编译警告。如果确实不关心返回值,给函数加上 @discardableResult 即可消除警告。
错误类型必须用枚举吗
不是必须的,结构体、类遵循 Error 也可以。
但枚举在表达「有限的几种失败原因」时最自然,也最方便用 catch 做模式匹配,因此实践中用得最多。
defer 里能写 throw 吗
不能。如果 defer 块要执行可能失败的操作,需要自己在块内用 do-catch 处理掉。
