本文最初於 2015 年 12 月發佈在 IBM developerWorks 中國網站發表,其網址是 http://www.ibm.com/developerworks/cn/mobile/mo-cn-swift/index.html。如需轉載請保留此行聲明。html
throws 關鍵字和異常處理機制是 Swift 2 中新加入的重要特性。Apple 但願經過在語言層面對異常處理的流程進行規範和統一,來讓代碼更加安全,同時讓開發者能夠更加及時可靠地處理這些錯誤。Swift 2 中全部的同步 Cocoa API 的 NSError
都已經被 throw 關鍵字取代,舉個例子,在文件操做中複製文件的 API 在 Swift 1 中使用的是和 Objective-C 相似的 NSError
指針方式:前端
func copyItemAtPath(_ srcPath: String, toPath dstPath: String, error: NSErrorPointer)
而在 Swift 2 中,變爲了 throws:git
func copyItemAtPath(_ srcPath: String, toPath dstPath: String) throws
使用時,Swift 1.x 中咱們須要建立並傳入 NSError
的指針,在方法調用後檢查指針的內容,來判斷是否成功:github
let fileManager = NSFileManager.defaultManager() var error: NSError? fileManager.copyItemAtPath(srcPath, toPath: dstPath, error: &error) if error != nil { // 發生了錯誤 } else { // 複製成功 }
在實踐中,由於這個 API 僅會在極其特定的條件下 (好比磁盤空間不足) 會出錯,因此開發者爲了方便,有時會直接傳入 nil 來忽視掉這個錯誤:swift
let fileManager = NSFileManager.defaultManager() // 不關心是否發生錯誤 fileManager.copyItemAtPath(srcPath, toPath: dstPath, error: nil)
這種作法無形中下降了應用的可靠性以及從錯誤中恢復的能力。爲了解決這個問題,Swift 2 中在編譯器層級就對 throws 進行了限定。被標記爲 throws 的 API,咱們須要完整的 try catch
來捕獲可能的異常,不然沒法編譯經過:後端
let fileManager = NSFileManager.defaultManager() do { try fileManager.copyItemAtPath(srcPath, toPath: dstPath) } catch let error as NSError { // 發生了錯誤 print(error.localizedDescription) }
對於非 Cocoa 框架的 API,咱們也能夠經過聲明 ErrorType
並在出錯時進行 throw 操做。這爲錯誤處理提供了統一的處理出口,有益於提升應用質量。安全
throws 關鍵字究竟作了些什麼,咱們能夠用稍微底層一點的手法來進行一些探索。閉包
全部的 Swift 源文件都要通過 Swift 編譯器編譯後才能執行。Swift 編譯過程遵循很是經典的 LLVM 編譯架構:編譯器前端首先對 Swift 源碼進行詞法分析和語法分析,生成 Swift 抽象語法樹 (AST),而後從 AST 生成 Swift 中間語言 (Swift Intermediate Language,SIL),接下來 SIL 被翻譯成通用的 LLVM 中間表述 (LLVM Intermediate Representation, LLVM IR),最後經過編譯器後端的優化,獲得彙編語言。整個過程能夠用下面的框圖來表示:架構
Swift 編譯器提供了很是靈活的命令行工具:swiftc,這個命令行工具能夠運行在不一樣模式下,咱們經過控制命令行參數能獲取到 Swift 源碼編譯到各個階段的結果。使用 swiftc --help
咱們能得知各個模式的使用方法,這篇文章會用到下面幾個模式,它們分別將 Swift 源代碼編譯爲 SIL,LLVM IR 和彙編語言。app
> swiftc --help ... MODES: -emit-sil Emit canonical SIL file(s) -emit-ir Emit LLVM IR file(s) -emit-assembly Emit assembly file(s) (-S) ...
在 Swift 開源以前,將源碼編譯到各個階段是探索 Swift 原理和實現方式的重要方式。即便是在 Swift 開源後的今天,在面對一段代碼時,想要知道編譯結果和底層的行爲,最快的方式仍是查看編譯後的語句。咱們接下來將會分析一段簡單的 throw 代碼,來看看 Swift 的異常機制究竟是如何運做的。
爲了保持問題的簡單,咱們定義一個最簡單的 ErrorType
並用一個方法來將其拋出,源代碼以下:
// throw.swift enum MyError: ErrorType { case SampleError } func throwMe(shouldThrow: Bool) throws -> Bool { if shouldThrow { throw MyError.SampleError } return true }
使用 swiftc 將其編譯爲 SIL:
swiftc -emit-sil -O -o ./throw.sil ./throw.swift
在輸出文件中,能夠找到 throwMe
的對應 Swift 中間語言表述:
// throw.throwMe (Swift.Bool) throws -> Swift.Bool sil hidden @_TF5throw7throwMeFzSbSb : $@convention(thin) (Bool) -> (Bool, @error ErrorType) { bb0(%0 : $Bool): debug_value %0 : $Bool // let shouldThrow // id: %1 %2 = struct_extract %0 : $Bool, #Bool.value // user: %3 cond_br %2, bb1, bb2 // id: %3 bb1: // Preds: bb0 ... throw %4#0 : $ErrorType // id: %7 bb2: // Preds: bb0 ... return %9 : $Bool // id: %10 }
_TF5throw7throwMeFzSbSb
是 throwMe
方法 Mangling 之後的名字。在去掉一些噪音後,咱們能夠將這個方法的簽名等效看作:
throwMe(shouldThrow: Bool) -> (Bool, ErrorType)
它實際上是返回的是一個 (Bool, ErrorType)
的多元組。和通常的多元組不一樣的是,第二個元素ErrorType
被一個 @error
修飾了。這個修飾讓多元組具備了「排他性」,也就是隻要多元組的第一個元素被返回便可:在條件分支 bb2
(也即沒有拋出異常的正常分支) 中,僅只有 Bool 值被返回了。而對於發生錯誤須要拋出的處理,SIL 層面還並無具體實現,只是生成了對應的錯誤枚舉對象,而後對其調用了 throw 命令。
這就是說,咱們想要探索 throw 的話,還須要更深刻一層。用 swiftc 將源代碼編譯爲 LLVM IR:
swiftc -emit-ir -O -o ./throw.ir ./throw.swift
結果中 throwMe
的關鍵部分爲:
define hidden i1 @_TF5throw7throwMeFzSbSb(i1, %swift.refcounted* nocapture readnone, %swift.error** nocapture) #0 { }
這是咱們很是熟悉的形式,參數中的 swift.error**
和 Swift 1 以及 Objective-C 中使用NSError
指針來獲取和存儲錯誤的作法是一致的。在示例的這種狀況下,LLVM 後端針對 swift.error 進行了額外處理,最終獲得的彙編碼的僞碼是這樣的 (在未啓用 -O 優化的條件下):
int __TF5throw7throwMeFzSbSb(int arg0) { rax = arg0; var_8 = rdx; if ((rax & 0x1) == 0x0) { rax = 0x1; } else { rax = swift_allocError(0x1000011c8, __TWPO5throw7MyErrorSs9ErrorTypeS_); var_18 = rax; swift_willThrow(rax); rax = var_8; *rax = var_18; } return rax; }
函數最終的返回是一個 int,它有多是一個實際的整數值,也有多是一個指向錯誤地址的指針。這和 Swift 1 中傳入 NSErrorPointer
來存儲錯誤指針地址有明顯不一樣:首先直接使用返回值咱們就能夠判斷調用是否出現錯誤,而沒必要使用額外的空間進行存儲;其次整個過程當中沒有使用到NSError
或者 Objective-C Runtime 的任何內容,在性能上要優於傳統的錯誤處理方式。
咱們在瞭解了 throw 的底層機理後,對於 try catch
代碼塊的理解天然也就水到渠成了。加入一個 try catch
後的 SIL 相關部分是:
try_apply %15(%16) : $@convention(thin) (Bool) -> (Bool, @error ErrorType), normal bb1, error bb9 // id: %17 bb1(%18 : $Bool): ... bb9(%80 : $ErrorType): ...
其餘層級的實現也與此相似,都是對返回值進行類型判斷,而後進入不一樣的條件分支進行處理。
throw 語句的做用對象是一個實現了 ErrorType
接口的值,本節將探討 ErrorType
背後的內容,以及 NSError
與它的關係。在 Swift 公開的標準庫中,ErrorType
接口並無公開的方法:
public protocol ErrorType { }
這個接口有一個 extension,可是也沒有公開的內容:
extension ErrorType { }
咱們能夠經過使用 LLDB 的類型檢索來獲取關於這個接口的更多信息。在調試器中運行 type lookup ErrorType
:
(lldb) type lookup ErrorType protocol ErrorType { var _domain: Swift.String { get } var _code: Swift.Int { get } } extension ErrorType { var _domain: Swift.String { get {} } }
能夠看到這個接口實際上須要實現兩個屬性:domain 描述錯誤的所屬域,code 標記具體的錯誤號,這和傳統的 NSError
中定義一個錯誤所須要的內容是一致的。事實上 NSError
在 Swift 2 中也實現了 ErrorType
接口,它簡單地返回錯誤的域和錯誤代碼信息,這是 Swift 1 到 2 的錯誤處理相關 API 轉換的兼容性的保證。
雖然 Cocoa/CocoaTouch 框架中的 throw API 拋出的都是 NSError
,可是應用開發者更爲經常使用的表述錯誤的類型應該是 enum,這也是 Apple 對於 throw 的推薦用法。對於實現了 ErrorType
的 enum 類型,其錯誤代碼將根據 enum 中 case 聲明的順序從 0 開始編號,而錯誤域的名字就是它的類型全名 (Module 名 + 類型名):
MyError.InvalidUser._code: 0 MyError.InvalidUser._domain: ModuleName.MyError MyError.InvalidPassword._code: 1 MyError.InvalidPassword._domain: ModuleName.MyError
這雖然爲按照錯誤號來處理錯誤提供了可能性,可是咱們在實踐中應當儘可能依賴 enum case 而非錯誤號來對錯誤進行辨別,這能夠提升穩定性,同時下降維護的壓力。除了 enum 之外,struct 和 class 也是能夠實現 ErrorType
接口,並做爲被 throw 的對象的。在使用非 enum 值來表示錯誤的時候,咱們可能須要顯式地指定 _code
和 _domain
,以區分不一樣的錯誤。
帶有 throw 的方法如今只能工做在同步 API 中,這受限於異常拋出方法的基本思想。一個能夠拋出的方法實際上作的事情是執行一個閉包,接着選擇返回一個值或者是拋出一個異常。直接使用一個 throw 方法,咱們沒法在返回或拋出以前異步地執行操做並根據操做的結果來決定方法行爲。要改變這一點,理論上咱們能夠經過將閉包的執行和對結果的操做進行分離,來達到「異步拋出」的效果。假設有一個同步方法能夠拋出異常:
func syncFunc<A, R>(arg: A) throws -> R
經過爲其添加一次調用,能夠將閉包執行部分和結果判斷及返回部分分離:
func syncFunc<A, R>(arg: A)() throws -> R
這至關於將原來的方法改寫爲了:
func syncFunc<A, R>(arg: A) -> (Void throws -> R)
這樣,單次對 syncFunc
的調用將返回一個 Void throws -> R
類型的方法,這使咱們有機會執行代碼而不是直接返回或拋出。在執行 syncFunc
返回後,咱們還須要對其結果用 try
來進行判斷是否拋出異常。利用這個特色,咱們就能夠將這個同步的拋出方法改寫爲異步形式:
func asyncFunc<A, R>(arg: A, callback: (Void throws -> R) -> Void) { // 處理操做 let result: () throws -> R = { // 根據結果拋出異常或者正常返回 } return callback(result) } // 調用 asyncFunc(arg: someArg) { (result) -> Void in do { let r = try result() // 正常返回 } catch _ { // 出現異常 } }
繞了一大個圈子,咱們最後發現這麼作本質上其實和簡單地使用 Result<T, E>
來表示異步方法的結果並無本質區別,反而增長了代碼閱讀和理解的難度,也破壞了 Swift 異常機制本來的設計意圖,其實並非可取的選項。除開某些很是特殊的用例外,對於異步 API 如今並不適合使用 throw 來進行錯誤判斷。
在 XCTest 中暫時尚未直接對 Swift 2 異常處理進行測試的方法,若是想要測試某個調用應當/不該當拋出某個異常的話,咱們能夠對 XCTest 框架的方法進行一些額外但很簡單包裝,傳入 block 並運行,而後在 try 塊或是 catch 塊內進行 XCTAssert 的斷言檢測。在 Apple 開發者論壇有關於這個問題的更詳細的討論,完整的示例代碼和使用例子能夠在這裏找到。
Swift 2 中異常另外一個嚴重的不足是類型不安全。throw 語句能夠做用於任意知足 ErrorType
的類型,你能夠 throw 任意域的錯誤。而在 catch 塊中咱們也一樣能夠匹配任意的錯誤類型,這一切都沒有編譯器保證。因爲這個緣由,如今的異常處理機制並很差用,須要處理異常的開發者每每須要通讀文檔才能知道可能會有哪些異常,而文檔的維護又是額外的工做。缺乏強制機制來保證異常拋出和捕獲的類型的正確性,這爲程序中 bug 的出現埋下了隱患。
事實上從咱們以前對 throw 底層實現的分析來看,在語言層面上實現只拋出某一特定類型的錯誤並非很困難的事情。可是考慮到與 NSError
和傳統錯誤處理 API 兼容問題,Swift 2 中並無這樣實現,也許咱們在以後的 Swift 版本中能看到限定類型的異常機制。
Swift 的異常拋出並非傳統意義的 exception,在調試時拋出異常並不會觸發 Exception 斷點。另外,throw 自己是語言的關鍵字,而不是一個 symbol,它也不能觸發 Symbolic 類型的斷點。若是咱們但願在全部 throw 語句執行的時候讓程序停住的話,須要一些額外的技巧。在以前 throw 的彙編實現中,能夠看到全部 throw 語句在返回前都會進行一次 swift_willThrow
的調用,這就是一個有效的 Symbolic 語句,咱們設置一個 swift_willThrow
的 Symbolic 斷點,就可讓程序在 throw 的時候停住,並使用調用棧信息來獲知程序在哪裏拋出了異常。
補充,在最新版本的 Xcode 中,Apple 直接爲咱們在斷點類型中加上了 「Swift Error Breakpoint」 的選項,它背後作的就是在
swift_willThrow
上添加一個斷點。不過由於有了更直接的方法,咱們如今再也不須要手動去添加這個符號斷點了。