现在位置: 首页 > Swift 教程 > 正文

Swift 错误处理

程序运行中总会遇到预期之外的情况:文件不存在、网络断开、用户输入格式不对。

Swift 用一套显式的错误处理机制来描述这些可恢复的失败,把「这个操作可能失败」直接写进函数签名,让编译器帮你盯住每一个调用点。

本文覆盖 Error 协议、throw / throws / try / do-catchtry? / try!deferrethrowsResult,最后说明什么时候该用错误、什么时候用可选类型就够了。


为什么需要错误处理

没有错误处理机制时,函数通常只能靠特殊返回值来报告失败,例如返回 -1、空字符串或者 nil。

这种做法的问题在于:调用方很容易忘记检查,而且「为什么失败」的信息在传递过程中丢掉了。

Swift 的做法是把失败写进函数签名,调用方必须用 try 明确表态,编译器负责检查你是否处理了这个错误。

报告失败的方式失败时返回什么调用方需要做什么适用场景
特殊返回值约定的哨兵值,如 -1自己记得检查失败含义单一,且哨兵值不会与正常结果冲突
可选类型nil用 if let 或 ?? 解包只关心「有没有值」,不关心失败原因
throws 错误抛出一个遵循 Error 的值用 try 处理或继续传播失败原因对调用方有意义,且可能恢复

Error 协议与自定义错误类型

Swift 中的错误类型必须遵循 Error 协议。

这个协议没有任何必须实现的要求,它只是一个标记,表示这个类型可以当作错误抛出。

最常用的定义方式是用枚举,因为枚举可以把所有可能的失败原因清楚列出来,再用 switchcatch 逐一处理。

用枚举定义错误

下面的例子定义了一个网络错误类型,并用 throws 标记可能失败的函数。

实例

import Foundation

// 遵循 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 一样的模式匹配语法把这些信息取出来。

实例

import Foundation

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 返回一句人类可读的说明。

实例

import Foundation

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 用来声明「这个函数可能抛出错误」,两者是一对。

Swift 错误处理流程图:throw 抛出错误后沿调用栈向上传播,遇到 do-catch 就地捕获,否则继续向上直到顶层未捕获而终止;右侧为 try? 与 try! 的行为,底部标注 defer 在离开作用域时执行且多个 defer 逆序执行

只有被标记为 throws 的函数、以及 do 块内部,才能写 throw

一旦执行到 throw,当前函数会立刻中断,后面的代码不会执行,控制权交回调用方。

实例

import Foundation

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 块中任意一行抛出错误,都会跳到 catchdo 块中剩下的语句不再执行。

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! 则是断言「这次调用一定不会失败」,一旦真的失败,程序会直接崩溃。

实例

import Foundation

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 用来声明一段「离开当前作用域前一定执行」的代码,无论函数是正常返回还是抛出错误。

它适合用来配对地做清理:打开文件后关闭、加锁后解锁、开始任务后打印日志。

实例

import Foundation

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,它们按声明的逆序执行,也就是后声明的先执行。

实例

import Foundation

func runoobTask() {
    defer { print("A") }
    defer { print("B") }
    defer { print("C") }
    print("开始")
}

runoobTask()

运行结果:

开始
C
B
A

defer 是在离开作用域时执行,不是「函数结束时」执行。写在 if 或循环体里的 defer,会在该块结束时触发。


错误传播与 rethrows

函数自己不处理错误时,可以在签名里声明 throws,把错误原样交给调用方,这叫错误传播。

传播链上的每一层都要写 try,直到某一层用 do-catch 处理掉为止。

实例

import Foundation

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 是给「接受函数参数」的高阶函数用的。

它表示:这个函数本身不产生错误,只有当传入的闭包抛错时,它才把错误继续传出去。

实例

import Foundation

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 引入)。

它适合用在错误需要被保存、传递、延迟处理的场合,例如异步回调、或者把错误存进数组。

实例

import Foundation

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

实例

import Foundation

// 只有「找到 / 没找到」两种结果,可选类型就够了
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)处理,直接终止程序,不进入错误处理流程。

不要用错误处理来掩盖逻辑错误。如果某个条件不满足就说明程序写错了,用 preconditionfatalError 更诚实。


常见问题

下面列出初学者在使用错误处理时最容易遇到的几个问题。

为什么 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 处理掉。