Swift 斷言

JeffersonOv 8年前發布 | 11K 次閱讀 Swisst Apple Swift開發

斷言是一種非常有用的機制,它可以檢查代碼中的假設部分,確保錯誤能夠被及時發現。今天我將探討 Swift 中提供的斷言調用以及它們的實現,這個話題是由讀者 Matthew Young 提出的。

我不會花太多時間討論一般意義上的斷言是什么或者在哪里使用它們。本文將著眼于 Swift 中提供的斷言機制以及一些實現的細節。如果你想要了解如何在代碼中充分利用斷言,可以閱讀我以前的文章 Proper Use of Asserts(斷言的正確使用)

API

在 Swift 標準庫中有兩個主要的斷言函數。

第一個函數被創造性地命名為 assert。調用時需要一個真命題:

assert(x >= 0) // x 不能為負

該函數提供一個可選參數,用于命題為假時打印錯誤信息:

assert(x >= 0, "x can't be negative here")

assert 只有在非優化構建時有效。在開啟優化的情況下這行代碼不會被編譯。當存在某些條件計算耗性能,從而拖慢構建速度,但這些條件又是有用的,調試時必須進行檢查,那么斷言的這一特性就顯得很有用了。

有些人傾向僅在調試版本中使用斷言,理論上調試的時候去做一些檢查是個好習慣,但最好保證 app 不會在實際使用時崩潰。不管在斷言檢查中有沒有出現過,一旦(在實際使用中)出現錯誤,都會導致非常嚴重的后果。更好的做法是如果在實際使用時出現了錯誤,應用能夠快速退出。由此引出下一個函數。

函數 precondition 與 assert 非常像,調用時二者看起來一樣:

precondition(x >= 0) // x 不能為負
precondition(x >= 0, "x can't be negative here")

不同之處在于該函數在優化構建條件下也會執行檢查。這使得它成為斷言檢查的一個更好的選擇,并且檢查速度足夠快。

盡管 precondition 在優化構建中有效,在「非檢查(unchecked)」的優化構建中仍是無效的。「非檢查」的構建是通過在命令行指定 -Ounchecked來實現的。該指令的執行不僅會移除 precondition 調用,還會進行數組邊界檢查。這是很危險的,除非你別無選擇,不得不執行該命令外盡量不要用。

關于非檢查構建有趣的一點是,盡管 precondition 檢查被移除了,優化器仍會假設命題為真,并在此基礎上優化下面的代碼。在上述例子中,生成代碼不會再檢查 x 是否為負,但在接下來的編譯中會默認 x >= 0。這一點對于 assert 也是成立的。

這些函數各自有一個不帶條件的變體,用來標志失敗的情況。上述兩個函數的變體分別是 assertionFailure 和 preconditionFailure。當你要進行斷言檢查的條件與該函數的調用不太相符時,變體就顯得很有用了。例如:

guard case .Thingy(let value) = someEnum else {
  preconditionFailure("This code should only be called with a Thingy.")
}

優化下的行為和帶條件時類似,開啟優化時 assertionFailure 不會被編譯,preconditionFailure 則保留,但在「非檢查」優化構建時仍會被移除。「非檢查」構建時,優化器假設這些函數永遠不會執行,并基于該假設生成代碼。

最后還有個函數 fatalError。該函數表示出現異常并終止程序,而不管構建是否開啟優化或檢查。

記錄調用者信息

當斷言檢查未通過,會得到這樣一條信息:

precondition failed: x must be greater than zero: file test.swift, line 6

程序是如何獲知文件和代碼行的信息的呢?

在 C 語言中,我們將 assert 當做宏指令來用,同時使用 __FILE__ 和 __LINE__ 這兩個神奇的標識符來獲取信息:

#define assert(condition) do { \
  if(!(condition)) { \
      fprintf(stderr, "Assertion failed %s in file %s line %d\n", #condition, __FILE__, __LINE__); \
      abort(); \
  } \
}

這些函數最終以調用者的文件和代碼行信息結尾,就是因為此處的宏定義。Swift 中沒有宏的概念,那該怎么辦?

Swift 中可以使用默認參數值達到同樣效果。上述神奇的標識符可被當做參數的默認值使用。如果調用者沒有提供一個確切的值,便可將調用者所處的文件及代碼行作為默認值。目前,這兩個神奇的標識符分別是 __FILE__ 和 __LINE__,但在 Swift 下一版本中會變成 #file 和 #line,更加符合 Swift 風格。

探討實際中的使用前,我們先看看 assert 的定義:

public func assert(
  @autoclosure condition: () -> Bool,
  @autoclosure _ message: () -> String = String(),
  file: StaticString = #file, line: UInt = #line
)

通常情況下,調用 assert 僅傳遞一個或兩個參數。file 和 line 參數則作為默認值,用來傳遞調用者的相關信息。

沒有強制要求必須使用默認值,如果需要的話你可以傳入其他的值。比如:

assert(false, "Guess where!", file: "not here", line: 42)

最終輸出:

assertion failed: Guess where!: file not here, line 42

有種更加實用的用法,你可以寫一個包裝器來保留原始調用者的信息,例如:

func assertWrapper(
  @autoclosure condition: () -> Bool,
  @autoclosure _ message: () -> String = String(),
  file: StaticString = #file, line: UInt = #line
) {
  if !condition() {
      print("Oh no!")
  }
  assert(condition, message, file: file, line: line)
}

Swift 版的 assert 有個缺陷。上文提到的 C 版本的 assert 提供 #condition關鍵字,斷言檢查未通過時可以輸出表達式。而在 Swift 中不可以。因此,盡管 Swift 可以打印斷言失敗時的文件和代碼行信息,但用來檢查的表達式是無從獲知的。

自動閉包

上述函數都使用 @autoclosure 來修飾 condition 和 message 參數,為什么?

先快速回顧一下 @autoclosure@autoclosure 修飾的無參閉包可作為某個函數的形參,調用該函數時,調用者提供一個表達式作為實參。這個表達式會被包裝成閉包并傳遞給函數,例如:

func f(@autoclosure value: () -> Int) {
  print(value())
}

f(42)

等價于:

func f(value: () -> Int) {
  print(value())
}

f({ 42 })

為什么要把表達式包裝成閉包傳遞?因為這樣可以讓調用的函數來決定表達式具體執行的時間。例如,對于實現兩個布爾類型的 && 運算符時,我們可以通過傳入兩個 Bool 參數實現:

func &&(a: Bool, b: Bool) -> Bool {
        if a {
            if b {
                return true
            }
        }
        return false
    }

有些情況下我們直接調用就可以:

x > 3 && x < 10

但如果右操作數計算復雜的話是很耗時的:

x > 3 && expensiveFunction(x) < 10

假定左操作數為 false 時,右操作數不會被執行的話,還有可能直接崩潰掉:

optional != nil && optional!.value > 3

跟 C 語言一樣,Swift 中的 && 也是短路操作符。左操作數為 false 時就不再計算右操作數了。因此該表達式在 Swift 中是安全的,但對我們的函數則不行。@autoclosure 使得函數可以控制表達式執行的時間,保證只有左操作數為 true的前提下才去執行該表達式:

func &&(a: Bool, @autoclosure b: () -> Bool) -> Bool {
  if a {
    if b() {
      return true
    }
  }
  return false
}

現在就符合 Swift 的語義了,當 a 為 false 時 b 永遠不會執行。

對斷言而言,則完全是考慮性能問題。因為斷言消息有可能是很耗時的操作。例如:

assert(widget.valid, "Widget wasn't valid: \(widget.dump())")

你肯定不想每次都去計算一長串字符串,即便 widget 是合法、什么都不必輸出的時候。對消息參數使用 @autoclosure 修飾,assert 便可避免計算message 表達式,除非當斷言檢查不通過的時候。

條件本身也是 @autoclosure,因為優化構建下 assert 不會去檢查條件。既然不去檢查,也就不涉及計算了。使用 @autoclosure 意味著不會拖慢優化構建的速度:

assert(superExpensiveFunction())

本文提到的 API 中的函數都使用了 @autoclosure 來保證除非不得已情況下,盡量避免參數的計算。出于某種原因,連 fatalError 都使用了@autoclosure 修飾,盡管它是無條件執行的。

代碼移除

基于代碼的編譯情況,這些函數會在代碼生成時被移除。它們位于 Swift 標準庫,而不是你自己寫的代碼中,而 Swift 標準庫的編譯遠早于你自己的代碼。這一切是怎么協調的?

在 C 語言中,這一切都跟宏相關。宏僅存在于頭部,因此會在執行代碼行的時候編譯,盡管原則上這些代碼隸屬于庫,實際上它們直接被當做你自己的代碼。這意味著它們可以檢查是否設置了 DEBUG 宏(或者類似標識),如果未設置就不會生成代碼。例如:

#if DEBUG
#define assert(condition) do { \
        if(!(condition)) { \
            fprintf(stderr, "Assertion failed %s in file %s line %d\n", #condition, __FILE__, __LINE__); \
            abort(); \
        } \
    }
#else
#define assert(condition) (void)0
#endif

又一次,在 Swift 中沒有宏的概念,那是怎么做的呢?

如果你看過這些函數在標準庫中的定義,會發現它們都用 @_transparent 進行了注釋。該特性使得函數有點類似于宏。這些函數的調用都是內聯的,而不是當做獨立函數來調用。當你在 Swift 代碼中寫入 precondition(...) 語句的時候,標準庫中 precondition 的函數體會被直接插入你的代碼中,就好像你自己復制粘貼過去一樣。這意味著這部分代碼的編譯情況跟其余代碼一樣,優化器完全可以看到函數體內的代碼。可以看到,當優化開啟的時候assert 編譯器沒有做任何事,而是被移除掉了。

標準庫是一個獨立的庫,獨立庫中的函數是怎么內聯進你自己的代碼中的呢?對 C 語言來講,庫中包括編譯對象的代碼,這個問題顯得沒有意義。

Swift 標準庫是一個 .swiftmodule 文件,完全不同于 .dylib 或者 .a 文件。一個 .swiftmodule 文件包含模塊中的所有對象的聲明,也可以包括完整的實現。引用 The module format documentation 中的一句話:

The SIL block contains SIL-level implementations that can be imported into a client’s SILModule context.(一個 SIL 塊包括可以被導入到用戶定義的 SILModule 上下文中的 SIL 層實現。)

這意味著這些斷言函數的函數體被以一種中間形式保存到標準庫模塊中。之后調用函數的時候函數體內的代碼便可被內聯。既然可以被內聯,這些代碼也就處于同一編譯環境下,必要時優化器也可以將它們全部移除。

總結

Swift 提供了一系列好用的斷言函數。assert 和 assertionFailure 函數僅在優化未開啟時有效。這對于檢查那些耗性能的條件是很有用的,但通常情況下應盡量避免使用。precondition 和 preconditionFailure 函數在優化開啟時也有效。

這些函數對 condition 和 message 的參數使用了 @autoclosure 修飾,使得函數可以控制參數計算的時機。從而避免了每次斷言檢查都去計算自定義的 message,同時也避免了在優化開啟,斷言函數無效時去檢查 condition

斷言函數是標準庫的一部分,但它們使用了 @_transparent 修飾,使得生成的中間代碼可以導入到模塊中。當函數被調用時,整個函數體會被內聯至調用處,因此優化器可以在需要的時候移除它們。

今天就講到這里!希望這篇文章可以幫助你在自己的代碼中更大膽地使用斷言。斷言是很有用的機制,它可以讓問題一旦發生就及時明顯地顯現出來,而不是發生很久后才顯示出一些“癥狀”。下次會帶來一些更棒的想法。每周周五問答都是基于讀者的一些想法建立的,如果你也有想在這里討論的話題,就快發過來吧

來源:http://swift.gg/2016/05/11/friday-qa-2016-03-04-swift-asserts/

 本文由用戶 JeffersonOv 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!