如何利用 Swift 的語言特性來處理例外?使用 Optional 是常見的做法。如果成功就返回 value,失敗則返回 nil
,這種模式常用於簡單的狀況。然而,面對複雜的情況,例如網路請求,若只簡單返回 nil
,調用者並無法得知是 404,抑或 500。為了解決這個問題,我們必須緊緊抱住錯誤/例外處理的大腿。
(撰於 2017-04-10,基於 Swift 3.1)
Intro of Exception Handling
在開始介紹 Swift 例外處理之前,先來了解什麼是例外處理。維基百科道:
…is the process of responding to the occurrence, during computation, of exceptions – anomalous or exceptional conditions requiring special processing – often changing the normal flow of program execution.
簡單來說,就是某些例外狀況,需要特別的處理,這個處理過程就稱為例外處理,而這個處理常伴隨程式流程轉移改變。
寫習慣 C++/Objective-C 的同學,想必很排斥寫 try-catch 這種吃效能、又易出錯的例外處理,明明 if…else 就能打遍天下嘛!而喜歡 Python/Ruby 的朋友對 raise
和各種 Exceptions 一定不陌生,甚至 Python 底層的 iterator 都是用 StopIteration
Exception 實作。依照各個程式語言的設計,例外處理大致分為兩類:
- 融入一般的 control flow(Python、Ruby 之流)
- 處理特殊、不正常的情況(C++、Objective-C、C# 等)
大多數程式語言,無論屬於哪一類,只要涉及例外處理,就可能出現效能上的疑慮,很難避開 Call Stack Unwinding 的問題。能改善的方法之一,就是明確定義哪些 function 能拋出例外,哪些必須拋出例外,哪些錯誤不需要拋出,而是 programmer 自己應該要 handle 的。
要釐清這個問題,首先要定義錯誤,程式錯誤的範疇很廣,不同的狀況有不同的應對方式,大致上可以分為以下幾種類型:
Simple Errors
一些很明顯可能產生錯誤的操作,例如 type casting、parsing string to integer。這種錯誤通常很容易理解,不需要過多的描述,在 Swift 或其他語言中,一般返回
nil
/undefined
/none
等值。let result = Int("I am not an integer, and will return an Optional")
Logical Failures
由 programmer 產生的錯誤,我們給他一個可愛的暱稱「bug」。Swift 強大的編譯器會幫開發者檢查這些問題,減少 logical failures 的數量。
Recoverable Errors
導致此錯誤的原因複雜,但能夠合理預料的錯誤。例如開啟檔案,可能會有 Permission Denied、File Not Found 等不同的錯誤。這類的錯誤就是 Exceptions Handling 主要的目標。
Swift Error Handling
Swift 在 2.0 版為了妥善處理錯誤,並避免影響效能,決定僅針對 Recoverable Error 引入 Error Handling 機制,其他系統底層/語言層的錯誤還是需要 programmer 自行避免。截至 3.1 版,相關的關鍵字如下:
do
catch
try
throw
throws
rethrows
defer
Error
Swift 的錯誤處理與主流設計大相逕庭,不幫 programmer 躲過自作孽的 Login Failure,不會 catch index out of bound 這類錯誤。實際上,Swift 的錯誤處理就只是另一種 Return Type,與相關的 Syntax Sugar。其設計理念/特色整理如下:
- 拋出錯誤之處需為顯式聲明。
- 函式必須顯式宣告它會拋出錯誤,讓 programmer 明確得知哪些程式該處理錯誤。
- 拋出錯誤的效能如同初始化並返回 Error 型別一樣簡單,不涉及 stack unwinding。
Swift 有四種方法處理 Error:
- 轉拋/傳遞錯誤(error propagation)。
- 使用 do-catch 陳述句處理。
- 將 Error 轉為 Optional Value(
try?
)。 - 停止錯誤傳遞(
try!
)
能被拋出的錯誤需繼承 Error
protocol,在此先定義一個錯誤類型,爾後再介紹。
enum DRMError: Error {
case timeout
case invalidHeader
case missingParam(String)
case responseFailure(code: Int, message: Data)
}
Propagating Errors
第一種處理方法:透過 throwing function 轉拋/傳遞錯誤。
任何一個 function、method 或 initializer 若要拋出錯誤,需在參數之後,Return Type 之前加上 throws
來宣告一個 throwing function,顯式聲明該函式的需要錯誤處理。並利用 throw
來拋出錯誤。我們可以利用這個特性,將錯誤轉拋/傳遞出去給外面的作用域。
func canThrowTimeout() throws { // 可以拋出錯誤
throw DRMError.timeout
}
func isHeaderEmpty(header: [String: Any]) throws -> Bool { // 可以拋出錯誤
guard header.count > 0 else { throw DRMError.invalidHeader }
return true
}
// 這個函式會錯誤轉拋/傳遞出去,將錯誤處理責任轉移到調用它的作用域。
func throwPropagation() throws {
throw DRMError.missingParam("pubkey")
try canThrowTimeout()
}
try throwPropagation()
調用 throwing function 時,必須在該函式前使用
try
顯式調用,否則編譯不會過。
我們可以把
throw
視為一種特殊的return
,專門用來返回一個 Error 實例。
Using do-catch
第二種處理方法:使用 do-catch 來捕獲錯誤。
do-catch 就好比 Objective-C 的 @try-@catch
,在 do
區塊內拋出的錯誤會被捕獲,並尋找對應的 catch
區塊來處理錯誤。用法如下:
do {
try throwPropagation() // 只能從用 try 標註的 throwing function 捕獲錯誤
// 若調用 `catchFromThis()` 這樣的函式,未使用 `try` 標註,
// 錯誤無法被捕獲(實際上也沒辦法拋出 custom error)。
// ...
} catch DRMError.timeout { // 使用 pattern matching 捕獲特定錯誤
print("Oh No! Timeout!")
} catch DRMError.missingParam(let p) where p == "pubkey" { // pattern matching + generic where clause
print("\(param) is missing.")
} catch { // 捕獲剩下的所有錯誤(類似 default),並 binding 到區域變數 `error`
print("Unexpected Error")
}
範例中,看到了 catch
結合 Swift 強大的 pattern matching 來捕獲錯誤,並活用 value binding 獲取錯誤的詳細資訊。我們可以把 catch
看作 switch-case 來使用各種 Swift patterns 的奇技淫巧。唯一不同的是,do-catch 不需要枚舉所有可能拋出的錯誤,若有錯誤未被處理,它將會繼續傳遞到周遭的作用域。
Converting to Optional
第三種處理法:利用 try?
將錯誤轉換成 Optional。
這種作法大家應該都很能理解,直接貼官方的例子:
func someThrowingFunction() throws -> Int {
// ...
}
let x = try? someThrowingFunction() // `x` 是一個 Optional
let y: Int?
do {
y = try someThrowingFunction() // 若無拋出錯誤,則將賦值給 `y`
} catch {
y = nil
}
透過 try?
,將 throwing function 的錯誤轉換成 Optional 後,理所當然可以使用 Optional 的所有特性,例如 optional-binding,例如官方的範例:
func fetchData() -> Data? {
if let data = try? fetchDataFromDisk() { return data }
if let data = try? fetchDataFromServer() { return data }
return nil
}
Stopping Propagation
第四種作法:使用 try!
停止錯誤繼續傳遞。
當你非常有信心錯誤不會發生,可以使用 try!
停止錯誤往下傳遞。
// 接續前一個例子
// `x` 是一個 Int,如果 someThrowingFunction 拋出錯誤,則會得到 runtime error。
let x = try! someThrowingFunction()
Other Handling Keywords
到此,我們介紹了 do
、catch
、try
、throw
、throws
,這裡接著介紹 rethrows
、defer
與 Error
。
rethrows
your Error
rethrows
這個關鍵字乍看很詭異,但它並非會再拋出錯誤,如果一個函式宣告為 rethrows
,意指
這個 rethrowing function 只會在它的函式型別參數(function parameter)拋出錯誤時,才會拋出錯誤。
要宣告為 rethrowing function,必須符合幾個要素:
- 至少一個函式型別參數帶有 throwing function signature。
- 只能在 do-catch 的
catch
語句中使用throw
拋出錯誤。 do
語句中只能處理作為參數的 throwing function 拋出的錯誤。
簡單的範例如下:
func rethrowFunction(callback: () throws -> Void) rethrows {
try callback()
}
try rethrowFunction {
throw DRMError.timeout
}
我們可以看到,許多與函數式程式設計相關 methods,都有帶 rethrows
的 signatures,例如 Collection
的 map()
與 index(where:)
,讓處理集合時,可以將錯誤傳遞到正確的作用域。
public protocol Collection : Sequence {
public func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]
public func index(where predicate: (Self.Iterator.Element) throws -> Bool) rethrows -> Self.Index?
}
defer
Your Finally
相信熟悉其他語言的童鞋,一定在想「我的 try-catch-finally 的 finally
呢?」,先前說過,Swift 的 error handling 只是一些甜死人的語法糖,官方並沒有特別為這個 model 增添關鍵字,而是使用大家已知的 defer
,不懂的趕快點這裡惡補一下。
這裡寫段開檔的 pseudo code 給大家瞧瞧:
func handle(fileError error: FileError) {
switch error {
case .notFound: print("File not found.")
case .permissionDenied: print("Permission denied")
default: print("Unknown error occurred.")
}
}
func writeTo(file: File, data: Data) {
defer { close(file) } // 在這個 code block 結束之前執行
do {
try openFile(file)
} catch let error as FileError {
handle(fileError: error)
} catch _ { // wildcard pattern without binding error value to error
print("This is not a FileError.")
}
}
Customize Your Error
一開始,我們實現了一個 DRMError
繼承了 Error
,讓我們自定義的錯誤能夠正確拋出。那這個 Error
protocol 究竟葫蘆裡買啥藥?很驚人地,Error
是個 empty protocol,沒有任何實現,可說是名副其實的語法糖。
public protocol Error {
}
extension Error {
}
extension Error where Self.RawValue : SignedInteger {
}
extension Error where Self.RawValue : UnsignedInteger {
}
由於 do-catch 和 Swift patterns 緊密結合,官方推薦使用 enum
客製化我們自己的 Error Type。當有特殊需求,例如 Errors 間有共享的 state 或 data 時,也可用如 struct
來實現自定義 Error,舉個官方的 XML Parsing 例子:
struct XMLParsingError: Error {
enum ErrorKind {
case invalidCharacter
case mismatchedTag
case internalError
}
let line: Int
let column: Int
let kind: ErrorKind
}
func parse(_ source: String) throws -> XMLDoc {
// ...
throw XMLParsingError(line: 19, column: 5, kind: .mismatchedTag)
}
do {
let xmlDoc = try parse(myXMLData)
} catch let e as XMLParsingError {
print("Parsing error: \(e.kind) [\(e.line):\(e.column)]")
} catch {
print("Other error: \(error)")
}
上例可清楚呈現解析 XML 時,Error 共享類似的 states。Swift Error Protocol 設計地非常有彈性。
Notices and Future
Swift 的 Error Handling 設計得很現代很 functional,也讓錯誤處理不再只存在於醜陋的 code 或是不齊全的 document 中,而是提升至語言層面加以約束、保障。同時,仍有幾點需要注意、了解:
throws
關鍵字是 function type 的一部分,而 non-throwing function 是 throwing function 的 subtype,所以可以在任何宣告 throwing function 處使用 non-throwing。- 承上,non-throwing method 可以 override throwing method,反之則否。
throw
的功能類似return
,對 asynchronous operation 不夠友善,因此許多人 porting 等其他語言的Promise
/Future
的特性,來彌補異步錯誤處理的不足。比較知名的庫有 PromiseKit 等(想學習Promise
概念,可參考這個連結)。
如果未來,語言層級的平行運算(並行運算)就像這篇文章所說的,會在 Swift 5 推出;如果之後 async
/await
如同 ES7 一樣納入 Swift 標準,如果 actor system 真的導入 Swift 中,天知道兩年後 Swift 寫起來會有多舒服!