關于 Swift Error 的分類
在去年我應 IBM 編輯的邀請寫過一篇關于 Swift 2 中 throws 的文章 。現在回頭看,Swift 2 其實是 Swift 語言發展的一個挺重要的節點:如果說 Swift 1 是一個更偏向于驗證階段的產品的話,Swift 2 中加入的特性為這門語言的基石進行了補足。在那篇文章里我們主要深入探索了新的 throw 關鍵字背后的事情,而同一時期其實 Swift 官方有過一次關于錯誤處理的討論。隨著 Swift 3 的開源,這些原始文檔也被一同公開,展示了 Swift 設計的過程和軌跡。如果你對這篇 Swift 2 中的錯誤處理的宣言感興趣的話,可以在 GitHub 上 Swift 項目文檔中 找到原文 。
最近參加了日本這邊的一個社區辦的 iOS 會議,其中 koher 給出了一個關于 錯誤處理的 session ,里面也提到了這篇文檔,正確理解和思考 Swift 錯誤機制的類型非常有意思,它也可以指導我們在不同場景下對應使用正確的處理機制。
Swift 錯誤類型的種類
Simple domain error
簡單的,顯而易見的錯誤。這類錯誤的最大特點是我們不需要知道原因,只需要知道錯誤發生,并且想要進行處理。用來表示這種錯誤發生的方法一般就是返回一個 nil 值。在 Swift 中,這類錯誤最常見的情況就是將某個字符串轉換為整數,或者在字典嘗試用某個不存在的 key 獲取元素:
// Simple Domain Error 的例子
let num = Int("hello world") // nil
let element = dic["key_not_exist"] // nil
在使用層面 (或者說應用邏輯) 上,這類錯誤一般用 if let 的可選值綁定或者是 guard let 提前進行返回處理即可,不需要再在語言層面上進行額外處理。
Recoverable error
正如其名,這類錯誤應該是被容許,并且是可以恢復的。可恢復錯誤的發生是正常的程序路徑之一,而作為開發者,我們應當去檢出這類錯誤發生的情況,并進一步對它們進行處理,讓它們恢復到我們期望的程序路徑上。
這類錯誤在 Objective-C 的時代通常用 NSError 類型來表示,而在 Swift 里則是 throw 和 Error 的組合。一般我們需要檢查錯誤的類型,并作出合理的響應。而選擇忽視這類錯誤往往是不明智的,因為它們是用戶正常使用過程中可能會出現的情況,我們應該嘗試對其恢復,或者至少向用戶給出合理的提示,讓他們知道發生了什么。像是網絡請求超時,或者寫入文件時磁盤空間不足:
// 網絡請求
let url = URL(string: "https://www.example.com/")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
// 提示用戶
self.showErrorAlert("Error: \(error.localizedDescription)")
}
let data = data!
// ...
}
// 寫入文件
func write(data: Data, to url: URL) {
do {
try data.write(to: url)
} catch let error as NSError {
if error.code == NSFileWriteOutOfSpaceError {
// 嘗試通過釋放空間自動恢復
removeUnusedFiles()
write(data: data, to: url)
} else {
// 其他錯誤,提示用戶
showErrorAlert("Error: \(error.localizedDescription)")
}
} catch {
showErrorAlert("Error: \(error.localizedDescription)")
}
}
Universal error
這類錯誤理論上可以恢復,但是由于語言本身的特性所決定,我們難以得知這類錯誤的來源,所以一般來說也不會去處理這種錯誤。這類錯誤包括類似下面這些情形:
// 內存不足
[Int](repeating: 100, count: .max)
// 調用棧溢出
func foo() { foo() }
foo()
我們可以通過設計一些手段來對這些錯誤進行處理,比如:檢測當前的內存占用并在超過一定值后警告,或者監視棧 frame 數進行限制等。但是一般來說這是不必要的,也不可能涵蓋全部的錯誤情況。更多情況下,這是由于代碼觸碰到了設備的物理限制和邊界情況所造成的,一般我們也不去進行處理(除非是人為操成的 bug)。
在 Swift 中,各種被使用 fatalError 進行強制終止的錯誤一般都可以歸類到 Universal error。
Logic failure
邏輯錯誤是程序員的失誤所造成的錯誤,它們應該在開發時通過代碼進行修正并完全避免,而不是等到運行時再進行恢復和處理。
常見的 Logic failure 包括有:
// 強制解包一個 `nil` 可選值
var name: String? = nil
name!
// 數組越界訪問
let arr = [1,2,3]
let num = arr[3]
// 計算溢出
var a = Int.max
a += 1
// 強制 try 但是出現錯誤
try! JSONDecoder().decode(Foo.self, from: Data())
這類錯誤在實現中觸發的一般是 assert 或者 precondition 。
斷言的作用范圍和錯誤轉換
和 fatalError 不同, assert 只在進行編譯優化的 -O 配置下是不觸發的,而如果更進一步,將編譯優化選項配置為 -Ounchecked 的話, precondition 也將不觸發。此時,各方法中的 precondition 將被跳過,因此我們可以得到最快的運行速度。但是相對地代碼的安全性也將降低,因為對于越界訪問或者計算溢出等錯誤,我們得到的將是不確定的行為。
函數 | faltaError | precondition | assert |
---|---|---|---|
-Onone | 觸發 | 觸發 | 觸發 |
-O | 觸發 | 觸發 | |
-Ounchecked | 觸發 |
對于 Universal error 一般使用 fatalError ,而對于 Logic failure 一般使用 assert 或者 precondition 。遵守這個規則會有助于我們在編碼時對錯誤進行界定。而有時候我們也希望能盡可能多地在開發的時候捕獲 Logic failure,而在產品發布后盡量減少 crash 比例。這種情況下,相比于直接將 Logic failure 轉換為可恢復的錯誤,我們最好是使用 assert 在內部進行檢查,來讓程序在開發時崩潰。
Quiz
光說不練假把式。讓我們來實際判斷一下下面這些情況下我們都應該選擇用哪種錯誤處理方式吧~
#1 app 內資源加載
請聽題。
假設我們在處理一個機器學習的模型,需要從磁盤讀取一份預先訓練好的模型。該模型以文件的方式存儲在 app bundle 中,如果讀取時沒有找到該模型,我們應該如何處理這個錯誤?
方案 1 Simple domain error
func loadModel() -> Model? {
guard let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl") else {
return nil
}
let url = URL(fileURLWithPath: path)
guard let data = try? Data(contentOf: url) else {
return nil
}
return try? ModelLoader.load(from: data)
}
方案 2 Recoverable error
func loadModel() throws -> Model {
guard let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl") else {
throw AppError.FileNotExisting
}
let url = URL(fileURLWithPath: path)
let data = try Data(contentOf: url)
return try ModelLoader.load(from: data)
}
方案 3 Universal error
func loadModel() -> Model {
guard let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl") else {
fatalError("Model file not existing")
}
let url = URL(fileURLWithPath: path)
do {
let data = try Data(contentOf: url)
return try ModelLoader.load(from: data)
} catch {
fatalError("Model corrupted.")
}
}
方案 4 Logic failure
func loadModel() -> Model {
let path = Bundle.main.path(forResource: "my_pre_trained_model", ofType: "mdl")!
let url = URL(fileURLWithPath: path)
let data = try! Data(contentOf: url)
return try! ModelLoader.load(from: data)
}
點擊展開答案
正確答案應該是 方案 4,使用 Logic failure 讓代碼直接崩潰 。
作為內建的存在于 app bundle 中模型或者配置文件,如果不存在或者無法初始化,在不考慮極端因素的前提下,一定是開發方面出現了問題,這不應該是一個可恢復的錯誤,無論重試多少次結果肯定是一樣的。也許是開發者忘了將文件放到合適的位置,也許是文件本身出現了問題。不論是哪種情況,我們都會希望盡早發現并強制我們修正錯誤,而讓代碼崩潰可以很好地做到這一點。
使用 Universal error 同樣可以讓代碼崩潰,但是 Universal error 更多是用在語言的邊界情況下。而這里并非這種情況。
#2 加載當前用戶信息時發生錯誤
我們在用戶登錄后會將用戶信息存儲在本地,每次重新打開 app 時我們檢測并使用用戶信息。當用戶信息不存在時,應該進行的處理:
方案 1 Simple domain error
func loadUser() -> User? {
let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username")
if let username {
return User(name: username)
} else {
return nil
}
}
方案 2 Recoverable error
func loadUser() throws -> User {
let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username")
if let username {
return User(name: username)
} else {
throws AppError.UsernameNotExisting
}
}
方案 3 Universal error
func loadUser() -> User {
let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username")
if let username {
return User(name: username)
} else {
fatalError("User name not existing")
}
}
方案 4 Logic failure
func loadUser() -> User {
let username = UserDefaults.standard.string(forKey: "com.onevcat.app.defaults.username")
return User(name: username!)
}
點擊展開答案
首先肯定排除方案 3 和 4。“用戶名不存在”是一個正常的現象,肯定不能直接 crash。所以我們應該在方案 1 和方案 2 中選擇。
對于這種情況,選擇 方案 1 Simple domain error 會更好 。因為用戶信息不存在是很簡單的一個狀況,如果用戶不存在,那么我們直接讓用戶登錄即可,這并不需要知道額外的錯誤信息,返回 nil 就能夠很好地表達意圖了。
當然,我們不排除今后隨著情況越來越復雜,會需要區分用戶信息缺失的原因 (比如是否是新用戶還沒有注冊,還是由于原用戶注銷等)。但是在當前的情況下來看,這屬于過度設計,暫時并不需要考慮。如果之后業務復雜到這個程度,在編譯器的幫助下將 Simple domain error 修改為 Recoverable error 也不是什么難事兒。
#3 還沒有實現的代碼
假設你在為你的服務開發一個 iOS 框架,但是由于工期有限,有一些功能只定義了接口,沒有進行具體實現。這些接口會在正式版中完成,但是我們需要預先發布給友商內測。所以除了在文檔中明確標明這些內容,這些方法內部應該如何處理呢?
方案 1 Simple domain error
func foo() -> Bar? {
return nil
}
方案 2 Recoverable error
func foo() throws -> Bar? {
throw FrameworkError.NotImplemented
}
方案 3 Universal error
func foo() -> Bar? {
fatalError("Not implemented yet.")
}
方案 4 Logic failure
func foo() -> Bar? {
assertionFailure("Not implemented yet.")
return nil
}
點擊展開答案
正確答案是 方案 3 Universal error 。對于沒有實現的方法,返回 nil 或者拋出錯誤期待用戶恢復都是沒有道理的,這會進一步增加框架用戶的迷惑。這里的問題是語言層面的邊界情況,由于沒有實現,我們需要給出強力的提醒。在任意 build 設定下,都不應該期待用戶可以成功調用這個函數,所以 fatalError 是最佳選擇。
#4 調用設備上的傳感器收集數據
調用傳感器的 app 最有意思了!不管是相機還是陀螺儀,傳感器相關的 app 總是能帶給我們很多樂趣。那么,如果想要調用傳感器獲取數據時,發生了錯誤,應該怎么辦呢?
方案 1 Simple domain error
func getDataFromSensor() -> Data? {
let sensorState = sensor.getState()
guard sensorState == .normal else {
return nil
}
return try? sensor.getData()
}
方案 2 Recoverable error
func getDataFromSensor() throws -> Data {
let sensorState = sensor.getState()
guard sensorState == .normal else {
throws SensorError.stateError
}
return try sensor.getData()
}
方案 3 Universal error
func loadUser() -> Data {
let sensorState = sensor.getState()
guard sensorState == .normal, let data = try? sensor.getData() else {
fatalError("Sensor get data failed!")
}
return data
}
方案 4 Logic failure
func loadUser() -> Data {
let sensorState = sensor.getState()
assert(sensorState == .normal, "The sensor state is not normal")
return try! sensor.getData()
}
點擊展開答案
傳感器由于種種原因暫時不能使用 (比如正在被其他進程占用,或者甚至設備上不存在對應的傳感器),是很有可能發生的情況。即使這個傳感器的數據對應用是至關重要,不可或缺的,我們可能也會希望至少能給用戶一些提示。基于這種考慮,使用 方案 2 Recoverable error 是比較合理的選擇。
方案 1 在傳感器數據無關緊要的時候可能也會是一個更簡單的選項。但是方案 3 和 4 會直接讓程序崩潰,而且這實際上也并不是代碼邊界或者開發者的錯誤,所以不應該被考慮。
Quiz 的一些總結
可以看到,其實在錯誤處理的時候,選用哪種錯誤是根據情景和處理需求而定的,我在參考答案也使用了很多諸如“可能”,“相較而言”等語句。雖然對于特定的場景,我們可以進行直觀的考慮和決策,但這并不是教條主義般的一成不變。錯誤類型之間可以很容易地通過代碼互相轉換,這讓我們在處理錯誤的時候可以自由選擇使用的策略:比如 API 即使提供給我們的是 Recoverable 的 throws 形式,我們也還是可以按照需要,通過 try ? 將其轉為 Simple domain error,或者用 try ! 將其轉為 Logic failure。
能切實理解使用情景,利用這些錯誤類型轉換的方式,靈活選取使用場景下最合適的錯誤類型,才能說是真正理解了這四種錯誤的分類依據。
參考鏈接
來自:http://onevcat.com/2017/10/swift-error-category/