如何利用 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 或其他語言中,一般返回 nilundefinednone 等值。

    let result = Int("I am not an integer, and will return an Optional")
    
  • Logical Failures

    由 programmer 產生的錯誤,我們給他一個可愛的暱稱「bug」。Swift 強大的編譯器會幫開發者檢查這些問題,減少 logical failures 的數量。

  • Recoverable Errors

    導致此錯誤的原因複雜,但能夠合理預料的錯誤。例如開啟檔案,可能會有 Permission DeniedFile 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:

  1. 轉拋/傳遞錯誤(error propagation)。
  2. 使用 do-catch 陳述句處理。
  3. 將 Error 轉為 Optional Valuetry?)。
  4. 停止錯誤傳遞(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

到此,我們介紹了 docatchtrythrowthrows,這裡接著介紹 rethrowsdeferError

rethrows your Error

rethrows 這個關鍵字乍看很詭異,但它並非會再拋出錯誤,如果一個函式宣告為 rethrows,意指

這個 rethrowing function 只會在它的函式型別參數(function parameter)拋出錯誤時,才會拋出錯誤。

要宣告為 rethrowing function,必須符合幾個要素:

  • 至少一個函式型別參數帶有 throwing function signature。
  • 只能在 do-catchcatch 語句中使用 throw 拋出錯誤。
  • do 語句中只能處理作為參數的 throwing function 拋出的錯誤。

簡單的範例如下:

func rethrowFunction(callback: () throws -> Void) rethrows {
    try callback()
}

try rethrowFunction {
  throw DRMError.timeout
}

我們可以看到,許多與函數式程式設計相關 methods,都有帶 rethrows 的 signatures,例如 Collectionmap()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-finallyfinally 呢?」,先前說過,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 推出;如果之後 asyncawait 如同 ES7 一樣納入 Swift 標準,如果 actor system 真的導入 Swift 中,天知道兩年後 Swift 寫起來會有多舒服!

Reference