異常和錯誤對于很多iOS,尤其是以Objective-C為主要語言的程序員來說是經常混淆的概念。最近在學習Swift時看到這篇tip,希望與大家共勉。
文章摘自 王巍 (@onevcat) 《Swifter (第二版)100個Swift 2開發必備Tip》 tip77 錯誤和異常處理
轉載請注明出處
在開始這一節的內容之前,我想先闡明兩個在很多時候被混淆的概念,那就是異常 (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 中變成了:
public func writeToFile(path: String, options writeOptionsMask: NSDataWritingOptions) throws
我們在使用這個 API 的時候,不再像之前那樣傳入一個 error
指針去等待方法填充,而是變為使用try catch
語句:
do { try d.writeToFile("Hello", options: [])} catch let error as NSError { print ("Error: \(error.domain)")}
如果你不使用 try 的話,是無法調用 writeToFile: 方法的,它會產生一個編譯錯誤,這讓我們無法有意無意地忽視掉這些錯誤。在上面的示例中 catch 將拋出的異常 (這里就是個 NSError) 用 let 進行了類型轉換,這其實主要是針對 Cocoa 現有的 API 的,是對歷史的一種妥協。對于我們新寫的可拋出異常的 API,我們應當拋出一個實現了 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
可以非常明確地指出問題所在。在調用時,catch
語句實質上是在進行模式匹配:
do {
try login("onevcat", password: "123")
} catch LoginError.UserNotFound {
print("UserNotFound")
} catch LoginError.UserPasswordNotMatch { print("UserPasswordNotMatch")
}// Do something with login user
如果你之前寫過 Java 或者 C# 的話,會發現 Swift 中的try catch
塊和它們中的有些不同。在那些語言里,我們會把可能拋出異常的代碼都放在一個 try
里,而 Swift 中則是將它們放在 do
中,并只在可能發生異常的語句前添加 try
。相比于 Java 或者 C# 的方式,Swift 里我們可以更清楚地知道是哪一個調用可能拋出異常,而不必逐句查閱文檔。
當然,Swift 現在的異常機制也并不是十全十美的。最大的問題是類型安全,不借助于文檔的話,我們現在是無法從代碼中直接得知所拋出的異常的類型的。比如上面的 login
方法,光看方法定義我們并不知道 LoginError
會被拋出。一個理想中的異常 API 可能應該是這樣的:
func login(user: String, password: String) throws LoginError
很大程度上,這是由于要與以前的 NSError 兼容所導致的妥協,對于之前的使用 NSError 來表達錯誤的 API,我們所得到的錯誤對象本身就是用像 domain 或者 error number 這樣的屬性來進行區分和定義的,這與 Swift 2.0 中的異常機制所拋出的直接使用類型來描述錯誤的思想暫時是無法兼容的。不過有理由相信隨著 Swift 的迭代更新,這個問題會在不久的將來得到解決。
另一個限制是對于非同步的 API 來說,拋出異常是不可用的 -- 異常只是一個同步方法專用的處理機制。Cocoa 框架里對于異步 API 出錯時,保留了原來的NSError
機制,比如很常用的 NSURLSession
中的 dataTask API:
func dataTaskWithURL(_ url: NSURL, completionHandler completionHandler: ((NSData!, NSURLResponse!, NSError!) -> Void)?) -> NSURLSessionDataTask
對于異步 API,雖然不能使用異常機制,但是因為這類 API 一般涉及到網絡或者耗時操作,它所產生錯誤的可能性要高得多,所以開發者們其實無法忽視這樣的錯誤。但是像上面這樣的 API 其實我們在日常開發中往往并不會去直接使用,而會選擇進行一些封裝,以求更方便地調用和維護。一種現在比較常用的方式就是借助于 enum
。作為 Swift 的一個重要特性,枚舉 (enum
) 類型現在是可以與其他的實例進行綁定的,我們還可以讓方法返回枚舉類型,然后在枚舉中定義成功和錯誤的狀態,并分別將合適的對象與枚舉值進行關聯:
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
來從枚舉值中將結果取出即可:
let result = doSomethingParam(path)
switch result {
case let .Success(ok):
let serverResponse = okcase
let .Error(error):
let serverResponse = error.description
}
在 Swift 2.0 中,我們甚至可以在 enum
中指定泛型,這樣就使結果統一化了。
enum Result<T> { case Success(T) case Failure(NSError)}
我們只需要在返回結果時指明 T
的類型,就可以使用同樣的 Result
枚舉來代表不同的返回結果了。這么做可以減少代碼復雜度和可能的狀態,同時不是優雅地解決了類型安全的問題,可謂一舉兩得。
因此,在 Swift 2 時代中的錯誤處理,現在一般的最佳實踐是對于同步 API 使用異常機制,對于異步 API 使用泛型枚舉。