保護(hù) Equatable 的實(shí)現(xiàn)

作者: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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,996評(píng)論 25 709
  • 問(wèn)題 1)柯里化,通過(guò)柯里化,改造target-action,因?yàn)閟elector只能使用字符串,在編譯時(shí)無(wú)法發(fā)現(xiàn)...
    lanjing閱讀 3,743評(píng)論 3 19
  • 這么說(shuō)路陽(yáng)留在甘孜并沒(méi)有回北京啊,我吃驚地問(wèn)。是的,路陽(yáng)他人沒(méi)回來(lái),我捧著的骨灰盒其實(shí)也是空的,這件事我是后來(lái)才知...
    小花fayer閱讀 200評(píng)論 1 2

友情鏈接更多精彩內(nèi)容