作者:Mike Ash,原文鏈接,原文日期:2016-03-04
譯者:zltunes;校對:Cee;定稿:shanks
斷言是一種非常有用的機制,它可以檢查代碼中的假設(shè)部分,確保錯誤能夠被及時發(fā)現(xiàn)。今天我將探討 Swift 中提供的斷言調(diào)用以及它們的實現(xiàn),這個話題是由讀者 Matthew Young 提出的。
我不會花太多時間討論一般意義上的斷言是什么或者在哪里使用它們。本文將著眼于 Swift 中提供的斷言機制以及一些實現(xiàn)的細節(jié)。如果你想要了解如何在代碼中充分利用斷言,可以閱讀我以前的文章 Proper Use of Asserts(斷言的正確使用)。
API
在 Swift 標準庫中有兩個主要的斷言函數(shù)。
第一個函數(shù)被創(chuàng)造性地命名為 assert
。調(diào)用時需要一個真命題:
assert(x >= 0) // x 不能為負
該函數(shù)提供一個可選參數(shù),用于命題為假時打印錯誤信息:
assert(x >= 0, "x can't be negative here")
assert
只有在非優(yōu)化構(gòu)建時有效。在開啟優(yōu)化的情況下這行代碼不會被編譯。當存在某些條件計算耗性能,從而拖慢構(gòu)建速度,但這些條件又是有用的,調(diào)試時必須進行檢查,那么斷言的這一特性就顯得很有用了。
有些人傾向僅在調(diào)試版本中使用斷言,理論上調(diào)試的時候去做一些檢查是個好習慣,但最好保證 app 不會在實際使用時崩潰。不管在斷言檢查中有沒有出現(xiàn)過,一旦(在實際使用中)出現(xiàn)錯誤,都會導致非常嚴重的后果。更好的做法是,如果在實際使用時出現(xiàn)錯誤,應用能迅速退出。我們來看一下如何實現(xiàn)。
函數(shù) precondition
與 assert
非常像,調(diào)用時二者看起來一樣:
precondition(x >= 0) // x 不能為負
precondition(x >= 0, "x can't be negative here")
不同之處在于該函數(shù)在優(yōu)化構(gòu)建條件下也會執(zhí)行檢查。這使得它成為斷言檢查的一個更好的選擇,并且檢查速度足夠快。
盡管 precondition
在優(yōu)化構(gòu)建中有效,在「非檢查(unchecked)」的優(yōu)化構(gòu)建中仍是無效的。「非檢查」的構(gòu)建是通過在命令行指定 -Ounchecked
來實現(xiàn)的。該指令的執(zhí)行不僅會移除 precondition
調(diào)用,還會進行數(shù)組邊界檢查。這是很危險的,除非你別無選擇,不得不執(zhí)行該命令外盡量不要用。
關(guān)于非檢查構(gòu)建有趣的一點是,盡管 precondition
檢查被移除了,優(yōu)化器仍會假設(shè)命題為真,并在此基礎(chǔ)上優(yōu)化下面的代碼。在上述例子中,生成代碼不會再檢查 x
是否為負,但在接下來的編譯中會默認 x >= 0
。這一點對于 assert
也是成立的。
這些函數(shù)各自有一個不帶條件的變體,用來標志失敗的情況。上述兩個函數(shù)的變體分別是 assertionFailure
和 preconditionFailure
。當你要進行斷言檢查的條件與該函數(shù)的調(diào)用不太相符時,變體就顯得很有用了。例如:
guard case .Thingy(let value) = someEnum else {
preconditionFailure("This code should only be called with a Thingy.")
}
優(yōu)化下的行為和帶條件時類似,開啟優(yōu)化時 assertionFailure
不會被編譯,preconditionFailure
則保留,但在「非檢查」優(yōu)化構(gòu)建時仍會被移除。「非檢查」構(gòu)建時,優(yōu)化器假設(shè)這些函數(shù)永遠不會執(zhí)行,并基于該假設(shè)生成代碼。
最后還有個函數(shù) fatalError
。該函數(shù)表示出現(xiàn)異常并終止程序,而不管構(gòu)建是否開啟優(yōu)化或檢查。
記錄調(diào)用者信息
當斷言檢查未通過,會得到這樣一條信息:
precondition failed: x must be greater than zero: file test.swift, line 6
程序是如何獲知文件和代碼行的信息的呢?
在 C 語言中,我們將 assert
當做宏指令來用,同時使用 __FILE__
和 __LINE__
這兩個神奇的標識符來獲取信息:
c
#define assert(condition) do { \
if(!(condition)) { \
fprintf(stderr, "Assertion failed %s in file %s line %d\n", #condition, __FILE__, __LINE__); \
abort(); \
} \
}
這些函數(shù)最終以調(diào)用者的文件和代碼行信息結(jié)尾,就是因為此處的宏定義。Swift 中沒有宏的概念,那該怎么辦?
Swift 中可以使用默認參數(shù)值達到同樣效果。上述神奇的標識符可被當做參數(shù)的默認值使用。如果調(diào)用者沒有提供一個確切的值,便可將調(diào)用者所處的文件及代碼行作為默認值。目前,這兩個神奇的標識符分別是 __FILE__
和 __LINE__
,但在 Swift 下一版本中會變成 #file
和 #line
,更加符合 Swift 風格。
探討實際中的使用前,我們先看看 assert
的定義:
public func assert(
@autoclosure condition: () -> Bool,
@autoclosure _ message: () -> String = String(),
file: StaticString = #file, line: UInt = #line
)
通常情況下,調(diào)用 assert
僅傳遞一個或兩個參數(shù)。file
和 line
參數(shù)則作為默認值,用來傳遞調(diào)用者的相關(guān)信息。
沒有強制要求必須使用默認值,如果需要的話你可以傳入其他的值。比如:
assert(false, "Guess where!", file: "not here", line: 42)
最終輸出:
assertion failed: Guess where!: file not here, line 42
有種更加實用的用法,你可以寫一個包裝器來保留原始調(diào)用者的信息,例如:
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
關(guān)鍵字,斷言檢查未通過時可以輸出表達式。而在 Swift 中不可以。因此,盡管 Swift 可以打印斷言失敗時的文件和代碼行信息,但用來檢查的表達式是無從獲知的。
自動閉包
上述函數(shù)都使用 @autoclosure
來修飾 condition
和 message
參數(shù),為什么?
先快速回顧一下 @autoclosure
。@autoclosure
修飾的無參閉包可作為某個函數(shù)的形參,調(diào)用該函數(shù)時,調(diào)用者提供一個表達式作為實參。這個表達式會被包裝成閉包并傳遞給函數(shù),例如:
func f(@autoclosure value: () -> Int) {
print(value())
}
f(42)
等價于:
func f(value: () -> Int) {
print(value())
}
f({ 42 })
為什么要把表達式包裝成閉包傳遞?因為這樣可以讓調(diào)用的函數(shù)來決定表達式具體執(zhí)行的時間。例如,對于實現(xiàn)兩個布爾類型的 && 運算符時,我們可以通過傳入兩個 Bool
參數(shù)實現(xiàn):
func &&(a: Bool, b: Bool) -> Bool {
if a {
if b {
return true
}
}
return false
}
有些情況下我們直接調(diào)用就可以:
x > 3 && x < 10
但如果右操作數(shù)計算復雜的話是很耗時的:
x > 3 && expensiveFunction(x) < 10
假定左操作數(shù)為 false
時,右操作數(shù)不會被執(zhí)行的話,還有可能直接崩潰掉:
optional != nil && optional!.value > 3
跟 C 語言一樣,Swift 中的 &&
也是短路操作符。左操作數(shù)為 false
時就不再計算右操作數(shù)了。因此該表達式在 Swift 中是安全的,但對我們的函數(shù)則不行。@autoclosure
使得函數(shù)可以控制表達式執(zhí)行的時間,保證只有左操作數(shù)為 true
的前提下才去執(zhí)行該表達式:
func &&(a: Bool, @autoclosure b: () -> Bool) -> Bool {
if a {
if b() {
return true
}
}
return false
}
現(xiàn)在就符合 Swift 的語義了,當 a 為 false 時 b 永遠不會執(zhí)行。
對斷言而言,則完全是考慮性能問題。因為斷言消息有可能是很耗時的操作。例如:
assert(widget.valid, "Widget wasn't valid: \(widget.dump())")
你肯定不想每次都去計算一長串字符串,即便 widget
是合法、什么都不必輸出的時候。對消息參數(shù)使用 @autoclosure
修飾,assert
便可避免計算 message
表達式,除非當斷言檢查不通過的時候。
條件本身也是 @autoclosure
,因為優(yōu)化構(gòu)建下 assert
不會去檢查條件。既然不去檢查,也就不涉及計算了。使用 @autoclosure
意味著不會拖慢優(yōu)化構(gòu)建的速度:
assert(superExpensiveFunction())
本文提到的 API 中的函數(shù)都使用了 @autoclosure
來保證除非不得已情況下,盡量避免參數(shù)的計算。出于某種原因,連 fatalError
都使用了 @autoclosure
修飾,盡管它是無條件執(zhí)行的。
代碼移除
基于代碼的編譯情況,這些函數(shù)會在代碼生成時被移除。它們位于 Swift 標準庫,而不是你自己寫的代碼中,而 Swift 標準庫的編譯遠早于你自己的代碼。這一切是怎么協(xié)調(diào)的?
在 C 語言中,這一切都跟宏相關(guān)。宏僅存在于頭部,因此會在執(zhí)行代碼行的時候編譯,盡管原則上這些代碼隸屬于庫,實際上它們直接被當做你自己的代碼。這意味著它們可以檢查是否設(shè)置了 DEBUG
宏(或者類似標識),如果未設(shè)置就不會生成代碼。例如:
c
#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 中沒有宏的概念,那是怎么做的呢?
如果你看過這些函數(shù)在標準庫中的定義,會發(fā)現(xiàn)它們都用 @_transparent
進行了注釋。該特性使得函數(shù)有點類似于宏。這些函數(shù)的調(diào)用都是內(nèi)聯(lián)的,而不是當做獨立函數(shù)來調(diào)用。當你在 Swift 代碼中寫入 precondition(...)
語句的時候,標準庫中 precondition
的函數(shù)體會被直接插入你的代碼中,就好像你自己復制粘貼過去一樣。這意味著這部分代碼的編譯情況跟其余代碼一樣,優(yōu)化器完全可以看到函數(shù)體內(nèi)的代碼??梢钥吹剑攦?yōu)化開啟的時候 assert
編譯器沒有做任何事,而是被移除掉了。
標準庫是一個獨立的庫,獨立庫中的函數(shù)是怎么內(nèi)聯(lián)進你自己的代碼中的呢?對 C 語言來講,庫中包括編譯對象的代碼,這個問題顯得沒有意義。
Swift 標準庫是一個 .swiftmodule
文件,完全不同于 .dylib
或者 .a
文件。一個 .swiftmodule
文件包含模塊中的所有對象的聲明,也可以包括完整的實現(xiàn)。引用 The module format documentation 中的一句話:
The SIL block contains SIL-level implementations that can be imported into a client's SILModule context.(一個 SIL 塊包括可以被導入到用戶定義的 SILModule 上下文中的 SIL 層實現(xiàn)。)
這意味著這些斷言函數(shù)的函數(shù)體被以一種中間形式保存到標準庫模塊中。之后調(diào)用函數(shù)的時候函數(shù)體內(nèi)的代碼便可被內(nèi)聯(lián)。既然可以被內(nèi)聯(lián),這些代碼也就處于同一編譯環(huán)境下,必要時優(yōu)化器也可以將它們?nèi)恳瞥?/p>
總結(jié)
Swift 提供了一系列好用的斷言函數(shù)。assert
和 assertionFailure
函數(shù)僅在優(yōu)化未開啟時有效。這對于檢查那些耗性能的條件是很有用的,但通常情況下應盡量避免使用。precondition
和 preconditionFailure
函數(shù)在優(yōu)化開啟時也有效。
這些函數(shù)對 condition
和 message
的參數(shù)使用了 @autoclosure
修飾,使得函數(shù)可以控制參數(shù)計算的時機。從而避免了每次斷言檢查都去計算自定義的 message
,同時也避免了在優(yōu)化開啟,斷言函數(shù)無效時去檢查 condition
。
斷言函數(shù)是標準庫的一部分,但它們使用了 @_transparent
修飾,使得生成的中間代碼可以導入到模塊中。當函數(shù)被調(diào)用時,整個函數(shù)體會被內(nèi)聯(lián)至調(diào)用處,因此優(yōu)化器可以在需要的時候移除它們。
今天就講到這里!希望這篇文章可以幫助你在自己的代碼中更大膽地使用斷言。斷言是很有用的機制,它可以讓問題一旦發(fā)生就及時明顯地顯現(xiàn)出來,而不是發(fā)生很久后才顯示出一些“癥狀”。下次會帶來一些更棒的想法。每周周五問答都是基于讀者的一些想法建立的,如果你也有想在這里討論的話題,就快發(fā)過來吧!
本文由 SwiftGG 翻譯組翻譯,已經(jīng)獲得作者翻譯授權(quán),最新文章請訪問 http://swift.gg。