異常和錯誤對于很多iOS,尤其是以O(shè)bjective-C為主要語言的程序員來說是經(jīng)?;煜母拍睢W罱趯W(xué)習(xí)Swift時看到這篇tip,希望與大家共勉。
文章摘自 王巍 (@onevcat) 《Swifter (第二版)100個Swift 2開發(fā)必備Tip》 tip77 錯誤和異常處理
轉(zhuǎn)載請注明出處
在開始這一節(jié)的內(nèi)容之前,我想先闡明兩個在很多時候被混淆的概念,那就是異常 (exception) 和錯誤 (error)。
在 Objective-C 開發(fā)中,異常往往是由程序員的錯誤導(dǎo)致的 app 無法繼續(xù)運行,比如我們向一個無法響應(yīng)某個消息的 NSObject 對象發(fā)送了這個消息,會得到 NSInvalidArgumentException的異常,并告訴我們 "unrecognized selector sent to instance";比如我們使用一個超過數(shù)組元素數(shù)量的下標(biāo)來試圖訪問 NSArray 的元素時,會得到 NSRangeException。類似由于這樣所導(dǎo)致的程序無法運行的問題應(yīng)該在開發(fā)階段就被全部解決,而不應(yīng)當(dāng)出現(xiàn)在實際的產(chǎn)品中。相對來說,由 NSError 代表的錯誤更多地是指那些“合理的”,在用戶使用 app 中可能遇到的情況:比如登陸時用戶名密碼驗證不匹配,或者試圖從某個文件中讀取數(shù)據(jù)生成 NSData 對象時發(fā)生了問題 (比如文件被意外修改了) 等等。
但是 NSError的使用方式其實變相在鼓勵開發(fā)者忽略錯誤。想一想在使用一個帶有錯誤指針的 API 時我們做的事情吧。我們會在 API 調(diào)用中產(chǎn)生和傳遞 NSError,并藉此判斷調(diào)用是否失敗。作為某個可能產(chǎn)生錯誤的方法的使用者,我們用傳入 NSErrorPointer 指針的方式來存儲錯誤信息,然后在調(diào)用完畢后去讀取內(nèi)容,并確認(rèn)是否發(fā)生了錯誤。比如在 Objective-C 中,我們會寫類似這樣的代碼:
NSError *error;
BOOL success = [data writeToFile: path options: options error: &error];
if(error) {
// 發(fā)生了錯誤
}
這非常棒,但是有一個問題:在絕大多數(shù)情況下,這個方法并不會發(fā)生什么錯誤,而很多工程師也為了省事和簡單,會將輸入的 error 設(shè)為 nil,也就是不關(guān)心錯誤 (因為可能他們從沒見過這個 API 返回錯誤,也不知要如何處理)。于是調(diào)用就變成了這樣:
[data writeToFile: path options: options error: nil];
但是事實上這個 API 調(diào)用是會出錯的,比如設(shè)備的磁盤空間滿了的時候,寫入將會失敗。但是當(dāng)這個錯誤出現(xiàn)并讓你的 app 陷入難堪境地的時候,你幾乎無從下手進行調(diào)試 -- 因為系統(tǒng)曾經(jīng)嘗試過通知你出現(xiàn)了錯誤,但是你卻選擇視而不見。
在 Swift 2.0 中,Apple 為這么語言引入了異常機制?,F(xiàn)在,這類帶有 NSError指針作為參數(shù)的 API 都被改為了可以拋出異常的形式。比如上面的 writeToFile:options:error:,在 Swift 中變成了:
public func writeToFile(path: String, options writeOptionsMask: NSDataWritingOptions) throws
我們在使用這個 API 的時候,不再像之前那樣傳入一個 error 指針去等待方法填充,而是變?yōu)槭褂?code>try catch 語句:
do { try d.writeToFile("Hello", options: [])} catch let error as NSError { print ("Error: \(error.domain)")}
如果你不使用 try 的話,是無法調(diào)用 writeToFile: 方法的,它會產(chǎn)生一個編譯錯誤,這讓我們無法有意無意地忽視掉這些錯誤。在上面的示例中 catch 將拋出的異常 (這里就是個 NSError) 用 let 進行了類型轉(zhuǎn)換,這其實主要是針對 Cocoa 現(xiàn)有的 API 的,是對歷史的一種妥協(xié)。對于我們新寫的可拋出異常的 API,我們應(yīng)當(dāng)拋出一個實現(xiàn)了 ErrorType 的類型,enum 就非常合適,舉個例子:
enum LoginError: ErrorType {
case UserNotFound, UserPasswordNotMatch
}
func login(user: String, password: String) throws {
//users 是 [String: String],存儲[用戶名:密碼]
if !users.keys.contains(user) {
throw LoginError.UserNotFound
}
if users[user] != password {
throw LoginError.UserPasswordNotMatch
}
print("Login successfully.")
}
這樣的 ErrorType 可以非常明確地指出問題所在。在調(diào)用時,catch語句實質(zhì)上是在進行模式匹配:
do {
try login("onevcat", password: "123")
} catch LoginError.UserNotFound {
print("UserNotFound")
} catch LoginError.UserPasswordNotMatch { print("UserPasswordNotMatch")
}// Do something with login user
如果你之前寫過 Java 或者 C# 的話,會發(fā)現(xiàn) Swift 中的try catch 塊和它們中的有些不同。在那些語言里,我們會把可能拋出異常的代碼都放在一個 try 里,而 Swift 中則是將它們放在 do 中,并只在可能發(fā)生異常的語句前添加 try。相比于 Java 或者 C# 的方式,Swift 里我們可以更清楚地知道是哪一個調(diào)用可能拋出異常,而不必逐句查閱文檔。
當(dāng)然,Swift 現(xiàn)在的異常機制也并不是十全十美的。最大的問題是類型安全,不借助于文檔的話,我們現(xiàn)在是無法從代碼中直接得知所拋出的異常的類型的。比如上面的 login 方法,光看方法定義我們并不知道 LoginError 會被拋出。一個理想中的異常 API 可能應(yīng)該是這樣的:
func login(user: String, password: String) throws LoginError
很大程度上,這是由于要與以前的 NSError 兼容所導(dǎo)致的妥協(xié),對于之前的使用 NSError 來表達(dá)錯誤的 API,我們所得到的錯誤對象本身就是用像 domain 或者 error number 這樣的屬性來進行區(qū)分和定義的,這與 Swift 2.0 中的異常機制所拋出的直接使用類型來描述錯誤的思想暫時是無法兼容的。不過有理由相信隨著 Swift 的迭代更新,這個問題會在不久的將來得到解決。
另一個限制是對于非同步的 API 來說,拋出異常是不可用的 -- 異常只是一個同步方法專用的處理機制。Cocoa 框架里對于異步 API 出錯時,保留了原來的NSError 機制,比如很常用的 NSURLSession 中的 dataTask API:
func dataTaskWithURL(_ url: NSURL, completionHandler completionHandler: ((NSData!, NSURLResponse!, NSError!) -> Void)?) -> NSURLSessionDataTask
對于異步 API,雖然不能使用異常機制,但是因為這類 API 一般涉及到網(wǎng)絡(luò)或者耗時操作,它所產(chǎn)生錯誤的可能性要高得多,所以開發(fā)者們其實無法忽視這樣的錯誤。但是像上面這樣的 API 其實我們在日常開發(fā)中往往并不會去直接使用,而會選擇進行一些封裝,以求更方便地調(diào)用和維護。一種現(xiàn)在比較常用的方式就是借助于 enum。作為 Swift 的一個重要特性,枚舉 (enum) 類型現(xiàn)在是可以與其他的實例進行綁定的,我們還可以讓方法返回枚舉類型,然后在枚舉中定義成功和錯誤的狀態(tài),并分別將合適的對象與枚舉值進行關(guān)聯(lián):
enum Result {
case Success(String) case Error(NSError)
}
func doSomethingParam(param:AnyObject) -> Result {
//...做某些操作,成功結(jié)果放在 success 中
if success {
return Result.Success("成功完成")
} else {
let error = NSError(domain: "errorDomain", code: 1, userInfo: nil) return Result.Error(error)
}
}
在使用時,利用 switch 中的 let 來從枚舉值中將結(jié)果取出即可:
let result = doSomethingParam(path)
switch result {
case let .Success(ok):
let serverResponse = okcase
let .Error(error):
let serverResponse = error.description
}
在 Swift 2.0 中,我們甚至可以在 enum 中指定泛型,這樣就使結(jié)果統(tǒng)一化了。
enum Result<T> { case Success(T) case Failure(NSError)}
我們只需要在返回結(jié)果時指明 T 的類型,就可以使用同樣的 Result 枚舉來代表不同的返回結(jié)果了。這么做可以減少代碼復(fù)雜度和可能的狀態(tài),同時不是優(yōu)雅地解決了類型安全的問題,可謂一舉兩得。
因此,在 Swift 2 時代中的錯誤處理,現(xiàn)在一般的最佳實踐是對于同步 API 使用異常機制,對于異步 API 使用泛型枚舉。