2.17 错误处理

原文链接: https://docs.swift.org/latest/documentation/the-swift-programming-language/errorhandling/

2.17 错误处理

对错误做出响应并从中恢复。

错误处理是响应并恢复程序错误状况的过程。Swift 对在运行时抛出、捕获、传播和操作可恢复错误提供了一等支持。

有些操作并不保证总能执行完成或产生有用的输出。可选类型用来表示值的缺失,但当操作失败时,弄清失败的原因往往很有用,这样代码才能做出相应的响应。

举例来说,考虑从磁盘上的文件读取并处理数据这项任务:它可能以多种方式失败,包括指定路径下文件不存在、没有读取该文件的权限,或者文件没有使用兼容的编码格式。区分这些不同的情况,能让程序解决其中一部分错误,并把无法解决的错误告知用户。

注意:Swift 的错误处理可以与 Cocoa、Objective-C 中使用 NSError 类的 错误处理模式互操作。 关于这个类的更多内容, 参见 Handling Cocoa Errors in Swift。

表示与抛出错误

在 Swift 中,错误用遵循 Error 协议的类型的值表示。这个空协议表示某个类型可以用于错误处理。

Swift 枚举特别适合为一组相关的错误状况建模,借助关联值还能传递关于错误性质的附加信息。例如,下面这样表示在游戏里操作自动售货机可能出现的错误状况:

1
2
3
4
5
enum VendingMachineError: Error {
    case invalidSelection
    case insufficientFunds(coinsNeeded: Int)
    case outOfStock
}

抛出错误让你能够表明发生了意外情况,正常的执行流程无法继续。你用 throw 语句抛出错误。例如,下面的代码抛出一个错误,表示自动售货机还需要五枚硬币:

1
throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

处理错误

错误被抛出时,周围必须由某段代码负责处理它——例如修正问题、换一种做法,或者把失败告知用户。

Swift 中处理错误有四种方式:把错误从函数传播给调用该函数的代码;用 do-catch 语句处理错误;把错误当作可选值处理;或者断言错误不会发生。每种方式都在下面各节中介绍。

函数抛出错误会改变程序的流程,因此能够快速识别代码中可能抛出错误的位置很重要。要识别代码中的这些位置,就在调用可能抛出错误的函数、方法或构造器的代码前写 try 关键字(或者 try?、try! 变体)。这些关键字在下面各节中介绍。

注意:Swift 的错误处理与其他语言中的异常处理类似, 都使用 try、catch 和 throw 关键字。 与许多语言(包括 Objective-C)的异常处理不同, Swift 的错误处理不涉及展开调用栈, 而展开调用栈可能非常耗费计算资源。 因此,throw 语句的性能特征 与 return 语句相当。

用抛出错误的函数传播错误

要表示某个函数、方法或构造器可能抛出错误,就在函数声明的参数之后写 throws 关键字。带 throws 标记的函数称为抛出函数。如果函数指定了返回类型,就把 throws 关键字写在返回箭头(->)之前。

1
2
3
func canThrowErrors() throws -> String

func cannotThrowErrors() -> String

抛出函数会把在它内部抛出的错误传播到调用它的那个作用域。

注意:只有抛出函数才能传播错误。 在非抛出函数内部抛出的任何错误 都必须在该函数内部处理。

在下面的例子里,VendingMachine 类有一个 vend(itemNamed:) 方法:如果请求的商品不存在、已售完,或者价格超过当前投入的金额,它就抛出相应的 VendingMachineError:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct Item {
    var price: Int
    var count: Int
}

class VendingMachine {
    var inventory = [
        "Candy Bar": Item(price: 12, count: 7),
        "Chips": Item(price: 10, count: 4),
        "Pretzels": Item(price: 7, count: 11)
    ]
    var coinsDeposited = 0

    func vend(itemNamed name: String) throws {
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
        }

        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
        }

        guard item.price <= coinsDeposited else {
            throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)
        }

        coinsDeposited -= item.price

        var newItem = item
        newItem.count -= 1
        inventory[name] = newItem

        print("Dispensing \(name)")
    }
}

vend(itemNamed:) 方法的实现用 guard 语句提前退出方法,并在购买零食的任何条件不满足时抛出相应的错误。由于 throw 语句会立即转移程序控制权,只有所有这些条件都满足时才会售出商品。

由于 vend(itemNamed:) 方法会传播它抛出的任何错误,调用该方法的所有代码要么处理这些错误——用 do-catch 语句、try? 或 try!——要么继续传播它们。例如,下面例子中的 buyFavoriteSnack(person:vendingMachine:) 也是一个抛出函数,vend(itemNamed:) 方法抛出的任何错误都会一直向上传播到调用 buyFavoriteSnack(person:vendingMachine:) 函数的地方。

1
2
3
4
5
6
7
8
9
let favoriteSnacks = [
    "Alice": "Chips",
    "Bob": "Licorice",
    "Eve": "Pretzels",
]
func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    try vendingMachine.vend(itemNamed: snackName)
}

在这个例子里,buyFavoriteSnack(person: vendingMachine:) 函数查找指定人物最喜欢的零食,并通过调用 vend(itemNamed:) 方法为他们购买。由于 vend(itemNamed:) 方法可能抛出错误,调用时前面要写 try 关键字。

抛出错误的构造器也可以像抛出函数那样传播错误。例如,下面清单中 PurchasedSnack 结构体的构造器在初始化过程中调用了一个抛出函数,并把遇到的所有错误传播给它的调用方。

1
2
3
4
5
6
7
struct PurchasedSnack {
    let name: String
    init(name: String, vendingMachine: VendingMachine) throws {
        try vendingMachine.vend(itemNamed: name)
        self.name = name
    }
}

用 do-catch 处理错误

你用 do-catch 语句运行一段代码来处理错误:如果 do 子句中的代码抛出错误,就会把它与各个 catch 子句匹配,确定由哪一个来处理。

do-catch 语句的一般形式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
do {
    try <#expression#>
    <#statements#>
} catch <#pattern 1#> {
    <#statements#>
} catch <#pattern 2#> where <#condition#> {
    <#statements#>
} catch <#pattern 3#>, <#pattern 4#> where <#condition#> {
    <#statements#>
} catch {
    <#statements#>
}

在 catch 后面写模式,用来表明该子句能处理哪些错误。如果某个 catch 子句没有模式,它就匹配任何错误,并把该错误绑定到名为 error 的局部常量。关于模式匹配的更多内容,参见模式。

例如,下面的代码匹配 VendingMachineError 枚举的全部三个成员。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var vendingMachine = VendingMachine()
vendingMachine.coinsDeposited = 8
do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
    print("Success! Yum.")
} catch VendingMachineError.invalidSelection {
    print("Invalid Selection.")
} catch VendingMachineError.outOfStock {
    print("Out of Stock.")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
    print("Insufficient funds. Please insert an additional \(coinsNeeded) coins.")
} catch {
    print("Unexpected error: \(error).")
}
// 输出 "Insufficient funds. Please insert an additional 2 coins."。

在上面的例子里,buyFavoriteSnack(person:vendingMachine:) 函数在 try 表达式中调用,因为它可能抛出错误。如果抛出了错误,执行会立即转移到各个 catch 子句,由它们决定是否允许继续传播。如果没有任何模式匹配,错误会被最后的 catch 子句捕获,并绑定到局部常量 error。如果没有抛出错误,就继续执行 do 语句中其余的语句。

catch 子句不必处理 do 子句中的代码可能抛出的每一个错误。如果没有任何 catch 子句处理该错误,错误就会传播到外围作用域。不过,传播出去的错误必须由某个外围作用域处理:在非抛出函数中,必须由外层的 do-catch 语句处理;在抛出函数中,必须由外层的 do-catch 语句或调用方处理。如果错误一直传播到顶层作用域都没有被处理,就会得到运行时错误。

例如,可以把上面的例子改写成:任何不是 VendingMachineError 的错误都由调用函数捕获:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func nourish(with item: String) throws {
    do {
        try vendingMachine.vend(itemNamed: item)
    } catch is VendingMachineError {
        print("Couldn't buy that from the vending machine.")
    }
}

do {
    try nourish(with: "Beet-Flavored Chips")
} catch {
    print("Unexpected non-vending-machine-related error: \(error)")
}
// 输出 "Couldn't buy that from the vending machine."。

在 nourish(with:) 函数中,如果 vend(itemNamed:) 抛出的错误是 VendingMachineError 枚举的某个成员,nourish(with:) 就通过打印一条消息来处理该错误;否则 nourish(with:) 把错误传播到调用它的地方,再由那个通用的 catch 子句捕获。

捕获若干相关错误的另一种方式,是把它们写在 catch 之后、用逗号分隔。例如:

1
2
3
4
5
6
7
func eat(item: String) throws {
    do {
        try vendingMachine.vend(itemNamed: item)
    } catch VendingMachineError.invalidSelection, VendingMachineError.insufficientFunds, VendingMachineError.outOfStock {
        print("Invalid selection, out of stock, or not enough money.")
    }
}

eat(item:) 函数列出了要捕获的售货机错误,它的错误文本与列表中的各项对应。如果抛出了所列三种错误中的任何一种,这个 catch 子句就通过打印一条消息来处理它们;其他任何错误都会被传播到外围作用域,包括以后可能新增的任何售货机错误。

把错误转换为可选值

你用 try? 把错误转换成可选值来处理它。如果在求值 try? 表达式时抛出了错误,该表达式的值就是 nil。例如在下面的代码中,x 和 y 的值与行为完全相同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func someThrowingFunction() throws -> Int {
    // ...
}

let x = try? someThrowingFunction()

let y: Int?
do {
    y = try someThrowingFunction()
} catch {
    y = nil
}

如果 someThrowingFunction() 抛出错误,x 和 y 的值就是 nil;否则 x 和 y 的值就是函数的返回值。注意 x 和 y 的类型是 someThrowingFunction() 返回值类型的可选类型:这里函数返回整数,因此 x 和 y 是可选整数。

当你想用同一种方式处理所有错误时,try? 让你能写出简洁的错误处理代码。例如,下面这段代码尝试用多种方式获取数据,如果所有方式都失败就返回 nil。

1
2
3
4
5
func fetchData() -> Data? {
    if let data = try? fetchDataFromDisk() { return data }
    if let data = try? fetchDataFromServer() { return data }
    return nil
}

禁用错误传播

有时你明知某个抛出函数或方法在运行时实际上不会抛出错误。这种情况下,你可以在表达式前写 try! 来禁用错误传播,并把调用包在一个"不会抛出错误"的运行时断言中。如果确实抛出了错误,就会得到运行时错误。

例如,下面的代码使用 loadImage(atPath:) 函数,它在给定路径加载图片资源,或者在无法加载图片时抛出错误。在这个例子里,由于图片是随应用一起发布的,运行时不会抛出错误,因此适合禁用错误传播。

1
let photo = try! loadImage(atPath: "./Resources/John Appleseed.jpg")

指定错误类型

上面所有例子都用的是最常见的错误处理方式:代码抛出的错误可以是任何遵循 Error 协议的类型的值。这种做法符合现实情况——你无法事先知道代码运行时可能发生的每一个错误,尤其是在传播别处抛出的错误时。它也反映了错误会随时间变化这一事实:库的新版本(包括你的依赖所用的库)可能抛出新的错误,现实世界用户配置的丰富复杂性也可能暴露出开发和测试阶段未曾见过的失败方式。上面例子中的错误处理代码总是包含一个 default 分支,用来处理没有特定 catch 子句的错误。

大多数 Swift 代码并不指定它所抛错误的类型。不过在下列特殊情况下,你可能会把代码限制为只抛出某一种特定类型的错误:

  • 在不支持动态分配内存的嵌入式系统上运行代码时。抛出 any Error 或其他装箱协议类型的实例需要在运行时分配内存来存放错误;相比之下,抛出特定类型的错误能让 Swift 避免为错误做堆分配。

  • 当这些错误属于某个代码单元(例如某个库)的实现细节、而不是该代码接口的一部分时。由于错误只来自这个库,而不来自其他依赖或该库的调用方,你可以把所有可能的失败列成一份穷尽的清单;又因为这些错误是库的实现细节,它们总是在该库内部被处理。

  • 在只传播由泛型参数描述的错误代码中,例如接受闭包实参并传播该闭包中任何错误的函数。关于传播特定错误类型与使用 rethrows 的比较,参见重新抛出错误的函数和方法。

例如,考虑一段汇总评分并使用下列错误类型的代码:

1
2
3
4
enum StatisticsError: Error {
    case noRatings
    case invalidRating(Int)
}

要指定某个函数只抛出 StatisticsError 值作为错误,就在声明该函数时写 throws(StatisticsError),而不只是 throws。这种语法也称为类型化抛出,因为在声明中把错误类型写在 throws 之后。例如,下面这个函数只抛出 StatisticsError 值作为错误。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func summarize(_ ratings: [Int]) throws(StatisticsError) {
    guard !ratings.isEmpty else { throw .noRatings }

    var counts = [1: 0, 2: 0, 3: 0]
    for rating in ratings {
        guard rating > 0 && rating <= 3 else { throw .invalidRating(rating) }
        counts[rating]! += 1
    }

    print("*", counts[1]!, "-- **", counts[2]!, "-- ***", counts[3]!)
}

在上面的代码中,summarize(_:) 函数汇总一组用 1 到 3 量程表示的评分。如果输入不合法,这个函数就抛出 StatisticsError 的实例。上面两处抛出错误的代码都省略了错误类型,因为这个函数的错误类型已经定义好了。在这样的函数中抛出错误时,可以使用简写形式 throw .noRatings,而不必写 throw StatisticsError.noRatings。

当你在函数开头写出特定的错误类型时,Swift 会检查你没有抛出其他错误。例如,如果你试图在上面这个 summarize(_:) 函数中使用本章前面例子里的 VendingMachineError,那段代码会在编译期报错。

你可以在普通的抛出函数中调用使用类型化抛出的函数:

1
2
3
4
func someThrowingFunction() throws {
    let ratings = [1, 2, 3, 2, 2, 1]
    try summarize(ratings)
}

上面的代码没有为 someThrowingFunction() 指定错误类型,因此它抛出 any Error。你也可以把错误类型显式写成 throws(any Error),下面的代码与上面等价:

1
2
3
4
func someThrowingFunction() throws(any Error) {
    let ratings = [1, 2, 3, 2, 2, 1]
    try summarize(ratings)
}

在这段代码中,someThrowingFunction() 传播 summarize(_:) 抛出的任何错误。来自 summarize(_:) 的错误总是 StatisticsError 值,而这对 someThrowingFunction() 来说也是合法的可抛出错误。

正如你可以用返回类型 Never 写出永不返回的函数一样,你也可以用 throws(Never) 写出永不抛出错误的函数:

1
2
3
func nonThrowingFunction() throws(Never) {
  // ...
}

这个函数不会抛出错误,因为无法创建 Never 类型的值来抛出。

除了指定函数的错误类型,你还可以为 do-catch 语句写出特定的错误类型。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let ratings = []
do throws(StatisticsError) {
    try summarize(ratings)
} catch {
    switch error {
    case .noRatings:
        print("No ratings available")
    case .invalidRating(let rating):
        print("Invalid rating: \(rating)")
    }
}
// 输出 "No ratings available"。

在这段代码中,写 do throws(StatisticsError) 表示这个 do-catch 语句抛出 StatisticsError 值作为错误。与其他 do-catch 语句一样,catch 子句既可以处理所有可能的错误,也可以把未处理的错误传播给某个外围作用域处理。这段代码处理了所有错误,用一个 switch 语句为每个枚举值各写一个 case。与其他没有模式的 catch 子句一样,这个子句匹配任何错误,并把错误绑定到名为 error 的局部常量。由于这个 do-catch 语句抛出的是 StatisticsError 值,error 的类型就是 StatisticsError。

上面的 catch 子句用 switch 语句匹配并处理每一种可能的错误。如果你给 StatisticsError 添加了新成员却没有更新错误处理代码,Swift 就会报错,因为这个 switch 语句不再穷尽。对于一个捕获自身所有错误的库来说,可以用这种做法确保任何新增的错误都有相应的新代码来处理。

如果函数或 do 块只抛出单一类型的错误,Swift 会推断这段代码使用了类型化抛出。使用这种更简短的语法,可以把上面的 do-catch 例子写成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let ratings = []
do {
    try summarize(ratings)
} catch {
    switch error {
    case .noRatings:
        print("No ratings available")
    case .invalidRating(let rating):
        print("Invalid rating: \(rating)")
    }
}
// 输出 "No ratings available"。

尽管上面的 do-catch 块没有指定它抛出的错误类型,Swift 也会推断它抛出 StatisticsError。你可以显式写出 throws(any Error),以避免让 Swift 推断出类型化抛出。

指定清理操作

你用 defer 语句在代码执行即将离开当前代码块之前执行一组语句。这个语句让你能完成任何必要的清理工作,无论执行是如何离开当前代码块的——是因为抛出了错误,还是因为 return、break 之类的语句。例如,你可以用 defer 语句确保文件描述符被关闭、手动分配的内存被释放。

defer 语句把执行推迟到离开当前作用域时。它由 defer 关键字和稍后要执行的语句组成。被推迟的语句中不能包含会把控制权转移出去的任何代码,例如 break、return 语句或抛出错误。被推迟的动作按照与源代码中书写顺序相反的顺序执行:也就是说,第一条 defer 语句中的代码最后执行,第二条 defer 语句中的代码倒数第二执行,依此类推;源代码中最后一条 defer 语句最先执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func processFile(filename: String) throws {
    if exists(filename) {
        let file = open(filename)
        defer {
            close(file)
        }
        while let line = try file.readline() {
            // 处理文件内容。
        }
        // close(file) 在这里、也就是作用域末尾被调用。
    }
}

上面的例子用 defer 语句确保 open(_:) 函数有对应的 close(_:) 调用。

即使不涉及任何错误处理代码,你也可以使用 defer 语句。更多内容参见延迟执行的动作。