《Pro Swift》 第五章:错误处理(Errors)

你可以扩展集合,使其具有安全的下标,当值不存在时返回nil

extension Array {
    subscript(safe index: Int) -> Element? {
        return indices ~= index ? self[index] : nil
    }
}

-- Chris Eidhof (@chriseidhof), author of Advanced Swift

错误基础(Error fundamentals)

Swift 有一个独特的错误处理方式,只要你完全理解所提供的服务,它就非常灵活。我将以相对简单的方式开始,并介绍所有的错误处理技术。苹果公司的 Swift 参考指南说:throw语句的性能特征可以与return语句的性能特征相媲美,这意味着它们非常快——我们没有理由忽视它们。

让我们从一个简单的例子开始。你想抛出的所有错误必须是一个符合 Error 协议的枚举,Swift 可以桥接 Objective-C 中的 NSError 类。所以,我们定义一个这样的错误枚举:

enum PasswordError: Error {
   case empty
   case short
}

这是一个普通枚举,就像其他枚举一样,所以我们可以添加一个关联值,如下所示:

enum PasswordError: Error {
   case empty
   case short
   case obvious(message: String)
}

要将函数或方法标记为有可能抛出错误,请在其返回类型之前添加throws,如下所示:

func encrypt(_ str: String, with password: String) throws -> String {
   // complicated encryption goes here
   let encrypted = password + str + password
   return String(encrypted.characters.reversed())
}

然后使用dotrycatch的组合来运行风险代码。至少,调用我们当前的代码应该是这样的:

do {
   let encrypted = try encrypt("Secret!", with: "T4yl0r")
   print(encrypted)
} catch {
   print("Encryption failed")
}

它要么打印调用encrypt()的结果,要么打印错误消息。使用catch本身捕获所有可能的错误,这在 Swift 中是必需的。这是有时被称为Pokémon(口袋精灵) 错误处理,因为“你必须抓住他们。” 注:此限制不适用于 Playground 的顶层代码;如果你正在使用一个Playground,你应该把你的do模块放在一个函数中进行测试:

func testCatch() {
   do {
      let encrypted = try encrypt("Secret!", with: "T4yl0r")
      print(encrypted)
   } catch {
      print("Encryption failed")
   }
}
testCatch()

有时候,在一个catch块中处理所有错误可能对你有用,但是更常见的情况是,你希望捕获个别的情况。要做到这一点,请分别列出每种情况,确保通用捕获是最后一个捕获,这样只有在没有其他匹配的情况下才会使用它:

do {
   let encrypted = try encrypt("secret information!", with: "T4ylorSw1ft")
   print(encrypted)
} catch PasswordError.empty {
   print("You must provide a password.")
} catch PasswordError.short {
   print("Your password is too short.")
} catch PasswordError.obvious {
   print("Your password is obvious")
} catch {
   print("Error")
}

要处理关联值,需要将其绑定到catch块中的常量:

catch PasswordError.obvious(let message) {
   print("Your password is obvious: \(message)")
}

如果你想要在实际中测试它,请将encrypt()方法修改为:

func encrypt(_ str: String, with password: String) throws -> String {
   // complicated encryption goes here
   if password == "12345" {
      throw PasswordError.obvious(message: "I have the same number on my luggage")
   }
   let encrypted = password + str + password
   return String(encrypted.characters.reversed())
}

在继续之前,还有最后一件事:在处理关联值时,还可以使用模式匹配。为此,首先使用let将关联值绑定到局部常量,然后使用where子句进行筛选。例如,我们可以修改PasswordError。返回应该提供多少字符的简写形式:

case short(minChars: Int)

有了这些变化,我们就可以通过minChars关联值过滤来捕捉short的变化:

catch PasswordError.short(let minChars) where minChars < 5 {
   print("We have a lax security policy: passwords must be at least \(minChars)")
} catch PasswordError.short(let minChars) where minChars < 8 {
   print("We have a moderate security policy: passwords must be at least \(minChars)")
} catch PasswordError.short(let minChars) {
   print("We have a serious security policy: passwords must be at least \(minChars)")
}

错误传递(Error propagation)

当你使用try调用函数时,Swift 会强制你处理任何可能的错误。这有时是无益的行为:如果函数 A 调用函数 B,函数 B 调用函数 C,那么谁应该处理由 C 抛出的错误?如果你的答案是 B,那么你现有的错误处理知识就足够了。

如果你的答案是 A ——也就是说,一个调用者应该处理它调用的任何函数中的一些或所有错误,以及这些函数调用的函数中的任何错误,等等,你需要了解一下错误传递。

让我们对 A()、B()、C() 函数调用以及我们已经使用的 PasswordError枚举的精简版本建模:

enum PasswordError: Error {
   case empty
case short
   case obvious
}
func functionA() {
   functionB()
}
func functionB() {
   functionC()
}
func functionC() {
   throw PasswordError.short
}

该代码不能按原样编译,因为functionC()会抛出一个错误,但没有使用throws标记。如果我们加上这个,代码如下:

func functionC() throws {
   throw PasswordError.short
}

但是现在代码仍然无法编译,因为functionB()在不使用try的情况下调用了一个抛出函数。现在我们看到了几个选项,我想单独研究它们。

第一个选项是捕获functionB()中的所有错误。如果希望functionA()忽略其下面发生的任何错误,则可以使用此选项,如下所示:

func functionA() {
   functionB()
}
func functionB() {
   do {
      try functionC()
   } catch {
      print("Error!")
   }
}
func functionC() throws {
   throw PasswordError.short
}

你可以向functionB()添加单独的catch块,但是原理是一样的。

第二个选项是functionB()忽略错误,让它们向上冒泡到自己的调用者,这称为错误传递。为此,我们需要将do/catch代码从functionB()移到functionA()。然后我们只需要用throws来标记functionB(),就像这样:

func functionA() {
   do {
      try functionB()
   } catch {
      print("Error!")
   }
}
func functionB() throws {
   try functionC()
}
func functionC() throws {
   throw PasswordError.short
}

在讨论第三个选项之前,我希望你仔细研究一下functionB()的当前代码:这是一个使用try的函数,周围没有do/catch块。这非常好,只要函数本身被标记为throws,那么任何错误都可以继续向上传递。

第三个选项是将错误处理的部分委托给最合适的函数。例如,你可能希望functionB()捕捉空密码,而functionA()处理所有其他错误。Swift 通常希望所有的try/catch块都是详尽的,但是如果你在一个抛出函数中,这个要求就被放弃了——任何你没有捕获的错误都会向上传播。

下面的代码中functionB()处理空密码,functionA()处理其他所有事情:

func functionA() {
  do {
     try functionB()
  } catch {
     print("Error!")
  }
}
func functionB() throws {
  do {
     try functionC()
  } catch PasswordError.empty {
     print("Empty password!")
  }
}
func functionC() throws {
  throw PasswordError.short
}

最终,必须捕获所有错误用例,因此在某个时候,你需要一个通用的catch all语句。

将抛出函数作为参数(Throwing functions as parameters)

现在我将介绍一下 Swift 的一个非常有用的特性,我之所以这么说,是因为如果你发现自己在质疑为什么它有用,我想确保你能坚持下去——相信我,这是值得的!

首先,这里有一条来自 Swift 参考指南 的重要引用:非抛出函数是抛出函数的子类型。因此,你可以在与抛出函数相同的位置使用非抛出函数。

想一想,让它深入:非抛出函数是抛出函数的子类型,因此可以在需要抛出函数的任何地方使用它们。如果你愿意,甚至可以编写如下代码,尽管你会收到编译器警告,因为这是不必要的:

func definitelyWontThrow() {
   print("Shiny!")
}
try definitelyWontThrow()

真正重要的是,当你使用一个抛出函数作为参数时,我想给你一个实际的例子,这样你就能在实际环境中学习所有这些。

设想一个应用程序必须远程或本地获取用户数据,然后对其进行操作。有一个函数可以获取远程用户数据,如果存在网络问题,这个函数可能会抛出一个错误。还有第二个函数来获取本地用户数据,它保证能够工作,因此不会抛出。最后,还有第三个函数调用这两个获取函数中的一个,然后对结果执行操作。

把最后一个函数放到一边,初始代码可能是这样的:

enum Failure: Error {
   case badNetwork(message: String)
   case broken
}
func fetchRemote() throws -> String {
   // complicated, failable work here
   throw Failure.badNetwork(message: "Firewall blocked port.")
}
func fetchLocal() -> String {
   // this won't throw
   return "Taylor"
}

第三个函数是有趣的地方:它需要调用fetchRemote()fetchLocal()并对获取的数据进行处理。这两个函数都不接受任何参数,并返回一个字符串,但是一个函数被标记为throws,另一个函数没有。

回想一下我几分钟前写的:你可以在任何需要抛出函数的地方使用非抛出函数。我们可以这样写一个函数:fetchUserData(using closure: () throws -> String)。让我们来分解一下:

  • 名为fetchUserData()
  • 它接受闭包作为参数
  • 该闭包必须不接受任何参数并返回字符串。

但是闭包不需要抛出:我们说过它可以抛出,但它不必抛出。记住这一点,第一次传递fetchUserData()函数可能是这样的:

func fetchUserData(using closure: () throws -> String) {
   do {
      let userData = try closure()
      print("User data received: \(userData)")
   } catch Failure.badNetwork(let message) {
      print(message)
   } catch {
      print("Fetch error")
   }
}
fetchUserData(using: fetchLocal)

如你所见,我们可以从本地获取切换到远程获取,只需要改变最后一行:

fetchUserData(using: fetchRemote)

所以,我们传入的闭包是否抛出并不重要,只要我们声明它可能抛出并适当地处理它。

当你想用一个可能会抛出异常的闭包作为参数来使用错误传递时,事情就会变得有趣——有趣的是,我的意思是非常棒。坚持住——我们快结束了!

一个简单的解决方案可以将fetchUserData()声明为抛出,然后捕获调用者的错误,如下所示:

func fetchUserData(using closure: () throws -> String) throws {
   let userData = try closure()
   print("User data received: \(userData)")
}
do {
   try fetchUserData(using: fetchLocal)
} catch Failure.badNetwork(let message) {
   print(message)
} catch {
   print("Fetch error")
}

在这种情况下,这是可行的,但是 Swift 有一个更聪明的解决方案。这个fetchUserData()可以在我们的应用程序的其他地方调用,可能不止一次——如果所有的try/catch代码都在其中,这会变得非常混乱,特别是当我们使用fetchLocal()并且知道它不会抛出的时候。

Swift 的解决方案是rethrow关键字,我们可以用它来替换fetchUser函数中的常规抛出,如下所示:

func fetchUserData(using closure: () throws -> String) rethrows {
   let userData = try closure()
   print("User data received: \(userData)")
}

因此,闭包会throws,但是fetchUserData()函数会rethrows。区别可能看起来很细微,但这段代码现在将在 Xcode 中产生一个警告:

do {
   try fetchUserData(using: fetchLocal)
} catch Failure.badNetwork(let message) {
   print(message)
} catch {
   print("Fetch error")
}

如果将try fetchUserData(using: fetchLocal)替换为fetchUserData(using: fetchRemote),则警告将消失。现在发生的情况是,Swift 编译器正在逐个检查每个对fetchUserData()的调用,现在只需要在传入有可能抛出异常的闭包时使用try/catch

因此,当你使用fetchUserData(using: fetchLocal)时,编译器可以看到try/ catch是不必要的,但当你使用fetchUserData(using: fetchRemote)时,Swift 将确保你正确地捕获错误。

所以,当你传递一个会抛出异常的闭包的时候,你会得到你想从 Swift 得到的所有安全,但是当你传递一个不会抛出异常的闭包的时候,你不需要添加无意义的try/catch代码。

有了这些知识,再看看短路逻辑&&运算符的代码,从 Swift 源代码中获取:

public static func && (lhs: Bool, rhs: @autoclosure () throws -> Bool) rethrows -> Bool {
   return lhs ? try rhs() : false
}

现在你应该能够准确地分解它的作用:&&的右边是一个自动闭包,因此只有当左边的值为true时才会执行它。rhs被标记为会抛出异常(即使可能没有),而整个函数被标记为rethrow,以便调用者仅在必要时才需要使用try/catch

我希望你能同意 Swift 的错误处理方法是非常漂亮的:你越深入研究它,你就越能欣赏它的精妙之处。

try vs try? vs try!

Swift 的错误处理有三种形式,它们都有各自的用途。在调用被标记为throws函数时都会用到,但其含义有细微的不同:

  1. 当使用try时,必须有一个catch块来处理发生的任何错误。
  2. 当使用try?时,如果抛出任何错误,你调用的函数将自动返回nil。你不需要捕获它们,但是你需要知道你的返回值是可选的。
  3. 当使用try!,如果抛出任何错误,该函数将使应用程序崩溃。

我对它们进行了编号,因为这是你应该使用它们的顺序:到目前为止,通常try是最常见的,其行为就像我们目前看到的那样;try?是一种安全而有用的后备方法,如果使用得当,将大大提高代码的可读性;try!意思是“抛出错误不太可能——或者不太受欢迎——以至于我愿意接受崩溃”,这是不常见的。

现在,你可能想知道为什么要try!甚至存在:如果你确定它不会抛出错误,那么为什么函数一开始就标记为throws呢?那么,考虑一下从应用程序包中读取文件的代码。如果文件不存在或不可读,从字符串内容加载文件可能会引发错误,但如果出现这种情况,则你的应用显然处于非常坏的状态——强制崩溃很可能是理想的结果,而不是允许用户继续使用损坏的应用。选择权在你。

在这三种方法中,只有常规的try需要一个do/catch块,所以如果你正在寻找简洁的代码,你可能想要使用try?。我已经介绍过try了,try!try?实际上是一样的,只是你得到的不是nil返回值,而是崩溃,所以我将重点在这里使用try?

try?表示安全地展开其可选返回值,如下所示:

if let savedText = try? String(contentsOfFile: "saved.txt") {
   loadText(savedText)
} else {
   showFirstRunScreen()
}

你也可以使用空值合并操作符??。若返回nil,则使用默认值,从而完全消除可选性:

let savedText = (try? String(contentsOfFile: "saved.txt")) ?? "Hello, world!"

在极少数情况下,我使用try?有点像 UDP :试试这个,但我不在乎它是否失败。互联网背后最重要的两种协议是 TCPUDPTCP 保证所有的数据包都会到达,并且会一直尝试重新发送,直到某个时间过期;例如,它用于下载,因为如果缺少 ZIP 文件的一部分,那么就什么都没有了。

UDP 只发送一次所有的数据包,并希望是最好的:如果它们到达,很好;如果不是,就等到下一个到达。UDP 对于视频流之类的事情很有用,在视频流中,你不在乎是否丢失了一秒的实时视频流,更重要的是新视频不断出现。

所以,try?可以像 UDP 一样使用:如果你不关心返回值是什么,而只想尝试做一些事情,那么try?适合你:

_ = try? string.write(toFile: somePathHere, atomically: true, encoding: String.Encoding.utf8)

断言(Assertions)

断言允许你声明某些条件必须为真。这个条件由你决定,可以像你希望的那样复杂,但是如果它的计算结果为false,你的程序将立即停止。断言在 Swift 中被巧妙地设计,以至于阅读其源代码是一项有价值的练习。

Xcode 中编写断言时,代码提示将为你提供两个选项:

assert(condition: Bool)
assert(condition: Bool, message: String)

在第一个选项中,你需要提供要测试的条件;在第二个选项中,如果条件的计算结果为false,你还需要提供要显示的消息。在下面的代码中,第一个断言将计算为true,因此不会发生任何事情;在第二个断言中,它将失败,因为条件为false,因此将打印消息:

assert(1 == 1)
assert(1 == 2, "Danger, Will Robinson: mathematics failure!")

很明显,断言基本算法并没有多大用处,因此你通常会编写如下代码:

let success = runImportantOperation()
assert(success == true, "Important operation failed!")

断言在编写复杂的应用程序时非常有用,因为你可以将它们分散到整个代码中,以确保一切正常。你可以想测试多少就测试多少,这意味着你的应用程序中出现了任何意想不到的状态——当你想知道某些变量是如何设置的?—— 在开发早期就被捕获。

要了解断言如何工作,请查看 Swift 源代码中的实际类型签名:

public func assert(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line)

最后两个参数是 Swift 编译器提供的默认值,你不太可能想要更改它们:#file替换为当前文件的名称,#line替换为触发断言的代码行号。这些参数以默认值的形式传入(而不是在函数中指定),以便 Swift 使用调用站点的文件名和行号,而不是assert()中的某些行。

前两个参数更有趣:条件参数和消息参数都使用@autoclosure,这意味着它们不会立即执行。这很重要,因为内部 Swift 只有在调试模式下才会执行断言。这意味着你可以在应用程序中断言数百次甚至数千次,但所有这些工作只有在调试时才能完成。当 Swift 编译器以 Release 模式运行时,将跳过此工作。

这里是assert()的主体,直接来自 Swift 源代码:

if _isDebugAssertConfiguration() {
   if !_branchHint(condition(), expected: true) {
      _assertionFailed("assertion failed", message(), file, line, flags: _fatalErrorFlags())
   }
}

带下划线前缀的函数是内部函数,但是你应该能够猜到它们的作用:

  • _isDebugAssertConfiguration()如果未处于调试模式,则返回false。这就是在为发布而构建时导致断言消失的原因。
  • !_branchHint(condition(),expected:true)运行@autoclosure创建的条件闭包。它告诉编译器期望条件计算成功(大多数情况下应该是这样),这有助于优化代码。这只会影响调试,但有助于你的断言更快地运行。
  • 如果在调试模式下,调用condition()返回false,则调用_assertionfailed()终止程序。此时,将调用message()闭包以打印有用的错误。

@autoclosure的使用非常适合这种情况,因为只有在调试模式下才会运行它。但是你可能想知道为什么message参数也是一个自动闭包—它不只是一个字符串吗?这就是assert()变得非常聪明的地方:因为message是一个闭包,所以你可以在最终返回字符串之前运行任何其他代码,除非断言失败,否则不会调用任何代码。

最常见的用法是在使用日志系统时:在将消息返回到assert()之前,消息闭包可以将错误写入日志。下面是一个简单的示例,它在返回消息之前将消息写到文件中—基本上只是一个传递,添加了一些额外的功能:

func saveError(message: String, file: String = #file, line: Int = #line) -> String {
   _ = try? message.write(toFile: pathToDebugFile, atomically: true, encoding: String.Encoding.utf8)
   return message
}
assert(1 == 2, saveError(message: "Fatal error!"))

先决条件(Preconditions)

只有当应用程序在调试模式下运行时才会检查断言,这在开发时很有用,但在发布模式下会自动停用断言。如果你希望在发布模式下进行断言(请记住,失败会导致你的应用程序立即终止),那么应该使用precondition()

它和assert()使用相同的参数,但编译方式不同:如果使用-onone-o(无优化或标准优化)生成,则失败的先决条件将导致应用程序终止。如果使用-ounchecked(最快的优化级别)构建,则仅会忽略前提条件。如果你使用的是 Xcode,这意味着 Disable Safety Checks选项设置为 Yes

就像使用try!一样,有一个很好的理由让你可能想在发布模式下崩溃你的应用程序:如果某个东西出了致命的错误,表明你的应用程序处于不稳定、未知、甚至可能是危险的状态,那么与其继续下去,冒着严重数据丢失的风险,不如出手相救。

在关于运算符重载的一章中,我对*运算符进行了修改,它允许我们将两个数组相乘:

func *(lhs: [Int], rhs: [Int]) -> [Int] {
   guard lhs.count == rhs.count else { return lhs }
   var result = [Int]()
   for (index, int) in lhs.enumerated() {
      result.append(int * rhs[index])
   }
   return result
}

如您所见,该函数以一个guard开始,以确保两个数组的大小完全相同——如果不是,我们只返回左侧的操作数。在许多情况下,这是安全的编程,但也有可能,如果我们的程序最终有两个不同大小的数组,那么严重的问题已经发生,我们应该停止执行。在这种情况下,使用precondition()可能更好:

func *(lhs: [Int], rhs: [Int]) -> [Int] {
   precondition(lhs.count == rhs.count, "Arrays were not the same size")
   var result = [Int]()
   for (index, int) in lhs.enumerated() {
      result.append(int * rhs[index])
   }
   return result
}
let a = [1, 2, 3]
let b = [4, 5]
let c = a * b

记住,启用-Ounchecked将有效地禁用你的先决条件,但它也禁用其他边界检查——这就是为什么它这么快的原因!

你可能感兴趣的:(《Pro Swift》 第五章:错误处理(Errors))