16. 錯誤處理

通常錯誤處理要處理的錯誤都是會導致程式當掉的重大錯誤,所以需要有一套好的機制來抓到這種錯誤發生,並且處理他。如果放著不管,等到真的錯誤發生時,程式就當了。

16.1 throws

我們在Swift內建的許多函數宣告上可以發現throws保留字,有這個保留字的函數表示當程式運作發生重大錯誤時,就會「丟出」錯誤,例如下面這個用來複製檔案的函數。

func copyItem(at srcURL: URL, to dstURL: URL) throws

當呼叫上述這個函數時,如果檔名或是路徑打錯一定會造成複製檔案失敗,所以函數內部就會丟出錯誤要求呼叫者必須處理,也藉由丟出錯誤告訴呼叫者,函數執行失敗了,複製檔案並沒有成功。通常函數會丟出錯誤就代表發生了重大問題,內部已經無法處理,如果呼叫者也不去管他,程式就當了。

設計會丟出錯誤的函數必須在函數後方,傳回值之前加上throws保留字,函數內部可在需要的時候使用throw保留字丟出特定錯誤,當丟出錯誤後函數就會立刻結束,throw後面的程式碼不會再執行。

func canThrowErrors() throws -> Int {
    throw SomeError
}

16.2 Do-Catch

一般來說,有經驗的工程師在開發系統時都會有一個良好習慣,他們總是先在測試環境中開發,等確定都沒有問題了才會將新功能移到正式環境中。這樣如果在測試過程中發生了錯誤,最多也只是影響了測試環境,對真正的上線環境不會造成任何影響。同樣的概念也應用在會丟出錯誤的函數上。呼叫這種函數時,我們也把他放到測試環境中去試著執行看看,如果沒有問題就把他移到正式環境,如果發現問題,我們就可以很安全的處理發生的問題而不會影響正式環境。當然在Swift的執行環境中不一定真的有正式與測試之分,但用這樣的比擬可以幫助我們瞭解錯誤處理的運作機制。

要怎麼知道一個有throws保留字的函數丟出了錯誤呢?我們使用do語句來攔截所有的錯誤。想像一下,do在左右大括號範圍內建立了一個測試環境,我們在這個環境裡去測試函數執行結果,如果都沒有錯誤發生,就把函數執行結果移到正式環境裡,如果有錯誤發生,透過catch區段來處理。do-catch語句格式如下,在do區段中呼叫帶有throws能力的函數前面一定要加上try,表示「試著執行看看」的意思。如果有錯誤發生,不同的錯誤樣式會被不同的catch攔截到。最後一個catch不指定任何錯誤,表示要攔截剩餘的所有錯誤。

do {
    try expression1
    try expression2
} catch error_pattern_1 {
    
} catch error_pattern_2 where condition {
    
} catch {
    
}

do區段裡面至少有要一個用try呼叫的函數,如果都沒有時,雖然程式也會照樣執行,只是這時使用do-catch沒有什麼意義,因為不會有任何的錯誤被丟出來,反而我們還會收到一個catch永遠不會被執行到的語法警告。

範例

這一節透過設計一個除法運算的函數,瞭解整個錯誤處理機制是如何運作的。首先必須先自訂一個錯誤樣式,錯誤樣式使用enum來設計,enum名稱可以隨意定,裡面的case項目也是根據需要自行決定,只要符合協定Error的規範即可,如下:

enum MyError: Error {
    case divisionByZero
}

除法運算函數的設計如下,當然檢查分母是否為零是這個函數設計的重點,如果分母為零會導致程式當掉,所以必須把這個錯誤丟回給呼叫者,要求呼叫者遇到這個錯誤時必須要處理。

func divided(_ a: Double, by b: Double) throws -> Double {
    guard b != 0 else {
        throw MyError.divisionByZero
    }
    return a / b
}

呼叫者的程式碼如下,這個執行結果會印出「Error: 分母不可為零」。

let x = 5.0, y = 0.0
var result: Double?

do {
    result = try divided(x, by: y)
} catch MyError.divisionByZero {
    print("Error: 分母不可為零")
}

if let result = result {
    print(result)
}

我們也可以把相關的變數與常數宣告放到do區段裡面,但這時這些變數或常數的有效範圍就僅止於do區段內了。

do {
    let x = 10.0, y = 7.0
    let result = try divided(x, by: y)
    print(result)
} catch MyError.divisionByZero {
    print("Error: 分母不可為零")
}

16.3 取得錯誤原因

如果catch後面沒有接錯誤樣式的話,代表這個catch要攔截沒有被前面cache攔截到的剩餘錯誤樣式,換句話說,如果只有唯一個cache,就代表他要攔截所有的錯誤樣式,這時在這個cache區段中的錯誤樣式會被綁定到一個區域常數error中,透過這個常數可以知道發生的錯誤屬於哪個錯誤樣式,例如下面這段程式碼中的error常數會顯示”divisionByZero”訊息,因為divided(_:by:)丟出的錯誤樣式就是divisionByZero。

do {
    let x = 10.0, y = 0.0
    let result = try divided(x, by: y)
    print(result)
} catch  {
    print(error)
}
// Prints "divisionByZero"

更豐富的錯誤訊息

有時候我們會發現一些Swift內建的函數在發生錯誤時,error提供的錯誤訊息遠比我們設計的MyError要來的更多。裡面包含了錯誤領域、錯誤碼、詳細錯誤原因,以及各式各樣的額外資訊。這樣豐富的錯誤訊息有兩種方式可以做到:一種是在enum的case中加上關連值(請參考第11章列舉),另一種則是靠NSError類別。其實NSError類別原本屬於Objective-C,但現在已經轉成Swift可以使用的格式了。

我們先以enum為例。下面這個函數用來輸入成績,但輸入的成績如果低於0分或超過100分時函數就要丟出錯誤。在enum中設計了三個參數:file用來記錄錯誤發生在哪個檔案;line表示是位於哪一行;message用來顯示錯誤的詳細訊息。當函數中發生錯誤而準備丟出錯誤時,把這三個參數需要的資料填妥即可。

enum MyError: Error {
    enum ErrorMessage: String {
        case greater = "輸入數字 >= 100"
        case less = "輸入數字 < 0"
    }
    case inputError(file: String, line: Int, message: ErrorMessage)
}

func inputGrade(_ grade: Int) throws {
    guard grade > 0 else {
        throw MyError.inputError(file: "input.swift", line: 20, message: .less)
    }
    guard grade <= 100 else {
        throw MyError.inputError(file: "input.swift", line: 20, message: .greater)
    }
    
    // save grade here
    print("輸入的成績為 \(grade)")
}

當錯誤發生時使用catch let來取得錯誤發生的原因,如下:

do {
    try inputGrade(105)
} catch let MyError.inputError(file, line, message) {
    print("[\(file): \(line)] Error: \(message.rawValue)")
}
// Prints "[InputData.swift: 20] Error: 輸入數字 >= 100"

第二種作法是使用NSError。函數中如果要丟出NSError型態的錯誤,必須要先產生NSError實體,有三個參數需要填入NSError的初始化器中,分別是domain、code與userInfo。參數domain為錯誤領域,這個值可以用來分類錯誤,我們自己定義即可;code為錯誤代碼,也是我們自己定義;最後的userInfo為字典型態,最主要的key為NSLocalizedDescriptionKey,用來詳細描述錯誤原因。舉例如下:

func divided(_ a: Double, by b: Double) throws -> Double {
    guard b != 0 else {
        let userInfo = [
            NSLocalizedDescriptionKey: "錯誤原因:分母不可以為零"
        ]
        throw NSError(domain: "some_domain", code: 1234, userInfo: userInfo)
    }
    return a / b
}

當錯誤發生時印出error,就會看到豐富的錯誤訊息了。

do {
    try divided(5, by: 0)
} catch {
    print(error)
    // Prints "Error Domain=some_domain Code=1234 "錯誤原因:分母不可以為零" UserInfo={NSLocalizedDescription=錯誤原因:分母不可以為零}"
}

16.4 try! 與 try?

Swift對會丟出錯誤函數的處理是非常彈性的,這個彈性甚至包含了不處理,但是如果呼叫者選擇不處理,Swift會要求呼叫者「簽名畫押」表示是自己不處理的。

我們再以上一節的divided(_:by:)為例,如果我們已經非常確定呼叫時輸入的分母不是0,這時候其實並不需要撰寫do-cache,因為呼叫後不會產生任何錯誤,所以可以選擇不處理這個錯誤。這時呼叫的語法為:

let result = try! divided(10, by: 4)
// result == 2.5

「try!」就是一種簽名畫押,表示這個函數呼叫後保證不會產生錯誤。但如果我們無法很肯定但又不想寫do-cache,另一種彈性作法就是使用「try?」,try?會在錯誤發生時,以nil取代函數呼叫結果,只要檢查是不是nil就可以知道是不是發生錯誤了。例如下面這個函數呼叫結果會丟出錯誤,但try?會讓這個結果變成nil,於是使用if let語句就可以判斷出函數呼叫是否成功執行。

if let result = try? divided(10, by: 0) {
    print(result)
}

繼續傳遞錯誤

如果在函數中呼叫另外一個會丟出錯誤的函數時,我們可以選擇將這個錯誤傳出去,這時就不需要做錯誤處理了,例如函數f(_:)接受一個csv格式的字串,內容為 “5,3”,經由字串分割與型態轉換後呼叫除法運算的函數,但是在函數f(_:)中選擇將除法運算的錯誤繼續往外丟,打算交由f(_:)的呼叫者去處理。所以函數f(_:)在宣告時要加上throws,然後只要用try去呼叫除法運算就可以了。

func f(_ str: String) throws -> Double {
    let arr = str.split(separator: ",")
    let a = Double(arr[0])!
    let b = Double(arr[1])!
    return try divided(a, by: b)
}

do {
    let result = try f("5,3")
    print(result)
} catch {
    print(error)
}

發表迴響