Swift 中的錯誤處理從 O-C 沿襲而來,但 Swift 1.0 之後逐漸發生了巨大改變。重要的改變發生在 Swift 2,它率先使用了“處理非異常的狀態和條件”的做法,使你的 app 變得更加簡單。
類似於其它編程語言,在 Swift 中,選擇使用哪種錯誤處理技術,需要根據具體的錯誤類型和 app 整體架構而定。
本教程將演示一個“魔法”,在這個例子中,不但有男巫、女巫和蝙蝠,還有蟾蜍,以此來演示在常見錯誤處理過程中的最佳實踐。你還可以看到,如何將使用 Swift 早期版本編寫的錯誤處理進行升級,最終使用你的水晶球看到未來 Swift 的錯誤處理將是什麼樣子。
注:本教程假設你已經熟悉了 Swift 2 語法——尤其是枚舉和可空。如果你不知道這些概念,請閱讀 Greg Heo 的 What’s New in Swift 2 post。
好了,讓我們開始領略 Swift2 的錯誤處理的迷人魅力吧!
開始
本教程有兩個開始項目(playground)。一節一個,分別是:Avoiding-Errors-with-nil-Starter.playground 和 Avoiding-Errors-with-Custom-Handling-Starter.playground。
打開第一個 playground 文件。
閱讀代碼,你將發現幾個類、結構和枚舉。
注意如下代碼:
protocol MagicalTutorialObject {
var avatar: String { get }
}
這個協議會被教程中所有類和結構所采用,並用於提供一個能夠將每個對象打印到控制台的 String 對象。
enum MagicWords: String {
case Abracadbra = “abracadabra”
case Alakazam = “alakazam”
case HocusPocus = “hocus pocus”
case PrestoChango = “presto chango”
}
這個枚舉用於表示“咒語”,它將被“念”(spell)出來。
struct Spell: MagicalTutorialObject {
var magicWords: MagicWords = .Abracadbra
var avatar = "*"
}
這個結構用於將咒語“念”出來。默認情況下,其 magicWords 屬性的初始值是 Abracadabra。
你已經了解在這個魔法世界的基本知識了,你可以開始練習咒語了。
為什麼要進行錯誤處理?
“錯誤處理是一門讓錯誤變得優雅的藝術。”
–Swift Apprentice,第 21 章(錯誤處理)
良好的錯誤處理能增強用戶體驗,讓軟件維護者更容易發現問題,了解出錯的原因以及錯誤的嚴重性。當代碼中的錯誤的處理無所不在的時候,診斷問題就變得更加容易了。錯誤處理還會讓系統以正確的方式終止執行,避免用戶產生不必要的困擾。
當然並不是所有的錯誤都需要被處理。當不對錯誤進行處理時,語言特性也會進行某種級別的錯誤處理。一般,如果你能夠避免錯誤的發生,則盡量避免。如果實在無法避免,則最好的做法就是錯誤處理。
避免 Swift 引用為空錯誤
由於 Swift 已經有了優雅的可空處理機制,類似這種錯誤:在你以為有值的地方卻沒有值——是可以完全避免的。作為一個聰明的程序員,你可以利用這種特性,在某種錯誤發生時故意返回一個 nil。如果你不想在錯誤發生時采取任何動作時,這種方式很好用,例如在事故發生時采取不作為措施。
避免 Swift 引用為空的兩個典型例子就是:允許失敗的初始化方法,以及 guard 語句。
允許失敗的初始化方法
允許失敗的初始化方法防止你創建出不完全滿足創建條件的對象。在 Swift 2 之前(已經其它語言),這種方法通常在工廠方法設計模式中用到。
在 Swift 中的這種設計模式體現在 createWithMagicWords 中:
static func createWithMagicWords(words: String) -> Spell? {
if let incantation = MagicWords(rawValue: words) {
var spell = Spell()
spell.magicWords = incantation
return spell
}
else {
return nil
}
}
上述初始化方法企圖用指定的咒語創建一個 Spell 對象,如果提供給它的 words 參數不是一個合法的咒語,則返回一個 nil 對象。
在本教程底部檢查 Spell 對象的創建語句,你會看到:
第一個語句用“abracadabra”成功創建了一個 Spell 對象,但第二句使用”ascendio” 就不行了,返回了一個 nil 對象。(哈,巫師不是每次都能成功念出咒語的)
工廠方法是一種古舊的編程風格。其實在 Swift 中我們可以有更好的選擇。你可以將 Spell 中的工廠方法修改為“允許失敗的初始化方法”。
刪除createWithMagicWords(_:) 並替換為:
init?(words: String) {
if let incantation = MagicWords(rawValue: words) {
self.magicWords = incantation
}
else {
return nil
}
}
這裡,在這個方法聲明中,我們沒有顯式地創建和返回一個 Spell 對象。
噢,這兩句出現編譯錯誤了:
let first = Spell.createWithMagicWords("abracadabra")
let second = Spell.createWithMagicWords("ascendio")
你需要將它們修改成調用新方法。將上面的語句修改為:
let first = Spell(words: "abracadabra")
let second = Spell(words: "ascendio")
這樣,錯誤消失,playground 編譯成功。這種改變讓你的代碼更整潔——但你還有更好的解決辦法!
Guard 語句
guard 語句是一種更好的斷言某些情況為 true 的方式:例如,判斷一個值大於 0,或者判斷某個值是否能夠被解包的時候。如果這種情況都不滿足,你可以執行語句塊。
guard 語句在 Swift 2 才開始引入,通常用於在調用堆棧中進行冒泡法錯誤處理,這種方法中,錯誤將在最後才被處理。guard 語句能夠盡早從方法/函數中退出,比起需要判斷某個條件滿足剩下的邏輯才會執行來說,顯得更加簡單。
將 Spell 的允許失敗的初始化方法修改為 guard 語句:
init?(words: String) {
guard let incantation = MagicWords(rawValue: words) else {
return nil
}
self.magicWords = incantation
}
在這裡,我們不需要將 else 放在單獨的行上,而且對斷言失敗的處理變得顯眼,因為它被更放在了方法的頭部。同時,“黃金路徑”縮進最少。“黃金路徑”是指當每件事都如預期即沒有錯誤發生時的執行路徑。而縮進最少,則使它更易於被看到。
注,雖然 first 和 second 最終值不會有任何改變,但代碼變得更加合理化。
對錯誤進行定制化處理
在完成 Spell 的初始化方法並利用 nil 避免某些錯誤之後,你將學習某些更高級的錯誤處理。
對於本教程的第二部分內容,請打開 Avoiding Errors with Custom Handling – Starter.playground。
看一下這些代碼:
struct Spell: MagicalTutorialObject {
var magicWords: MagicWords = .Abracadbra
var avatar = "*"
init?(words: String) {
guard let incantation = MagicWords(rawValue: words) else {
return nil
}
self.magicWords = incantation
}
init?(magicWords: MagicWords) {
self.magicWords = magicWords
}
}
這是 Spell 的初始化方法,在第一部分內容的基礎上修改而來。注意,MagicalTutorialObject 協議的使用,以及第二個允許失敗的初始化方法,為了方便我們添加了它。
protocol Familiar: MagicalTutorialObject {
var noise: String { get }
var name: String? { get set }
init()
init(name: String?)
}
Familiar 協議會被使用到各種動物(比如蝙蝠和蟾蜍)。
注:Familiar 的意思是僕從,也就是男巫或女巫的動物精靈,擁有類人的特點。比如《哈利波特》中的貓頭鷹(名為 Hedwig),或者《The Wizard of Oz》中的飛猴。
雖然它不是 Hewig,但仍然很漂亮,不是嗎?
struct Witch: MagicalBeing {
var avatar = "*"
var name: String?
var familiar: Familiar?
var spells: [Spell] = []
var hat: Hat?
init(name: String?, familiar: Familiar?) {
self.name = name
self.familiar = familiar
if let s = Spell(magicWords: .PrestoChango) {
self.spells = [s]
}
}
init(name: String?, familiar: Familiar?, hat: Hat?) {
self.init(name: name, familiar: familiar)
self.hat = hat
}
func turnFamiliarIntoToad() -> Toad {
if let hat = hat {
if hat.isMagical { // When have you ever seen a Witch perform a spell without her magical hat on ? :]
if let familiar = familiar { // Check if witch has a familiar
if let toad = familiar as? Toad { // Check if familiar is already a toad - no magic required
return toad
} else {
if hasSpellOfType(.PrestoChango) {
if let name = familiar.name {
return Toad(name: name)
}
}
}
}
}
}
return Toad(name: "New Toad") // This is an entirely new Toad.
}
func hasSpellOfType(type: MagicWords) -> Bool { // Check if witch currently has appropriate spell in their spellbook
return spells.contains { $0.magicWords == type }
}
}
最後,是女巫。請看下面:
•女巫的初始化需要一個名字和一只精靈,或者一個名字、一只精靈和一頂帽子。
•女巫會念許多咒語,用一個 spells 保存,即一個 Spell 數組。
•女巫有一個嗜好,當她一念到咒語:“PrestoChango”,她的精靈就會被變成一只蟾蜍,這個動作用 turnFamiliarIntoToad() 方法
注意 turnFamiliarIntoToad() 方法中的縮進。在這個方法中,如果遇到任何錯誤,會返回一只全新的蟾蜍。這看起來有點不對勁(這是錯誤的!)。在下一部分,你將用自定義錯誤處理來解決這個問題。
用 Swift 錯誤進行重構
Swift 提供了運行時拋出、捕獲、傳遞和操縱可恢復類型錯誤的支持。
-《The Swift Programming Language (Swift 2.2)》
與“死亡之廟”不同,在 Swift 或其它語言中,“厄運金字塔”是另外一種相反的模型。使用這種模型會在控制流中使用多級嵌套。例如上面的 turnFamiliarIntoToad() 方法,使用了 6 個 } 符號才能結束嵌套,基本構成了一條對角線。這樣的代碼閱讀起來相當費勁。
厄運金字塔
使用先前提到的 guard 語句,以及可空綁定,能夠避免出現“厄運金字塔”代碼。do-catch 機制能夠將錯誤處理從控制流中解耦出來,從減少“厄運金字塔”的出現。
do-catch 機制常用的關鍵字包括:
•throws
•do
•catch
•try
•defer
•ErrorType
要試一試 do-catch 機制,你將拋出多個自定義錯誤。首先,你需要定義一個枚舉,將所有你想處理的狀態列到其中,而這些狀態可能表明某個地方東西出錯了。
在 Witch 類定義之上添加如下代碼:
enum ChangoSpellError: ErrorType {
case HatMissingOrNotMagical
case NoFamiliar
case FamiliarAlreadyAToad
case SpellFailed(reason: String)
case SpellNotKnownToWitch
}
關於 ChangoSpellError 有兩點需要注意:
•它采用了 ErrorType 協議,這是必須的。在 Swift 中, ErrorType 表明了這是一種錯誤。
•在 SpellFailed 的 case 分支,你可以指定一種自定義的原因,表示為什麼咒語會念錯。
注:ChangoSpellError 的名字來自於咒語“Presto Chango!”——女巫在將精靈變成蟾蜍時念的咒語。
好了,親愛的,趕緊施展你的魔法吧。很好。在方法簽名中添加一個 throws 關鍵字,表明方法調用時可能會拋出錯誤:
func turnFamiliarIntoToad() throws -> Toad {
Update it as well on the MagicalBeing protocol:
protocol MagicalBeing: MagicalTutorialObject {
var name: String? { get set }
var spells: [Spell] { get set }
func turnFamiliarIntoToad() throws -> Toad
}
現在,你擁有了錯誤狀態列表,接下來需要重新編寫 turnFamiliarIntoToad() 方法,針對每個錯誤類型編寫不同的處理語句。
處理帽子的錯誤
首先,修改下列語句,確保女巫已經佩戴了她永不離身的魔法師帽。
修改之前的代碼:
if let hat = hat {
修改之後的代碼:
guard let hat = hat else {
throw ChangoSpellError.HatMissingOrNotMagical
}
注:不要忘記在方法底部將對應的 } 也刪掉。否則 playground 會編譯錯誤!
下一句是對一個布爾值進行檢查,這也和魔法師帽有關:
if hat.isMagical {
你可以再用一個 guard 語句進行檢查,也可以將兩個檢查合並到一個 guard 語句——這顯然要清晰和簡潔得多。因此將第一個 guard 語句修改為:
guard let hat = hat where hat.isMagical else {
throw ChangoSpellError.HatMissingOrNotMagical
}
然後將 if hat.isMagical { 刪除。
在接下來的部分,你將繼續破解“金字塔”問題。
處理精靈的錯誤
接著,判斷巫師是否有一只精靈:
if let familiar = familiar {
將這句用拋出一個 .NoFamiliar 錯誤來替換:
guard let familiar = familiar else {
throw ChangoSpellError.NoFamiliar
}
忽略此時出現的任何錯誤,因為接下來的代碼會讓它們消失。
處理蟾蜍的錯誤
接下來一句,如果女巫在試圖用 turnFamiliarIntoToad() 方法時發現她的精靈其實已經是一只蟾蜍了,則返回已有的蟾蜍。但這裡更好的做法是,用一個錯誤來表示這種情況。將下列代碼:
if let toad = familiar as? Toad {
return toad
}
修改為:
if familiar is Toad {
throw ChangoSpellError.FamiliarAlreadyAToad
}
注意,我們將 as? 改為了 is。在需要檢查某個對象是否能夠轉換為某個協議,但同時不需要使用轉換結果時,這種寫法更加簡潔。is 關鍵字也可以更加泛型化的方式進行類型比較。如果你想了解更多內容,請閱讀The Swift Programming Language“類型轉換”一節。
將 else 之內的代碼移到 else 之外,然後刪除 else 語句,它沒用了。
處理咒語的錯誤
最後,調用了 hasSpellOfType(type:) 方法,以檢查女巫的魔法書中確實有相應的咒語。將下列代碼:
if hasSpellOfType(.PrestoChango) {
if let toad = f as? Toad {
return toad
}
}
修改為:
guard hasSpellOfType(.PrestoChango) else {
throw ChangoSpellError.SpellNotKnownToWitch
}
guard let name = familiar.name else {
let reason = "Familiar doesn’t have a name."
throw ChangoSpellError.SpellFailed(reason: reason)
}
return Toad(name: name)
現在,刪除最後一行不安全的代碼。也就是這行:
return Toad(name: "New Toad")
現在,你的方法變得更清晰和整潔,已經能夠使用了。我在上述的代碼添加了注釋,以解釋這個方法所做的工作:
func turnFamiliarIntoToad() throws -> Toad {
// When have you ever seen a Witch perform a spell without her magical hat on ? :]
guard let hat = hat where hat.isMagical else {
throw ChangoSpellError.HatMissingOrNotMagical
}
// Check if witch has a familiar
guard let familiar = familiar else {
throw ChangoSpellError.NoFamiliar
}
// Check if familiar is already a toad - if so, why are you casting the spell?
if familiar is Toad {
throw ChangoSpellError.FamiliarAlreadyAToad
}
guard hasSpellOfType(.PrestoChango) else {
throw ChangoSpellError.SpellNotKnownToWitch
}
// Check if the familiar has a name
guard let name = familiar.name else {
let reason = "Familiar doesn’t have a name."
throw ChangoSpellError.SpellFailed(reason: reason)
}
// It all checks out! Return a toad with the same name as the witch's familiar
return Toad(name: name)
}
你曾經在 turnFamiliarIntoToad() 方法中返回一個可空來表示“在念咒語時出了差錯”,但使用自定義錯誤能夠更加清晰地表達錯誤的狀態,以便你根據這些狀態采取對應措施。
自定義錯誤還有什麼好處?
現在,你有一個方法拋出了一個自定義 Swift 錯誤,你需要處理它們。接下來的標准動作是使用 do-catch 語句,這就好比 Java 等語言中的 try-catch 語句。
在 playground 的底部加入下列代碼:
func exampleOne() {
print("") // Add an empty line in the debug area
// 1
let salem = Cat(name: "Salem Saberhagen")
salem.speak()
// 2
let witchOne = Witch(name: "Sabrina", familiar: salem)
do {
// 3
try witchOne.turnFamiliarIntoToad()
}
// 4
catch let error as ChangoSpellError {
handleSpellError(error)
}
// 5
catch {
print("Something went wrong, are you feeling OK?")
}
}
一下是對這個方法的解釋:
•創建女巫的精靈,它的名字叫 Salem。
•創建女巫,名字叫 Sabrina。
•試圖將貓咪變成蟾蜍。
•捕獲 ChangoSpellError 並進行相應的處理。
•捕獲其它錯誤,打印友好信息。
寫完上述代碼,你會看到一個編譯錯誤——讓我們來搞定它。
handleSpellError() 方法還沒有定義,在 exampleOne() 方法之上加入這個方法:
func handleSpellError(error: ChangoSpellError) {
let prefix = "Spell Failed."
switch error {
case .HatMissingOrNotMagical:
print("\(prefix) Did you forget your hat, or does it need its batteries charged?")
case .FamiliarAlreadyAToad:
print("\(prefix) Why are you trying to change a Toad into a Toad?")
default:
print(prefix)
}
}
最後,在 playground 最後執行這個方法:
exampleOne()
點擊 Xcode 工作空間左下角的上箭頭,打開 Debug 控制台,你就會看到 playground 的輸出了:
捕獲錯誤
下面對上述代碼中的每個語法特性進行簡單討論。
catch
你可以用 Swift 的模板匹配來處理某種錯誤,或者將錯誤類型進行分組處理。
前面的代碼示范了 catch 的兩個用法:一個是用於捕捉 ChangoSpell 錯誤,一種用於捕捉剩下的錯誤。
try
try 與 do-catch 語句配合使用,用於清晰定位是哪行語句或代碼塊將拋出錯誤。
try 語句有幾種不同的用法,上面用到了其中之一:
•try: 標准用法,在簡單的、立即的 do-catch 語句中使用。就是前面代碼中的用法。
•try?: 處理錯誤,以忽略該錯誤的方式;如果有錯誤拋出,這個語句的結果是 nil。
•try!: 類似強制解包,這個前綴會創建期望的對象,理論上這個語句會拋出錯誤,但實際上這種錯誤永遠不會發生。 try! 可以用於執行加載文件的動作,特別是當你明確知道文件是肯定存在的。就如前置解包一樣,使用這種結構需要特別謹慎。
讓我們來體驗一下 try? 的使用。復制粘貼下列代碼到 playgournd 的底部:
func exampleTwo() {
print("") // Add an empty line in the debug area
let toad = Toad(name: "Mr. Toad")
toad.speak()
let hat = Hat()
let witchTwo = Witch(name: "Elphaba", familiar: toad, hat: hat)
let newToad = try? witchTwo.turnFamiliarIntoToad()
if newToad != nil { // Same logic as: if let _ = newToad
print("Successfully changed familiar into toad.")
}
else {
print("Spell failed.")
}
}
注意和 exampleOne 不同的地方。在這裡我們不需要知道具體的錯誤輸出了些什麼,只是在它們拋出是捕獲它們。Toad 對象最終會創建失敗,因此 newToad 的值應當為 nil。
傳遞錯誤
throws
在 Swift 中,如果方法或函數代碼中會拋出錯誤,則必須用到 throws 關鍵字。被拋出的錯誤會自動在調用堆棧中進行傳遞,但如果讓錯誤從現場地向上冒泡太多並不是一個好主意。在代碼庫中充斥大量的錯誤傳遞會增加錯誤不被正確處理的可能性,因此 throws 是強制性的,以確保錯誤的傳遞被代碼所記錄——對於程序員來說是顯而易見的。
rethrows
目前你所見到的例子都是關於 throws 的,而沒有它的親兄弟 rethrows 的嗎?
rethrows 告訴編譯器,這個函數會拋出一個錯誤,同時它的參數也會拋出一個錯誤。下面是一個例子(不需要將它加到你的 playground 裡):
func doSomethingMagical(magicalOperation: () throws -> MagicalResult) rethrows -> MagicalResult {
return try magicalOperation()
}
這個方法只會拋出 magicalOperation 參數拋出的那個錯誤。如果成功,它返回一個 MagicalResult 對象。
操縱錯誤處理的行為
defer
盡管大部分情況下,我們讓錯誤自動傳播就可,但某些情況下,你可能想控制錯誤在調用堆棧中傳遞時 app 的行為。
defer 語句提供一種機制,讓你在當前作用域結束時執行某些“清理”動作,比如方法或函數返回時。它可以清理某些資源,而無論動作是否執行成功或失敗,尤其在錯誤處理上下文中有用。
要測試這種行為,請在 Witch 結構中加入如下方法:
func speak() {
defer {
print("*cackles*")
}
print("Hello my pretties.")
}
在 playground 底部加入代碼:
func exampleThree() {
print("") // Add an empty line in the debug area
let witchThree = Witch(name: "Hermione", familiar: nil, hat: nil)
witchThree.speak()
}
exampleThree()
在 Debug 控制台,你將看到女巫在每說一句話之後都會“咯咯笑”(cackles)。
有趣的是,defer 語句的執行順序與書寫順序相反。
在 speak() 方法中添加另一個 defer 語句,這樣當女巫說完一句話後,會先尖叫,然後再發出“咯咯”的笑聲。
func speak() {
defer {
print("*cackles*")
}
defer {
print("*screeches*")
}
print("Hello my pretties.")
}
打印順序是否如你所想?呵,神奇的 defer!
其它和錯誤有關的事
總而言之,Swift 已經和其他主流語言站到了同一起跑線上,同時 Swift 也不再采用 O-C 的基於 NSError 的錯誤處理機制。O-C 錯誤很多時候是被轉換過的了,由編譯器中的靜態分析器幫你很好地完成了注入捕捉什麼樣的錯誤和錯誤何時發生的工作。
盡管 do-catch 和相關特性在其他語言中有不小的開銷,但在 Swift 中,它們被視作和其它語句完全相同。這使得它們保持經濟和高效。
雖然你可以創建自定義錯誤並隨意拋出它們,但不意味著你就應該那樣做。在每個項目中,當你需要拋出並捕捉錯誤時,你都應當遵循一定的開發指南。我建議:
•不管在哪個代碼庫中,你的錯誤類型都需要命名清晰。
•在只有一種錯誤狀態時,使用可空就可以了。
•在超過一種以上的錯誤狀態是,才使用自定義錯誤。
•不要讓錯誤從錯誤現場傳遞到太遠的地方。
Swift 將來的錯誤處理
在各大 Swift 論壇中,有很多關於未來的錯誤處理的想法。其中討論得最多的一個是無類型傳遞。
“…我們覺得應該在當前的處理模型中增加對無類型傳遞的支持,以針對通用型的錯誤。做好這一點,尤其是在不增加代碼尺寸和性能代價的前提下,需要有足夠的決心和遠見。因此,這被看成是 Swift 2.0 以後的任務。”
– from Swift 2.x Error Handling
無論你是否喜歡這種觀點,Swift 3 中的錯誤處理必將有重大改變,或者你只關心眼前的一切,你也需要知道隨著這門語言的演進,那種清晰的錯誤處理機制正在被激烈地討論和改進當中。