作者:Ole Begemann,原文鏈接,原文日期:2017-03-08
譯者:Cwift;校對(duì):walkingway;定稿:CMB
假設(shè)你有一個(gè)結(jié)構(gòu)體:
struct Person {
var name: String
}
并且讓其遵守 Equatable:
extension Person: Equatable {
static func ==(lhs: Person, rhs: Person) -> Bool {
return lhs.name == rhs.name
}
}
實(shí)際的效果滿足預(yù)期:
Person(name: "Lisa") == Person(name: "Lisa") // → true
Person(name: "Lisa") == Person(name: "Bart") // → false
Equatable 的一致性是脆弱的
不幸的是,同我在 上一篇文章 中講到的枚舉的例子一樣,這種方式實(shí)現(xiàn)的 Equatable 的一致性是非常脆弱的:每次向結(jié)構(gòu)體中添加屬性時(shí),你都必須記得去更新 == 函數(shù)的實(shí)現(xiàn)。如果忘記的話,Equatable 的一致性就會(huì)被打破,這個(gè) bug 多久會(huì)被發(fā)現(xiàn)取決于測(cè)試的質(zhì)量 —— 這里編譯器無(wú)法提供任何幫助。
例如,向該結(jié)構(gòu)體中增加另一個(gè)字段:
struct Person {
var name: String
var city: String
}
由于 Equatable 的實(shí)現(xiàn)沒(méi)有改變,兩個(gè)名字相同的人就會(huì)被判定為相等 —— 根本沒(méi)有考慮 city 屬性:
let lisaSimpson = Person(name: "Lisa", city: "Springfield")
let lisaStansfield = Person(name: "Lisa", city: "Dublin")
lisaSimpson == lisaStansfield // → true!!!
更糟糕的是,與枚舉的示例不同,沒(méi)有簡(jiǎn)單的方法來(lái)確保 == 函數(shù)避免出現(xiàn)這樣的錯(cuò)誤。除了 switch 語(yǔ)句,編譯器沒(méi)有其他針對(duì)上下文的窮盡性檢查。(假設(shè)對(duì)類型進(jìn)行判等的一般性規(guī)則是檢查類型的所有存儲(chǔ)屬性是否相等,那么可以設(shè)想一下在未來(lái)當(dāng) == 的實(shí)現(xiàn)中沒(méi)用使用對(duì)象的所有存儲(chǔ)屬性時(shí)(如果實(shí)踐證明這確實(shí)是一個(gè)重要的錯(cuò)誤來(lái)源),編譯器應(yīng)該發(fā)出警告。不過(guò)現(xiàn)在還沒(méi)有這種機(jī)制。)
使用 dump 聲明相等性
目前我使用標(biāo)準(zhǔn)庫(kù)中的 dump(轉(zhuǎn)儲(chǔ))函數(shù)實(shí)現(xiàn)保護(hù)。dump 非常有趣,因?yàn)樗褂?Swift 的反射功能,用一個(gè)字符串類型存儲(chǔ)值或者對(duì)象中的所有存儲(chǔ)字段。通常由 dump 展示出的值或者對(duì)象的內(nèi)部情況要比其自身的 description 或者 debugDescription 更詳細(xì)。dump 的輸出如下所示:
dump(lisaSimpson)
// ? Person
// - name: "Lisa"
// - city: "Springfield"
下面的函數(shù)斷言它的兩個(gè)參數(shù)具有相同的 dump 輸出:
/**
斷言兩個(gè)表達(dá)式具有相同的 `dump` 輸出。
- 注意:與標(biāo)準(zhǔn)庫(kù)中的 `assert` 類似,該斷言只在 Playground 文件以及 `-Onone` 模式的 Build 中才有效果。
在開(kāi)啟優(yōu)化后的 Build 中不起作用。
- 可參考:`dump(_:to:name:indent:maxDepth:maxItems)`
*/
func assertDumpsEqual<T>(_ lhs: @autoclosure () -> T,
_ rhs: @autoclosure () -> T,
file: StaticString = #file, line: UInt = #line) {
assert(String(dumping: lhs()) == String(dumping: rhs()),
"Expected dumps to be equal.",
file: file, line: line)
}
extension String {
/**
使用給定值的 `dump` 輸出創(chuàng)建一個(gè)字符串
*/
init<T>(dumping x: T) {
self.init()
dump(x, to: &self)
}
}
更新于 2017 年 3 月 9 日: 非常感謝 Tim Vermeulen 提供的這個(gè)函數(shù)版本。它比我的原始版本簡(jiǎn)單得多,舊版本我保存在了文章末尾的附錄中。
保護(hù) == 函數(shù)
現(xiàn)在你必須在 == 函數(shù)中調(diào)用 assertDumpsEqual:
extension Person: Equatable {
static func ==(lhs: Person, rhs: Person) -> Bool {
// 錯(cuò)誤!沒(méi)有包含 city 屬性。
let areEqual = lhs.name == rhs.name
//保護(hù):相等的值必須有相同的 dump
if areEqual {
assertDumpsEqual(lhs, rhs)
}
return areEqual
}
}
從現(xiàn)在開(kāi)始,如果你判斷相等的兩個(gè)值具有不同的 dump 輸出,程序會(huì)陷入運(yùn)行時(shí)陷阱:
lisaSimpson == lisaStansfield
// Crash: assertion failed: Expected dumps to be equal.
當(dāng)你忘記在 == 函數(shù)中包含 city 屬性時(shí),這個(gè)方案可以讓你立即注意到這個(gè)問(wèn)題。當(dāng)然它不是 100% 安全的:編譯期檢查明顯是更優(yōu)秀的方案,而且你依舊必須要記得在 == 函數(shù)中調(diào)用 assertDumpsEqual 函數(shù) —— 不過(guò)在每個(gè)類型中你只需調(diào)用一次,而不用為每個(gè)屬性都添加一次方法調(diào)用。
2017年3月9日更新:使用該模式的話,== 函數(shù)的樣式始終相同:測(cè)試值是否相等,如果為 true 則執(zhí)行基于 dump 的斷言,最后返回測(cè)試的結(jié)果。Tim Vermeulen 建議創(chuàng)建一個(gè)實(shí)現(xiàn)了該模式的協(xié)議,并將實(shí)際的判等測(cè)試作為自定義的參數(shù)。這是一種有趣的替換,為你節(jié)省了一些樣板代碼,代價(jià)是隱藏了具體的實(shí)現(xiàn)。
缺點(diǎn)
這個(gè)方案的最大缺點(diǎn)可能是 dump 在判等時(shí)不是一個(gè)完全可靠的方案。它應(yīng)該可以很好地避免漏報(bào),但有時(shí)你可能會(huì)遇到一些誤報(bào),即實(shí)際上相等但是 dump 的輸出不同的值。誤報(bào)的主要對(duì)象是 NSObject 的子類,這類對(duì)象是否相等不基于對(duì)象的標(biāo)識(shí),而是基于包含內(nèi)存地址的 description(這是默認(rèn)設(shè)置)。
我查看了一些標(biāo)準(zhǔn)庫(kù)中的 Swift 類型以及 Apple 原生框架中的類,這些類型都遵守了 Equatable 協(xié)議,它們與 dump 的用法配合的很好。但是你必須注意在使用自定義的 NSObject 子類時(shí)需要重寫 description。
結(jié)論
或許你可以使用 linter、靜態(tài)分析工具、像 Sourcery 這樣的代碼生成工具或者其他的什么方法來(lái)保護(hù) Equatable 的實(shí)現(xiàn),避免回顧代碼。不過(guò),我不認(rèn)為目前有任何代碼分析工具能深入到本文所討論的問(wèn)題。我提出的這個(gè)并不完美的方案可能會(huì)幫你捕獲一些 bug,直到你遇到更好用的工具。
附錄
典型的 Swift 和 Objective-C 類型的 dump 輸出示例
dump([1,2,3])
// ? 3 elements
// - 1
// - 2
// - 3
dump(1..<10)
// ? CountableRange(1..<10)
// - lowerBound: 1
// - upperBound: 10
dump(["key": "value"])
// ? 1 key/value pair
// ? (2 elements)
// - .0: "key"
// - .1: "value"
dump("Lisa" as String?)
// ? Optional("Lisa")
// - some: "Lisa"
dump(Date())
// ? 2017-03-08 14:08:27 +0000
// - timeIntervalSinceReferenceDate: 510674907.82620001
dump([1,2,3] as NSArray)
// ? 3 elements #0
// - 1 #1
// - super: NSNumber
// - super: NSValue
// - super: NSObject
// - 2 #2
// - super: NSNumber
// - super: NSValue
// - super: NSObject
// - 3 #3
// - super: NSNumber
// - super: NSValue
// - super: NSObject
dump("Hello" as NSString)
// - Hello #0
// - super: NSMutableString
// - super: NSString
// - super: NSObject
dump(UIColor.red)
// - UIExtendedSRGBColorSpace 1 0 0 1 #0
// - super: UIDeviceRGBColor
// - super: UIColor
// - super: NSObject
// UIFont 對(duì)象的 dump 輸出包含了內(nèi)存地址,
// 但是 UIFont 在內(nèi)部共享這些對(duì)象,所以
// 不會(huì)出問(wèn)題。
let f1 = UIFont(name: "Helvetica", size: 12)!
let f2 = UIFont(name: "Helvetica", size: 12)!
f1 == f2 // → true
dump(f1)
// - <UICTFont: 0x7ff5e6102e60> font-family: "Helvetica"; font-weight: normal; font-style: normal; font-size: 12.00pt #0
// - super: UIFont
// - super: NSObject
dump(f2)
// - <UICTFont: 0x7ff5e6102e60> font-family: "Helvetica"; font-weight: normal; font-style: normal; font-size: 12.00pt #0
// - super: UIFont
// - super: NSObject
// Swift 中的類的 dump 輸出不會(huì)包含內(nèi)存地址:
class A {
let value: Int
init(value: Int) { self.value = value }
}
dump(A(value: 42))
// ? A #0
// - value: 42
// NSObject 的子類會(huì)包含內(nèi)存地址
//因此會(huì)出問(wèn)題:
class B: NSObject {
let value: Int
init(value: Int) {
self.value = value
super.init()
}
static func ==(lhs: B, rhs: B) -> Bool {
return lhs.value == rhs.value
}
}
dump(B(value: 42))
// ? <__lldb_expr_26.B: 0x101012160> #0
// - super: NSObject
// - value: 42
// 修正: 重寫 `description`:
extension B {
override open var description: String {
return "B: \(value)"
}
}
dump(B(value: 42))
// ? B: 42 #0
// - super: NSObject
// - value: 42
發(fā)布版本中的零開(kāi)銷斷言
在 Swift 中,assert 只應(yīng)該用在 Debug 版本中(在 Release 版本中使用 precondition 一類的語(yǔ)句會(huì)造成 trap)。assertDumpsEqual 的功能實(shí)現(xiàn)依托于標(biāo)準(zhǔn)庫(kù)中的 assert 函數(shù)。為了能正常使用該函數(shù),在調(diào)用 assert 的前后都不應(yīng)執(zhí)行任何工作。assert 可以接受一個(gè)大開(kāi)銷的表達(dá)式:assert 使用了一個(gè)標(biāo)記為 @autoclosure 的屬性作為參數(shù),以確保在調(diào)用時(shí)不會(huì)立即執(zhí)行大開(kāi)銷的表達(dá)式。
正文所示的 assertDumpsEqual 的版本(由 Tim Vermeulen 編寫)的優(yōu)勢(shì)是在自定義的 String 構(gòu)造器中創(chuàng)建 dump (大開(kāi)銷的操作)。這是我的原始版本,在函數(shù)內(nèi)創(chuàng)建 dump:
/**
斷言兩個(gè)表達(dá)式具有相同的 `dump` 輸出。
- 注意: 該斷言只在定義了 `DEBUG`
條件編譯符時(shí)才有效。否則該函數(shù)不執(zhí)行任何操作。注意 在 Playground 中和 -Onone
模式下的 Build 不會(huì)自動(dòng)設(shè)置 `DEBUG` 標(biāo)志位。
*/
func assertDumpsEqual<T>(_ lhs: @autoclosure () -> T,
_ rhs: @autoclosure () -> T,
file: StaticString = #file, line: UInt = #line) {
#if DEBUG
var left = "", right = ""
dump(lhs(), to: &left)
dump(rhs(), to: &right)
assert(left == right,
"Expected dumps to be equal.\nlhs: \(left)\nrhs:\(right)",
file: file, line: line)
#endif
}
除非設(shè)置了 DEBUG 條件編譯符,否則不能編譯 #if DEBUG 塊中的整個(gè)函數(shù)體。DEBUG 標(biāo)志位在進(jìn)行未優(yōu)化的 Build 時(shí)總是默認(rèn)設(shè)置的,依靠它就可以滿足上述目的了。不幸的是,Xcode 不會(huì)為 Playground 自動(dòng)設(shè)置標(biāo)志位,使用 Swift Package Manager 的 Debug Build 默認(rèn)情況下也不會(huì)設(shè)置該標(biāo)志位(在 SwiftPM 中你可以使用 swift build -Xswiftc "-D" -Xswiftc "DEBUG" 命令手動(dòng)設(shè)置該標(biāo)志位)。標(biāo)準(zhǔn)庫(kù)中的 assert 更加聰明。它將所有未優(yōu)化的 Build 過(guò)程(包括 Playground)視作有價(jià)值的。不過(guò),assert 識(shí)別未優(yōu)化的 Build 的功能在 stdlib 之外是不可用的,這就解釋了為什么應(yīng)該將整個(gè)大開(kāi)銷的計(jì)算過(guò)程都放在 assert 中執(zhí)行。
在我看到 Tim 提出的簡(jiǎn)化方案之前,我自己的實(shí)現(xiàn)方法是將生成和比較 dump 信息的代碼放到一個(gè)局部的閉包之中,之后在傳遞給 assert 的時(shí)候再“調(diào)用”該閉包。因?yàn)?assert 的參數(shù)也是一個(gè) @autoclosure 類型的,所以閉包中的代碼實(shí)際只在 assert 內(nèi)部執(zhí)行(也就意味著只會(huì)在未優(yōu)化的 Build 中執(zhí)行)。我的方案看起來(lái)像這樣:
func assertDumpsEqual<T>(_ lhs: @autoclosure () -> T,
_ rhs: @autoclosure () -> T,
file: StaticString = #file, line: UInt = #line) {
func areDumpsEqual() -> Bool {
var left = "", right = ""
// Error: Declaration closing over non-escaping
// parameter may allow it to escape
dump(lhs(), to: &left)
// Error: Declaration closing over non-escaping
// parameter may allow it to escape
dump(rhs(), to: &right)
return left == right
}
assert(areDumpsEqual(), "Expected dumps to be equal.",
file: file, line: line)
}
然而,上面的代碼會(huì)發(fā)生編譯錯(cuò)誤。編譯器不允許我們捕獲閉包中的 lhs 和 rhs 參數(shù),因?yàn)樗鼈兪?non-escaping(非逃逸)的。在我們的例子中,閉包實(shí)際上并不會(huì)從作用域中逃逸,但是編譯器無(wú)法驗(yàn)證這一點(diǎn)。為了解決該問(wèn)題,可以(1)使用 @escaping 標(biāo)注 assertDumpsEqual 的參數(shù),或者(2)使用 withoutActuallyEscaping 函數(shù)(Swift 3.1 的新特性)來(lái)修改編譯器的規(guī)則?,F(xiàn)在該函數(shù)看起來(lái)像下面這樣:
/// - 注意: 使用 `withoutActuallyEscaping` 要求 Swift 3.1。
func assertDumpsEqual<T>(_ lhs: @autoclosure () -> T,
_ rhs: @autoclosure () -> T,
file: StaticString = #file, line: UInt = #line) {
// 嵌套函數(shù)是為了解決 Bug SR-4188: `withoutActuallyEscaping`
// 不能接受 `@autoclosure` 參數(shù)。 https://bugs.swift.org/browse/SR-4188
func assertDumpsEqualImpl(lhs: () -> T, rhs: () -> T) {
withoutActuallyEscaping(lhs) { escapableL in
withoutActuallyEscaping(rhs) { escapableR in
func areDumpsEqual() -> Bool {
var left = "", right = ""
dump(escapableL(), to: &left)
dump(escapableR(), to: &right)
return left == right
}
assert(areDumpsEqual(), "Expected dumps to be equal.",
file: file, line: line)
}
}
}
assertDumpsEqualImpl(lhs: lhs, rhs: rhs)
}
(嵌套函數(shù)是解決以下 Bug 的一種方案:withoutActuallyEscaping 目前不支持 autoclosure 形式的參數(shù)。)
本文由 SwiftGG 翻譯組翻譯,已經(jīng)獲得作者翻譯授權(quán),最新文章請(qǐng)?jiān)L問(wèn) http://swift.gg。