你可以扩展集合,使其具有安全的下标,当值不存在时返回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())
}
然后使用do
、try
和catch
的组合来运行风险代码。至少,调用我们当前的代码应该是这样的:
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
函数时都会用到,但其含义有细微的不同:
- 当使用
try
时,必须有一个catch
块来处理发生的任何错误。 - 当使用
try?
时,如果抛出任何错误,你调用的函数将自动返回nil
。你不需要捕获它们,但是你需要知道你的返回值是可选的。 - 当使用
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 :试试这个,但我不在乎它是否失败。互联网背后最重要的两种协议是 TCP 和 UDP。TCP 保证所有的数据包都会到达,并且会一直尝试重新发送,直到某个时间过期;例如,它用于下载,因为如果缺少 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
将有效地禁用你的先决条件,但它也禁用其他边界检查——这就是为什么它这么快的原因!