错误和异常处理
在开始这一节的内容之前,我想先阐明两个在很多时候被混淆的概念,那就是异常(exception)和错误(error)。
在Objective-C开发中,异常往往是由程序员的错误导致的app无法继续运行,比如我们向一个无法响应某个消息的 NSObject
对象发送了这个消息,会得到 NSInvalidArgumentException
的异常, 并告诉我们"unrecognized selector sent to instance"; 比如我们使用一个超过数组元素数量的下标来试图访问 NSArray
的元素时,会得到 NSRangeException
。类似由于这样所导致的程序无法运行的问题应该在开发阶段就被全部解决,而不应当出现在实际的产品中。相对来说,由 NSError
代表的错误更多地是指那些“合理的”,在用户使用app中可能遇到的情况: 比如登陆时用户名密码验证不匹配,或者试图从某个文件中读取数据生成 NSData
对象时发生了问题(比如文件被意外修改了)等等。
但是 NSError
的使用方式其实变相在鼓励开发者忽略错误。想一想在使用一个带有错误指针的API时我们做的事情吧。我们会在API调用中产生和传递 NSError
,并藉此判断调用是否失败。 作为某个可能产生错误的方法的使用者,我们用传入 NSErrorPointer
指针的方式来存储错误信息,然后在调用完毕后去读取内容,并确认是否发生了错误。比如在Objective-C中,我们会写类似这样的代码:
NSError *error;
BOOL success = [data writeToFile: path options: options error: &error];
if(error) {
//发生了错误
}
这非常棒,但是有一个问题:在绝大多数情况下,这个方法并不会发生什么错误,而很多工程师也为了省事和简单,会将输入的error设为 nil
,也就是不关心错误(因为可能他们从没见过这个API返回错误,也不知要如何处理)。于是调用就变成了这样:
[data writeToFile: path options: options error: nil];
但是事实上这个API调用是会出错的,比如设备的磁盘空间满了的时候,写入将会失败。但是当这个错误出现并让你的app陷入难堪境地的时候,你几乎无从下手进行调试--因为系统曾经尝试过通知你出现了错误,但是你却选择视而不见。
在Swift 2.0中,Apple为这門语言引入了异常机制。现在,这类带有 NSError
指针作为参数的API都被改为了可以抛出异常的形式。比如上面的 writeToFile:options:error:
,在Swift中变成 了:
open func write(toFile path: String, options writeOptionsMask: NSData.WritingOptions = []) throws
我们在使用这个API的时候,不再像之前那样传入一个error指针去等待方法填充,而是变为使用 try catch
语句:
do {
try data.write(toFile: "Hello", options: [])
} catch let error as NSError {
print("Error: \(error.domain)")
}
// 输出: Error: NSCocoaErrorDomain
如果你不使用 try
的话, 是无法调用 write(toFile:options:)
方法的, 它会产生一个编译错误, 这让我们无法有意无意地忽视掉这些错误. 在上面的 catch
将抛出的异常(这里就是个 NSError
) 用 let 进行了类型转换, 这其实主要是针对 Cocoa 现有的 API 的, 是对历史的一种妥协. 对于我们新写的可抛出异常的API,我们应当抛出一个实现了 Error
协议的类型,enum
就非常合适,举个例子 :
//************* 同步API使用异常机制 *************
//users 是 [String: String] , 存储[用户名: 密码]
private var users = ["lirui":"123456","xiaoMing":"111111"]
enum LoginError: Error {
case userNotFound, userPasswordNotMatch
}
func login(user: String, password: String) throws {
if !users.keys.contains(user) {
throw LoginError.userNotFound
}
if users[user] != password {
throw LoginError.userPasswordNotMatch
}
print("login successfully.")
}
这样的 Error
可以非常明确地指出问题所在。在调用时,catch
语句实质上是在进行模式匹配:
//************* 同步API使用异常机制 *************
do {
try login(user: "lirui", password: "123456")
} catch LoginError.userNotFound {
print("UserNotFound")
} catch LoginError.userPasswordNotMatch {
print("UserPasswordNotMatch")
} catch {
//cathch 完枚举的错误以后,要把catch关闭,即最后加入一个空的catch,否则则会报错:
//"Error throw from are not handled because the enclosing catch is not exhaustive" 意思就是catch没有关闭
}
//输出: login successfully.
do {
try login(user: "lirui", password: "12345")
} catch LoginError.userNotFound {
print("UserNotFound")
} catch LoginError.userPasswordNotMatch {
print("UserPasswordNotMatch")
} catch {
}
//输出: UserPasswordNotMatch
do {
try login(user: "lixu", password: "123456")
} catch LoginError.userNotFound {
print("UserNotFound")
} catch LoginError.userPasswordNotMatch {
print("UserPasswordNotMatch")
} catch {
}
//输出: UserNotFound
可以看出,在Swift中,我们虽然把这块内容叫做“异常”,但是实质上它更多的还是“错误”而非真正意义上的异常。
Swift现在的异常机制并不是十全十美的。最大的问题是类型安全,不借助于文档的话, 我们现在是无法从代码中直接得知所抛出的异常的类型的。比如上面的login
方法,光看方法定义我们并不知道 LoginError
会被抛出。一个理想中的异常API可能应该是这样的:
func login(user: String, password: String) throws LoginError
很大程度上,这是由于要与以前的 NSError
兼容所导致的妥协,对于之前的使用 Error
来表达错误的API,我们所得到的错误对象本身就是用像domain或者error number这样的属性来进行区分和定义的,这与Swift 2.0中的异常机制所抛出的直接使用类型来描述错误的思想暂时是无法兼容的。不过有理由相信随着Swift的迭代更新,这个问题会在不久的将来得到解决。
另一个限制是对于非同步的API来说,抛出异常是不可用的--异常只是一个同步方法专用的处理机制。Cocoa框架里对于异步API出错时,保留了原来的 Error
机制,比如很常用的 URLSession
中的 dataTask
API:
open func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Swift.Void) -> URLSessionDataTask
对于异步API,虽然不能使用异常机制,但是因为这类API一般涉及到网络或者耗时操作,它所产生错误的可能性要高得多,所以开发者们其实无法忽视这样的错误。但是像上面这样的API其实我们在日常开发中往往并不会去直接使用,而会选择进行一些封装,以求更方便地调用和维护。一种现在比较常用的方式就是借助于 enum
。作为Swift的一个重要特性,枚举(enum)类型现在是可以与其他的实例进行绑定的,我们还可以让方法返回枚举类型,然后在枚举中定义成功和错误的状态,并分别将合适的对象与枚举值进行关联:
//************* 异步API使用泛型枚举 *************
enum Result {
case Success(String)
case Error(NSError)
}
func doSomethingParam(_ param: AnyObject) -> Result {
//... 做某些操作吗, 成功结果放在 success 中
if success {
return Result.Success("成功完成")
} else {
let error = NSError(domain: "errorDomain", code: 1, userInfo: nil)
return Result.Error(error)
}
}
在使用时,利用 switch 中的 let 来从枚举值中将结果取出即可:
//************* 异步API使用泛型枚举 *************
let result = doSomethingParam("http://www.baidu.com" as AnyObject)
switch result {
case let .Success(ok):
let serverResponse = ok
print(serverResponse)
case let .Error(error):
let serverResponse = error.description
print(serverResponse)
}
//输出: Error Domain=errorDomain Code=1 "(null)"
在 Swift 2.0 中, 我们甚至可以在 enum
中指定泛型,这样就可以使结果统一化了:
//************* 异步API使用泛型枚举 *************
enum Result<T> {
case Success(T)
case Error(NSError)
}
func doSomethingParam(_ param: AnyObject) -> Result<String> {
//... 做某些操作吗, 成功结果放在 success 中
if success {
return Result.Success("成功完成")
} else {
let error = NSError(domain: "errorDomain", code: 1, userInfo: nil)
return Result.Error(error)
}
}
我们只需要在返回结果时指明 T
的类型,就可以使用同样的 Result
枚举来代表不同的返回结果了。这么做可以减少代码复杂度和可能的状态,同时不是优雅地解决了类型安全的问题,可谓一举两得。
因此,在Swift 2时代中的错误处理,现在一般的最佳实践是对于同步API使用异常机制,对于异 步API使用泛型枚举。
关于 try
和 throws
,想再多讲两个小点。首先,try
可以接 !
表示强制执行,这代表你确定知道这次调用不会抛出异常。如果在调用中出现了异常的话,你的程序将会崩溃,这和我们在对Optional值用 !
进行强制解包时的行为是一致的。另外,我们也可以在后面加上 ?
来进行尝试性的运行。会返回一个Optional值: 如果运行成功,没有抛出错误的话,它会包含这条语句的返回值,否则将为 nil
。和其他返回Optional的方法类似,一个典型的的 try?
应用场景是和 if let
这样的语句搭配使用,不过如果你用了try?
的话,就意味着你无视了错误的具体类型:
enum E: Error {
case Negative
}
func methodThrowsWhenPassingNegative(number: Int) throws -> Int {
if number < 0 {
throw E.Negative
}
return number
}
if let num = try? methodThrowsWhenPassingNegative(number: 100) {
print(type(of: num))
} else {
print("failed")
}
// 输出: Int
值得一提的是,在一个可以throw的方法里,我们永远不应该返回一个Optional的值。因为结合 try?
使用的话,这个Optional的返回值将被再次包装一层Optional,使用这种双重Optional的值非常容易产生错误,也十分让人迷惑。也就是说,像下面这样的代码是绝对应该避免的:
// Never do this!
func methodThrowsWhenPassingNegative1(number: Int) throws -> Int? {
if number < 0 {
throw E.Negative
}
if number == 0 {
return nil
}
return number
}
if let num = try? methodThrowsWhenPassingNegative1(number: 0) {
print(type(of: num))
print(num)
} else {
print("failed")
}
// 输出:
// Optional<Int>
// nil
// Optional<Int> 其实里面包装的是一个 nil
含有 throws
的方法会抛出一个异常,也有细心读文档的朋友会发现,在Swift 2.0中还有一个类似的关键字: rethrows
。其实 rethrows
和 throws
做的事情并没有太多不同,它们都是标记了一 个方法应该抛出错误。但是 rethrows
一般用在参数中含有可以 throws
的方法的高阶函数中,来表示它既可以接受普通函数,也可以接受一个能 throw 的函数作为参数。也就是像是下面这样的 方法,我们可以在外层用进行标注:
// rethrows
func methodThrows(num: Int) throws {
if num < 0 {
print("throwing")
throw E.Negative
}
print("Excuted!")
}
func methodRethrows(num: Int, f: (Int) throws -> ()) rethrows {
try f(num)
}
do {
try methodRethrows(num: 1, f: methodThrows)
} catch {
}
//输出:Excuted!
其实在这种情况下,我们简单地把 rethrows
改为 throws
,这段代码依然能正确运行。但是rethrows
和 throws
还有有所区别的。简单理解的话你可以将看做 rethrows
是 throws
的 “子类”,rethrows
的方法可以用来重载那些被标为 throws
的方法或者参数,或者用来满足被标为 throws
的协议,但是反过来不行。如果你拿不准要怎么使用的话,就先记住你在要另一个 throws
时,应该将前者改为 rethrows
。这样在不失灵活性的同时保证了代码的可读性和准确性。标准库中很常用的 map
,reduce
等函数式特点鲜明的函数都采用了 rethrows
的方式来拓展适用范围。
断言
断言(assertion)在Cocoa开发里一般用来在检查输入参数是否满足一定条件,并对其进行“论断”。这是一个编码世界中的哲学问题,我们代码的使用者(有可能是别的程序员,也有可能是未来的自己)很难做到在不知道实现细节的情况下去对自己的输入进行限制。大多数时候编译器可以帮助我们进行输入类型的检查,但是如果代码需要在特定的输入条件下才能正确运行的话,这种更细致的条件就难以控制了。在超过边界条件的输入的情况下,我们的代码可能无法正确工作,这就需要我们在代码实现中进行一些额外工作。
一种容易想到的做法是在方法内部使用 if
这样的条件控制来检测输入,如果遇到无法继续的情况,就提前返回或者抛出错误。但是这样的做法无疑增加了API使用的复杂度,也导致了很多运行时的额外开销。对于像判定输入是否满足某种条件的运用情景,我们有更好的选择,那就是断言。
Swift为我们提供了一系列的方法来使用断言,其中最常用的一个是:
public func assert(_ condition: @autoclosure () -> Bool,
_ message: @autoclosure () -> String = default,
file: StaticString = #file,
line: UInt = #line)
在使用时,最常见的情况是给定条件和一个简单的说明。举一个在温度转换时候的例子, 我们想 要把摄氏温度转为开尔文温度的时候,因为绝对零度永远不能达到,所以我们不可能接受一个小 于-273.15摄氏度的温度作为输入:
let absoluteZeroInCelsius = -237.15
// 将摄氏温度转化为开尔文温度
func convertToKelvin(_ celsius: Double) -> Double {
assert(celsius > absoluteZeroInCelsius, "输入的摄氏温度不能低于绝对零度")
return celsius - absoluteZeroInCelsius
}
let roomTemperature = convertToKelvin(27)
let tooCold = convertToKelvin(-300)
// 运行时错误:
// assertion failed:
// 输入的摄氏温度不能低于绝对零度: file {YOUR_FILE_PATH}, line {LINE_NUMBER}
在遇到无法处理的输入时,运行会产生错误,保留堆栈,并抛出我们预设的信息,用来提醒调用这段代码的用户。
断言的另一个优点是它是一个开发时的特性,只有在Debug编译的时候有效,而在运行时是不被编译执行的,因此断言并不会消耗运行时的性能。这些特点使得断言成为面向程序员的在调试开发阶段非常合适的调试判断,而在代码发布的时候,我们也不需要刻意去将这些断言手动清理掉,非常方便。
虽然默认情况下只在Release的情况下断言才会被禁用,但是有时候我们可能出于某些目的希望断言在调试开发时也暂时停止工作,或者是在发布版本中也继续有效。我们可以通过显式地添加编译标记达到这个目的。在对应 target 的 Build Settings 中,我们在 Swift Compiler - Custom Flags 中的 Other Swift Flags 中添加 -assert-config Debug
来强制启用断言,或者 -assert-config Release
来强制禁用断言。当然,除非有充足的理由,否则并不建议做这样的改动。如果我们需要在Release发布时在无法继续时将程序强行终止的话,应该选择使用 fatalError
。
原来在 Objective-C 中使用的断言函数
NSAssert
已经在 Swift 中被彻底移除,和我们永远的说再见了.
fatalError
细心的读者可能会发现,在我们调试一些纯Swift类型出现类似数组越界这样的情况时,我们在控制台得到的报错信息会和传统调试 NSObject
子类时不太一样,比如在使用 NSArray
时:
let array: NSArray = [1, 2, 3]
print(array[100])
//*** Terminating app due to uncaught exception 'NSRangeException',
//reason: '*** -[__NSArrayI objectAtIndex:]: index 100 beyond bounds [0 .. 2]'
如果我们使用 Swift 类型的话:
let array: Array = [1, 2, 3]
print(array[100])
//输出: fatal error: Index out of range
在调试时我们可以使用断言来排除类似这样的问题,但是断言只会在Debug环境中有效,而在Release编译中所有的断言都将被禁用。在遇到确实因为输入的错误无法使程序继续运行的时候,我们一般考虑以产生致命错误(fatalError)的方式来终止程序。
fatalError
的使用非常简单,它的API和断言的比较类似:
public func fatalError(_ message: @autoclosure () -> String = default,
file: StaticString = #file,
line: UInt = #line) -> Never
关于语法,唯一要需要解释的是 @noreturn
,这表示调用这个方法的话可以不再需要返回值,因为程序整个都将终止。这可以帮助编译器进行一些检查,比如在某些需要返回值的switch语句中, 我们只希望switch的内容在某些范围内,那么我们可以在不属于这些范围的default块里直接写 fataError
而不再需要指定返回值:
enum MyEnum {
case Value1, Value2, Value3
}
func check(someValue: MyEnum) -> String {
switch someValue {
case .Value1:
return "OK"
case .Value2:
return "Maybe OK"
default:
//这个分支没有 return 也能编译通过 , fatalError方法被标记为 @noreturn ,表示不在需要返回值
fatalError("Should not show!")
}
}
在我们实际自己编码的时候,经常会有不想让别人调用某个方法,但又不得不将其暴露出来的时候。一个最常见并且合理的需求就是“抽象类型或者抽象函数”。在很多语言中都有这样的特性: 父类定义了某个方法,但是自己并不给出具体实现,而是要求继承它的子类去实现这个方法,而在 Objective-C 和 Swift 中都没有直接的这样的抽象函数语法支持,虽然在Cocoa中对于这类需求我们有时候会转为依赖协议和委托的设计模式来变通地实现,但是其实Apple自己在Cocoa中也有很多类似抽象函数的设计。比如 UIActicity
的子类必须要实现一大堆指定的方法,而正因为缺少抽象函数机制,这些方法都必须在文档中写明。
在面对这种情况时,为了确保子类实现这些方法,而父类中的方法不被错误地调用,我们就可以利用 fatalError
来在父类中强制抛出错误,以保证使用这些代码的开发者留意到他们必须在自己的子类中实现相关方法:
class MyClass {
func methodMustBeImplementedInSubClass() {
fatalError("这个方法必须在子类中被重写")
}
}
class YourClass: MyClass {
override func methodMustBeImplementedInSubClass() {
print("YourClass 实现了该方法")
}
}
class ThirdClass: MyClass {
func someOtherMethod() {
}
}
YourClass().methodMustBeImplementedInSubClass()
// YourClass 实现了该方法
ThirdClass().methodMustBeImplementedInSubClass()
// 这个方法必须在子类中被重写
不过一个好消息是Apple不仅意识到了抽象函数这个特性的缺失,而且在 Swift 2 开始提出了面向协议编程的概念(Protocol-Oriented Programming)的概念。通过使用协议,我们可以将需要实现的方法定义在协议中,遵守协议的类型必须实现这个方法。相比起“模拟的抽象函数”的方式,面向协议编程能够提供编译时的保证,而不需要将检查推迟到运行的时候。
不仅仅是对于类似抽象函数的使用中可以选择 fataError
,对于其他一切我们不希望别人随意调用,但是又不得不去实现的方法,我们都应该使用 fataError
来避免任何可能的误会。比如父类标明了某个 init
方法是 required
的,但是你的子类永远不会使用这个方法来初始化时,就可以采用类似的方式, 被广泛使用(以及被广泛讨厌的) init(code: NSCoder)
就是一个例子。在子类中,我们往往会写:
required init(code: NSCoder) {
fatalError("NSCoding not supported")
}
来避免编译错误。