木又的《Swift进阶》读书笔记——错误处理

错误处理

《Swift进阶》的作者认为一个良好的错误处理架构应该具备以下特征:

  • 简洁 (Conciseness):不应该让逻辑正确的代码淹没在抛出错误的代码里。
  • 可传递 (Propagation):错误不一定要在发生的原地进行处理。通常,让错误处理的代码远离错误发生的地方更好。
  • 文档 (Documentation):让程序员可以确定错误可能会发生的位置和具体的错误类型。
  • 安全 (Safety):可以帮助程序员出于意外原因而忽略错误处理。
  • 通用 (Universality):制定一个可以用于所有场景的抛出错误和处理错误的机制。

错误类型

  • 可以忽略细节的错误 (Trivial errors):有些操作只有一个可以预期的失败情况。
  • 需要提供详细信息的错误 (Rich errors):对于网络和文件系统操作,它们应该提供关于失败情况的更多实质性问题描述,而不能只是一句简单的“有些东西工作”而已。
  • 非预期错误:指的是那些在程序员预料之外的条件下导致的错误。处理这类错误的方式通常就是让程序崩溃。

在代码中,我们使用各种类型的断言 (例如:assert,precondition,或者 fatalError) 来确认期望的结果,并且在不满足条件的时候,让程序中断。

Result 类型

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

来写一个从磁盘读取文件的函数。

enum FileError: Error {
  case fileDoesNotExit
  case noPermission
}
复制代码
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")
  }
}
复制代码

抛出和捕获

对于每个可以抛出错误的函数调用,编译器都会验证调用者有没有捕获错误,或者把这个错误向上传递给它调用者。把之前实现的 contents(ofFile:)throws 语法表达出来是这样的:

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

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

  • Swift 原生的错误机制使用了 无类型错误 (untyped errors)。我们只能用 throws 声明函数会抛出错误,但无法指定它究竟会抛出那些具体的错误。

  • Result,属于具体类型错误 (typed errors)。Result 带有两个泛型参数,Success 和 Failure,而后者指定了错误的具体类型。

  • 具体类型错误也有它自己严重的问题:

    • 具体错误类型使得组合会抛出异常的函数,以及聚合这些函数抛出的错误都非常困难。
    • 严格的错误类型会限制程序库的可扩展性。
    • 和必须完整遍历枚举的所有成员不同,要求处理所有的错误情况通常都是没必要也不现实的。

    由于错误是无类型的,在文档中记录函数有可能抛出的错误就显得尤为重要。Xcode 支持在代码注释中使用 Throws 关键字 标记函数抛出的错误。下面就是个例子:

    /// Opens a text file and returns its contents.
    ///
    /// - Parameter filename: The name of the file to read.
    /// - Returns: The file contents, interpreted as UTF-8.
    /// - Throws: `FileError` if the file does not exist or
    ///      the process doesn't have read permissions.
    func contents(ofFile filename: String) throws -> String
    复制代码

    这样,当你按住 Option 点击函数名的时候,在弹出的快速帮助面板中,就会出现一块专门用于显示错误的区域了。

不可忽略的错误

举个栗子,考虑 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 之间转换

错误和可选值,都是函数要对外报告有些功能不正确时,很常用的方式。

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

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

类似的,为了把一个返回 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)
}
复制代码

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

在 throws 和 Result 之间转换

Resultthrows 关键字,其实只是 Swift 错误处理机制的两种不同的呈现方式。

为了调用一个可抛出错误的函数,并把它的返回值包装成一个 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):
      	throws failure
    }
  }
}
复制代码

错误链

接连调用多个可能抛出错误的函数是非常普遍的情况。

throws 链

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

Result 链

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 } }
}
复制代码

mapError 的任务就是把具体的错误类型泛化成“一个实现了 Error 的类型”。

异步代码中的错误

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

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

现如今,我们只能在会发生错的异步代码里,坚持使用 Result

使用 defer 进行清理

  • 当离开当前作用域的时候,defer 指定的代码块总是会被执行,无论是因为执行成功返回,还是因为发生了某些错误,亦或是其他原因。这使得 defer 代码块成为了执行清理工作的首选场所。
  • 如果相同的作用域中有多个 defer 代码块,它们将按照定义的顺序 逆序 执行。
  • 一个 defer 代码块会在程序离开 defer 定义的作用域时被执行。甚至 return 语句的评估都会在同作用域的 defer 被执行之前完成。你可以利用这个特性在返回某个变量之后再修改某个变量的值。
  • 当然,也有一些 defer 语句不会执行的情况,例如:当程序发生段错误,或者触发了致命错误的时候 (使用 fatalError 函数或者强制解包 nil),这时所有的代码执行都会立即终止。

Rethrows

rethrows 标记一个函数就相当于告诉编译器:这个函数只有它的参数抛出错误的时候,它才会抛出错误。因此。filter 方法最终的签名是这样的:

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

谓词函数仍旧被标记成了 throws 函数,表示调用者可能会传递一个可抛出错误的函数。

在标准库里,几乎所有序列和集合类型中,带有函数类型参数的方法都是用 rethrows 进行了标记。其中只有一个例外,就是延迟加载的集合方法。

将错误桥接到 Objective-C

举个栗子,Objective-C 版本的 contents(ofFile:) 写出来可能是这样的:

- (NSString *)contentsOfFile(NSString *)filename error: (NSError **)error;
复制代码

Swift 会自动把接受 NSError ** 作为参数的方法转换为 throws 语法的版本。它被导入到 Swift 就会变成这样:

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

如果你把一个 Swift 错误传递给 Objective-C 的方法,类似地,它将被桥接为 NSError

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