【iOS】objc.io – Swift 进阶 – 整理 – (十一)错误处理

错误处理架构

编程语言自身可以提供一个良好的错误处理架构很重要。认为架构应该具备的一些特征:

  • 简洁(Conciseness):不应该让逻辑正确的代码淹没在抛出错误的代码里。
  • 可传递(Propagation):错误不一定要在发生的原地进行处理。通常,让错误处理的代码远离错误发生的地方更好。一个错误处理架构应该可以很容易让错误沿着调用栈向上传递到合适的位置。而让中间函数(自身不抛出错误也不处理错误的函数, 但实现里调用了会抛出错误的其它函数) 在不引入复杂语法变更的情况下就可以传递错误。
  • 文档(Documentation):让程序员可以确定错误可能会发生的位置和具体的错误类型。
  • 安全(Safety):可以帮助程序员出于意外原因而忽略错误处理。
  • 通用(Universality):制定一个可以用于所有场景的抛出错误和处理错误的机制。

错误类型

“错误 (Error)” 和 “失败 (Failure)” 可以表达各种类型的问题。为“可能发生错误的事件”进行一些归类:

预期中的错误:在常规操作中,可以被程序员预见的失败情况。例如:网络连接问题、用户输入内容不合法问题等。根据造成失败原因的复杂度,可以进一步把预期中的错误分成下面几类:

  • 可以忽略细节的错误(Trivialerrors):有些操作只有一个可以预期的失败情况。例如查询字典结果只能是键值存在 (操作成功) 或不存在 (操作失败)。在 Swift 里, 对于 “不存在” 或 “不合法输入” 这种简单且明确的日常错误条件,倾向于让函数返回可选值。

在错误原因对函数调用者非常明确的时候,可选值在简洁性,安全性,文档,可传递性以及通用性方面均表现优异。

  • 需要提供详细信息的错误(Richerrors):对于网络和文件系统操作,应该提供关于失败情况的更多实质性问题描述。在这些场景里造成失败的因素很多,程序员会根据不同的因素采用不同的处理方法。

尽管大部分标准库 API 都返回可以忽略细节的错误 (通常就是返回可选值),但 Codable 系统使用了这种需要提供详细信息的错误。编码和解码包含了很多触发错误的条件,因此,精确的错误信息对于指出发生问题的环节非常有价值。

  • 非预期错误:预料之外的条件下导致的错误,通常会导致程序难以继续执行。通常这意味着假定的一些 (“绝对不会发生的”) 条件被破坏了。

处理这类错误的方式通常就是让程序崩溃,因为程序在一个不确定的状态下执行并不安全。这些情况都被认为是程序员自身的错误,它们应该在测试中被检测出来并修复。

Result 类型

Result 是一个和 Optional 结构类似的枚举,包含两个成员,分别是:success 和 failure,它们的功能和 Optional 中的 some 和 none 是相同的。
不同的是,Result.failure 也带有关联值,因此,Result 可以表达那些需要提供详细信息的错误:

enum Result<Success, Failure: Error> { 
    case success(Success)
    case failure(Failure)
}
复制代码

Result 给表达失败情况的泛型参数添加了 Error 约束,表示这个 case 只用于表达错误。

Optional 和 Result 的区别可以提示是否可以忽略错误的细节,还是必须要为错误提供额外的信息。

假设正在写一个从磁盘读取文件的函数。一开始时,使用可选值来定义接口。因为读取一个文件可能会失败,在这种情况下返回 nil:

func contentsOrNil(ofFile filename: String) -> String?
复制代码

这个函数签名非常简单,没有读取文件失败的具体原因。告诉调用者失败的原因是有必要的。

enum FileError: Error { 
    case fileDoesNotExist 
    case noPermission
}
复制代码

这样,就可以让函数返回一个 Result,要么表示一个字符串 (操作成功),要么表示一个 FileError (操作失败):

func contents(ofFile filename: String) -> Result<String, FileError>

let result = contents(ofFile: "input.txt") 
switch result {
case let .success(contents):
    print(contents)
case let .failure(error):
    switch error {
    case .fileDoesNotExist:
        print("File not found") 
    case .noPermission:
        print("No permission") 
    }
}
复制代码

抛出和捕获

Swift 内建的错误处理方式在很多方面都借鉴了 Result 的用法,只不过使用了不同的语法。Swift 没有使用返回 Result 的方式来表示失败,而是将方法标记为 throws。对于每个可以抛出错误的函数调用,编译器都会验证调用者有没有捕获错误,或者把这个错误向上传递给它调用者。

contents(ofFile:) 用 throws 语法表达出来是这样的:

func contents(ofFile filename: String) throws -> String
复制代码

所有对 contents(ofFile:) 的调用都必须用关键字 try 标记,否则代码将无法编译。而这个
try 关键字无论对编译器还是代码的读者来说,都是一个信号,表明这个函数可能会抛出错误。

调用一个可抛出错误的函数还会迫使我们决定如何处理错误。可以选择使用 do/catch 直接处理,或者把当前函数标记为 throws 将错误传递给调用栈上层的调用者。如果使用 catch 的话,可能会存在多条 catch 语句,可以用模式匹配来捕获某个特定的错误类型。在 catch-all 里,编译器还会自动生成一个 error 变量 (和属性的 willSet 中的 newValue 很像):

do{
    let result = try contents(ofFile: "input.txt") 
    print(result)
} catch FileError.fileDoesNotExist { 
    print("File not found")
} catch { 
    print(error)
    // 处理其它错误。 
}
复制代码

Swift 的异常机制 并不会像很多语言那样带来额外的运行时开销。编译器会认为 throw 是一个普通的返回,这样一来,普通的代码路径和异常的代码路径速度都会很快。

如果要在错误中给出更多信息,可以使用带有关联值的枚举。例如,一个文件解析器的程序库可以像下面这样建模可能的错误条件:

enum ParseError: Error {
    case wrongEncoding
    case warning(line: Int, message: String)
}
复制代码

也可以把一个结构体或者类作为错误类型来使用;任何遵守 Error 协议的类型都可以被函数作为错误抛出。而且由于 Error 协议中其实并没有任何要求,所以任何类型都可以声明遵守它,而并不需要添加任何额外的实现。

为了快速测试某些代码,或者编写一些简单原型的时候,让 String 实现 Error 有时会很有帮助。只需要一行代码就可以搞定了:extension String: Error {}。 这样,就可以直接把表达错误消息的字符串作为可抛出的错误值使用,例如: throw “File not found”。在生产环境的代码里,并不推荐如此,因为让一个不属于你的类型实现某个协议并不是值得推荐的做法。

有了 ParseError 之后,我们的解析函数看起来是这样的:

func parse(text: String) throws -> [String]

do{
    let result = try parse(text: "{ \"message\": \"We come in peace\" }") 
    print(result)
} catch ParseError.wrongEncoding { 
    print("Wrong encoding")
} catch let ParseError.warning(line, message) { 
    print("Warning at line \(line): \(message)")
} catch {
    preconditionFailure("Unexpected error: \(error)")
}
复制代码

具体类型错误和无类型错误

上节代码中的 do/catch 有些地方并不是非常合适。即使非常确定所有可能发生的错误都是 ParseError 类型的,并且也逐一处理了它的每一个 case,编译器还是需要在最后写一个捕获所有异常的 catch 以确认所有可能的错误都被处理了。

Swift 原生的错误处理机制使用了无类型错误 (untyped errors)。我们只能用 throws 声明函数会抛出错误,但无法指定它究竟会抛出哪些具体的错误。因此,为了从语言层面确保所有错误都可以被处理,编译器总是要求编写一个 catchall 语句。在错误处理系统中使用无类型错误是 Swift 刻意为之的。

而 Result则属于具体类型错误 (typed errors)。Result 带有两个泛型参数, Success 和 Failure,后者指定了错误的具体类型。正是这种结构,实现 contents(ofFile:) 时可以通过遍历 Result<String, FileError> 中的 FileError,处理每一种错误。

再来看个例子,下面是 parse(text:) 方法的一个变体,把 throws 替换成了 Result<[String], ParseError>。于是,parse 可能发生的错误就被限定成了 ParseError,也就能只处理它的每一种情况了 (无须像 do/catch 一样提供一个 “多余” 的 catchall 语句):

func parse(text: String) -> Result<[String], ParseError>
复制代码

使用 Swift 内建错误处理机制中的无类型错误,和使用 Result 这种带有类型的错误在行为模式上是不一样的。

Swift 还提供了一个持有无类型错误的 Result 变体, case failure 的关联值是任何一个实现了 Error 的类型。正因为如此,Result 实际上是一个同时支持两种错误处理范式的混合体。如果不希望指定具体的错误类型,就用 Result<…, Error> 作为返回值就可。

所以,Result 为使用无类型错误和具体错误之间提供了选择。只是用 Result 表示无类型错误的时候,要多写一些代码,因为要在泛型参数列表中,写上 Error。可以为这种 Result 定义一个别名:

typealias UResult<Success> = Result<Success, Error>
复制代码

之所以可以定义 Result<Success, Error>,实际上是编译器特别为 Error 协议开了后门。刚才看到过,在 Result 中 Failure 是一个实现了 Error 的类型:

enum Result<Success, Failure: Error>
复制代码

但在 Swift 中,协议并不是一个实现了它们自己的类型,因此 Result<…, Error> 这种写法中, Error 并不是一个满足类型约束要求的表达方式。为了允许用这样的形式表达无类型错误, Swift 为编译器加入了对 Error 的特别处理,让它是一个可以 “自我实现” 的协议,而其它协议均不具有这样的性质。

避免编译器总是提示我们要通过 catchall 处理所有错误,使用 Result 让它包含一个具体的错误类型都是个很好的选择。如果到处得到的都是具体类型错误,可以指定抛出的具体错误类型就必然应该成为函数的一个可选功能,但也不必要求每个抛出错误的函数都必须如此。因为具体类型错误也有它自己严重的问题:

  • 具体错误类型使得组合会抛出异常的函数,以及聚合这些函数抛出的错误都非常困难。如果一个函数调用了多个可能抛出错误的函数,要么它就会向调用栈上层传递多种错误,要么它就得为调用栈下层定义一个包含这些错误的全新错误类型。这种做法很快就会超出控制。
  • 严格的错误类型会限制程序库的可扩展性。例如,每次为函数添加新的错误条件,对于 那些需要捕获完整错误列表的调用代码来说,都是一次破坏性的更新。为了在不同版本的程序库之间维持二进制兼容性,每一处 do/catch 代码都要加上可以捕获所有异常的默认处理语句。
  • 要求处理所有的错误情况通常都是没必要也不现实的。大部分程序只会有针 对性的处理几种常见的情况,并为其它原因提供一个通用的解决方案,例如记录一条日志,或给用户显示一个错误提示。

不可忽略的错误

把安全性作为了判断一个优质错误处理系统的因素。使用内建错误处理的一个很大的好处,当调用一个可能会抛出错误的方法时,编译器不会让你忽略掉这些错误。但使用 Result,情况就不会总是如此了。

例如,考虑 Foundation 中的 Data.write(to:options:) 方法 (向文件中写入若干字节) 或 FileManager.removeItem(at:) 方法 (删除指定文件):

extension Data {
func write(to url: URL, options: Data.WritingOptions = []) throws
}
extension FileManager {
func removeItem(at URL: URL) throws
}

// 如果这些方法使用基于 Result 的错误处理方式,它们的声明看上去可能是这样的:

extension Data {
func write(to url: URL, options: Data.WritingOptions = [])
-> Result<(), Error> 
}

extension FileManager {
func removeItem(at URL: URL) -> Result<(), Error>
}
复制代码

这些方法的特别之处就是为了它们的副作用而调用它们的,而不是为了返回值。除了表示操作是否成功之外,这两个方法都没有一个真正有意义的返回值。

所以,上面这两个 Result 的版本,无论是否是故意的,对于程序员来说,都太容易忽略掉任何失败的情况了,可能会直接写出下面的代码:

_ = FileManager.default.removeItem(at: url)
复制代码

而调用 throws 版本的时候,编译器会强制调用前面使用 try 前缀。编译器还会要求要么把调用嵌套在一个 do/catch 代码块里,要么把错误传递到调用栈的上层。这都是一个明确清晰的提示:当前调用的函数是有可能执行失败的,编译器会强制要求处理相关的错误。

错误转换

在 throws 和 Optionals 之间转换

错误和可选值,都是函数要对外报告有些功能不正确时,很常用的方式。提供了一些为自定义的函数选择报错方式的建议。当把一个函数的结果传递给其它 API 时,在可抛出错误的函数和返回可选值的函数之间,难免还要在这两种表达错误的形式之间来回转换。

try? 关键字允许我们忽略函数抛出的错误,并把函数的返回值变成包含原始返回值的 Optional。 这个 Optional 可以告诉我们函数是否执行成功了:

if let result = try? parse(text: input) { 
    print(result)
}
复制代码

使用 try? 意味着将比之前获得更少的错误信息,我们唯一知道的,就是函数究竟是执行成功了,还是发生了某些错误,至于和错误相关的具体信息,则已经被丢掉无法找回了。类似的,为了把一个返回 Optional 的函数变成一个抛出错误的函数,我们得为 nil 提供相应的错误值。下面是个 Optional 的扩展,它会对自己解包,并且当自身为 nil 时,抛出一个错误:

extension Optional {
/// 如果是非 `nil` 值,就对 `self` 解包。
/// 如果 `self` 是 `nil`,就抛出错误。
    func or(error: Error) throws -> Wrapped {
        switch self {
        case let x?: return x 
        case nil: throw error 
        }
    } 
}

do{
    let int = try Int("42").or(error: ReadIntError.couldNotRead)
} catch { 
    print(error)
}
复制代码

当我们要把多个返回 Optional 的函数调用转换为可抛出异常的函数调用时,或者想把它们写在一个已经被标记为 throws 的函数中时,or(error:) 扩展就会非常有用。另外,在单元测试中, 这也是个不错的模式。如果你把一个测试用例标记成 throws,并在它内部抛出错误的话, XCTest 框架可以自动把这个测试用例标记为失败。如果你的测试用例需要依赖一个 Optional 不为 nil 才可以继续,就可以使用上面的模式来解包数据,当 Optional 为 nil 的时候,测试将会失败。而这些逻辑,只需要一行代码就能搞定了。

try? 关键字的出现,可能会引起一些争议。和 Swift 不允许忽略错误的哲学相违背。但毕竟还要明确使用 try? 关键字,这也可以看作是编译器强制你对错误进行的某种响应,而代码的读者也可以通过 try? 明确意图。因此,当对错误信息完全不感兴趣的时候,try? 是一种合理的选择。

try 还有第三种形式:try!。只有你确认函数绝对不可能发生错误的时候,才应该使用这种形式。和强制解包 Optional 类似,如果使用 try! 调用的函数抛出了错误,就会造成 App 闪退。

在 throws 和 Result 之间转换

Result 和 throws 关键字,其实只是 Swift 错误处理机制的两种不同的呈现方式。可以把 Result 看成是对可抛出错误的函数的返回值进行的改良。正是因为这种双重身份,标准库中提供了在这两种表现形式之间进行转换的方法。

为了调用一个可抛出错误的函数,并把它的返回值包装成一个 Result,可以使用 init(catching:) 初始化方法,它接受一个可抛出错误的函数作为参数,并把这个函数的返回值包装成Result 对象。它的实现是这样的:

extension Result where Failure == Swift.Error {
/// 通过评估一个可抛出错误的函数的返回值创建一个新的 `Result` 对象, 
/// 把成功的返回结果包装在 `case success` 里,而失败时抛出的错误
/// 则包装在 `case failure`里。
    init(catching body: () throws -> Success) {
        do{
            self = .success(try body())
        } catch {
            self = .failure(error)
        } 
    }
}

// 这个初始化方法用起来是这样的:

let encoder = JSONEncoder()
let encodingResult = Result { try encoder.encode([1, 2]) } // success(5 bytes)
type(of: encodingResult) // Result<Data, Error>
复制代码

如果想延迟处理错误,或者把函数的返回结果发送给其它函数,这个方法就会非常有用了。

和 init(catching:) 相反的方法叫做 Result.get()。它会评估 Result 的结果,并把 failure 中的值作为错误抛出。它的实现:

extension Result {}
    public func get() throws -> Success {
        switch self {
        case let .success(success):
            return success
        case let .failure(failure):
            throw failure 
        }
    } 
}
复制代码

错误链

接连调用多个可能抛出错误的函数是非常普遍的情况。例如,一个操作可能被分成多个子任务,每一个子任务的输出都是下一个子任务的输入。如果每个子任务都可能执行失败,一旦其中一个抛出错误,整个操作就应该立即退出。

throws 链

并不是所有的错误处理系统都可以很好地处理上面这种情况,但这在 Swift 内建错误处理机制之下就很简单了,不需要使用嵌套的 if 语句或者类似的结构来保证代码运行,只要简单地将这些函数调用放到一个 do/catch 代码块中 (或者封装到一个被标记为 throws 的函数中) 就好了。当遇到第一个错误时,调用链将结束,代码将被切换到 catch 块中,或者传递到上层调用者去。

下面这个例子,是个拥有三个子任务的操作:

func complexOperation(filename: String) throws -> [String] {
    let text = try contents(ofFile: filename)
    let segments = try parse(text: text)
    return try process(segments: segments) 
}
复制代码

Result 链

把基于 try 的例子和与之等价的使用 Result 的代码进行一次对比。将多个返回 Result 的函数手动链接起来需要很多努力。需要调用第一个函数,解包它的输出,如果遇到的是 .success,则将值传递给下一个函数重新开始这个过程。一旦函数返回了 .failure,则需要将链打断,放弃接下来的所有调用,并将这个失败返回给调用者:

func complexOperation1(filename: String) -> Result<[String], Error> { 
    let result1 = contents(ofFile: filename)
    switch result1 {
    case .success(let text):
    let result2 = parse(text: text) 
        switch result2 {
        case .success(let segments):
            return process(segments: segments) .mapError { $0 as Error }
        case .failure(let error):
            return .failure(error as Error)
        }
    case .failure(let error):
        return .failure(error as Error) 
    }
}
复制代码

这种用法就会让代码变得一团糟,每串联一个返回 Result 的函数,就需要一层额外的 switch 语句嵌套,并且还要重复一遍相同的错误处理语句。
在重构这段代码之前,我们先来看看在所有 failure 语句中是如何处理错误的。以下是这些方法 的签名:

func contents(ofFile filename: String) -> Result<String, FileError>
func parse(text: String) -> Result<[String], ParseError>
func process(segments: [String]) -> Result<[String], ProcessError>
复制代码

每个函数都有个不同的错误类型:FileError,ParseError 和 ProcessError。因此,沿着这些子 任务的调用链,不仅要转换每一步成功之后的结果 (从 String 到 [String] 再到 [String]), 还必须要把每一步可能发生的错误转换成一个聚合类型,在上面的代码中,这个聚合类型就是 Error,当然也可以是其它具体的类型。而这种错误类型的转换,一共发生了三次:

  • 前两个 return .failure(error as Error) 把 error 从具体类型转换成了一个遵守 Error 的类型。我们也可以忽略 as Error 的部分,编译器会进行隐式类型转换。但明确写出来可以表明这里真正完成的工作。
  • 在调用链的最后一步,不能简单 return process(segments: segments),因为 process 的返回值类型和 Result<[String], Error> 并不兼容。必须用 mapError (Result 提供的方法) 对错误类型再进行一次转换。

Result.flatMap 方法封装了这种根据 Result 结果决定是要继续向下个环节传递成功值,还是由于失败必须退出调用链的模式。它的结构和 flatMap 是一样的。

用 flatMap 替换掉嵌套的 switch 语句之后,是非常优雅的,尽管和 throws 的实现方案比还差了点:

func complexOperation2(filename: String) -> Result<[String], Error> { 
    return contents(ofFile: filename).mapError { $0 as Error }
    .flatMap { text in parse(text: text).mapError { $0 as Error } } .
    flatMap { segments in
    process(segments: segments).mapError { $0 as Error } 
   }
}
复制代码

要注意,还是得处理不兼容的错误类型。Result.flatMap 只会转换执行成功的结果,并保持 failure 的情况不变。因此串联多个 map 或者 flatMap 就要求 Result 中的 Failure 类型是相同的。这是通过不断调用 mapError 完成的,它们的任务就是把具体的错误类型泛化成 “一个实现了 Error 的类型”。

异步代码中的错误

Swift 内建的错误处理机制无法和通过回调函数向调用者传递错误的异步 API 搭配在一起工作。异步大数计算的例子,它通过回调函数在计算完成后通知结果:

func compute(callback: (Int) -> ())

compute { number in 
print(number)
}

复制代码

在这种模式下,应该如何集成错误呢?如果可选值足以表达要传递的错误,我们可以让回调函数接受 Int? 作为参数就好了。这样,只要回调函数收到 nil,就表示计算失败了:

func computeOptional(callback: (Int?) -> ())
复制代码

现在,在回调函数里,就必须通过某种方式对参数进行解包了,例如,使用 ?? 操作符:

computeOptional { numberOrNil in 
    print(numberOrNil ?? -1)
}
复制代码

如果想给回调函数传递多错误信息该怎么办?下面这个函数签名看上去是个很自然的做法:

func computeThrows(callback: (Int) throws -> ())
复制代码

并不表明计算大数的方法会执行失败,而是表示回调函数自身可能发生错误。把上面的回调函数用返回 Result 的形式写出来:

func computeResult(callback: (Int) -> Result<(), Error>)
复制代码

当然,上面签名不对。需要的是把计算得到的 Int 包装在 Result 里,而不是用 Result 包装回调函数的返回值:

func computeResult(callback: (Result<Int, Error>) -> ())
复制代码

Swift 内建错误处理机制对异步 API 的不兼容,体现了使用 throws 和使用 Optional 或 Result 处理错误时的一个关键区别。只有后者才可以自由地传递错误信息,而 throws 反而不那么灵活。

throw 和 return 很像,它只能沿着一个方向工作,也就是向上传递消息。可以把错误抛给函数的调用者,但不能把错误向下作为参数抛给接下来会调用的其它函数。

这种可以把错误抛给接下来会执行的函数的能力,正是在异步代码环境中进行错误处理所需要的。但不幸的是,现在还没有一种非常清晰的方式表明应该如何把 throws 用在异步的环境里。只有把 Int 包装在一个可能抛出错误的函数里,但这只能让 compute 的签名更加复杂:

func compute(callback: (() throws -> Int) -> ())
复制代码

并且,compute 用起来也会更加麻烦。为了得到计算的整数,在回调函数里调用这个会抛出错误的函数。这也就意味着要在回调函数里进行错误处理:

compute { (resultFunc: () throws -> Int) in 
    do {
        let result = try resultFunc()
        print(result) 
    } catch {
        print("An error occurred: \(error)") 
    }
}
复制代码

虽然这样可行,但绝不是 Swift 提倡的编程方式。即便无法使用 throws 同时处理同步和异步执行的代码会造成些许不便,但在异步环境中使用 Result 的确是更好的错误处理方法。

使用 defer 进行清理

很多编程语言都有 try/finally 这样的结构,当函数返回的时候,无论是否发生错误,finally 指定的代码块总是会被执行。Swift 中的 defer 关键字功能和它类似,但具体做法却稍有不同。和 finally 类似的是,当离开当前作用域的时候,defer 指定的代码块也总是会被执行,无论是因为执行成功返回,还是因为发生了某些错误,亦或是其它原因。这使得 defer 代码块成为了执行清理工作的首选场所。和 finally 不同的是,defer 不需要前置的 try 或 do 代码块,可以部署到代码中的任何地方。

func contents(ofFile filename: String) throws -> String { 
    let file = open(filename, O_RDONLY)
    defer { close(file) }
    return try load(file: file) 
}
复制代码

无论 contents 执行成功或者抛出了错误,第二行的 defer 代码块都可以确保文件在函数返回的时候可以被关闭。

虽然 defer 经常会被和错误处理一同使用,但在其他上下文中,这个关键字也很有用。例如,当把资源的初始化和清理代码放在一起的时候 (例如打开和关闭文件)。把这类代码放在一起可以极大地提高代码的可读性,尤其是在较长的函数里。

如果相同的作用域中有多个 defer 代码块,它们将按照定义的顺序逆序执行。可以把这些 defer 想象成一个栈。

let database = try openDatabase(...)
defer { closeDatabase(database) }
let connection = try openConnection(database) defer { closeConnection(connection) }
let result = try runQuery(connection, ...)
复制代码

一个 defer 代码块会在程序离开 defer 定义的作用域时被执行。甚至 return 语句的评估都会在同作用域的 defer 被执行之前完成。可以利用这个特性在返回某个变量之后再修改某个变量的值。在接下来的例子中,increment 函数在返回 counter 之后,使用 defer 代码块递增了捕获到的 counter:

var counter = 0
func increment() -> Int { 
    defer { counter += 1 }
return counter 
}
increment() // 0 
counter // 1
复制代码

Rethrows

由于函数可以抛出错误,这给那些接受函数作为参数的函数 (例如 map 或 filter) 带来了一个问题。

func filter(_ isIncluded: (Element) -> Bool) -> [Element]
复制代码

这个定义没问题,但它有个缺陷:编译器不会接受一个可抛出错误的函数作为谓词,因为 isIncluded 参数没有标记为 throws。

编写一个检查文件某种可用性的函数开始。checkFile 可以返回一个布尔值 (true 表示可用,false不可用),或者抛出一个检查文件过程中发生的错误:

func checkFile(filename: String) throws -> Bool
复制代码

假设我们有一个文件名数组,要从中筛选出不可用的文件。选择使用 filter 方法,但是编译器不会让这么干,因为 checkFile 是一个可能抛出错误的函数:

作为一种解决方案,可以在 filter 的谓词函数中进行错误处理:

let validFiles = filenames.filter { 
    filename in do {
        return try checkFile(filename: filename) 
    } catch {
        return false
    } 
}
复制代码

但这样很不方便,上面的代码用 false 遮掩了 checkFile 所有可能抛出的错误。

一种解决方案就是让标准库在 filter 的签名中,用 throws 修饰自己的谓词函数:

func filter(_ isIncluded: (Element) throws -> Bool) throws -> [Element]
复制代码

这样做可行,但它同样会带来不便。因为现在每个 filter 调用都必须用 try (或者 try!) 来修饰。 这会导致标准库中所有高阶函数都必须用 try 调用。显然,这就违背了 try 关键 字的设计初衷,它本来是帮助代码读者快速识别可抛出错误函数的。

另外一种实现方案是实现两个版本的 filter,分别接受普通的和可抛出错误的谓词函数。除了要用 try 调用谓词函数之外,这两个版本的 filter 实现,是完全一样的。可以依赖编译器根据函数重载的规则自动选择正确的版本。这样做看上去更好一些,至少不同版本的调用很清晰,但是这还是太浪费了。

幸运的是,Swift 通过 rethrows 关键字提供了一个更好的方案。用 rethrows 标记一个函数就相当于告诉编译器:这个函数只有它的参数抛出错误的时候,它才会抛出错误。因此,filter 方法最终的签名是这样的:

func filter(_ isIncluded: (Element) throws -> Bool) rethrows -> [Element]
复制代码

谓词函数仍旧被标记成了 throws 函数,表示调用者可能会传递一个可抛出错误的函数。在 filter 的实现里,必须使用 try 调用谓词函数。而 rethrows 则确保了 filter 会把谓词函数中的错误沿着调用栈向上传递,但 filter 自身不会抛出任何错误。因此,当传递的谓词函数不会抛出 错误时,编译器就不会要求使用 try 调用 filter 了。

在标准库里,几乎所有序列和集合类型中,带有函数类型参数的方法都是用 rethrows 进行了标记。其中只有一个例外,就是延迟加载的集合方法。这主要是由于 throws 无法和异步代码融合在一起工作导致的。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享