結(jié)構(gòu)體中的 Lazy 屬性探究

作者:Ole Begemann,原文鏈接,原文日期:2015-12-17
譯者:pmst;校對:Cee;定稿:小鍋

定稿注:原文沒有提供源碼,作為一個(gè)走心的翻譯組,我們已經(jīng)將本篇文章的最終版源碼作成 Playground,可以到這個(gè)地址進(jìn)行下載。

更新:

2015-12-17 提到 Swift evolution 郵件列表中一個(gè)關(guān)于行為屬性的新提案,如果這個(gè)提案被采取,則本篇文章中的大部分將成為過時(shí)的內(nèi)容

Swift 中的 lazy 關(guān)鍵字允許你定義這么一個(gè)屬性:它的初始值在當(dāng)它被首次訪問的時(shí)候才計(jì)算。舉個(gè)例子,試想通過一個(gè)結(jié)構(gòu)體來描述一副圖像。該圖像的元數(shù)據(jù)(metadata)字典創(chuàng)建操作所需要的開銷代價(jià)也許很大,因此我們更傾向于推遲這個(gè)行為直至需要該數(shù)據(jù)的時(shí)候。我們可以像這樣聲明一個(gè) lazy var 屬性:

struct Image {
    lazy var metadata: [String:AnyObject] = {
        // 加載圖片和解析 metadata,相當(dāng)占內(nèi)存
        // ...
        return ...
    }()
}

注意我們必須使用 var 關(guān)鍵字聲明屬性。let 關(guān)鍵字聲明的常量必須在實(shí)例初始化完成前擁有一個(gè)值,顯然對于 lazy 變量無法保證初始化前有值。(譯者注:實(shí)例指結(jié)構(gòu)體實(shí)例)

訪問 lazy 屬性是一個(gè) mutating 操作,因?yàn)閷傩缘某跏贾抵辉诘谝淮卧L問時(shí)才計(jì)算確定。當(dāng)結(jié)構(gòu)體(屬于值類型)包含一個(gè) lazy 屬性時(shí),任何該結(jié)構(gòu)體的擁有者同樣必須聲明為一個(gè)變量(var 關(guān)鍵字聲明),因?yàn)樵L問該屬性意味著可能改變結(jié)構(gòu)體內(nèi)容。所以以下行為是不被允許的:

let image = Image()
print(image.metadata)
// error: Cannot use mutating getter on immutable value: 'image' is a 'let' constant.

你可以強(qiáng)制要求用戶使用 var 關(guān)鍵字來聲明 Image 類型,但是這可能有些不合適(比如,函數(shù)中作為變量的情況)或容易混淆(因?yàn)?getter 方法通常并不是可變的)。

使用Box包裝類型

另外一種選擇是將 lazy 值封裝到一個(gè)類中,有點(diǎn)類似經(jīng)常使用的 Box type。由于類是引用類型,結(jié)構(gòu)體可以包含一個(gè) let 常量指向一個(gè)類實(shí)例,如此結(jié)構(gòu)體依舊是不可變的,但它指向的引用對象本身是可以改變的。

首先我們定義一個(gè)枚舉類型,命名為 LazyValue,用于表示值類型 T 能夠被懶加載(有時(shí)又稱延遲加載)。它具有兩個(gè)可能狀態(tài):要么還未執(zhí)行計(jì)算操作,要么已經(jīng)執(zhí)行計(jì)算并得到結(jié)果值。在前一種情況中,它存儲(chǔ)用于執(zhí)行計(jì)算的函數(shù)(閉包)。后一種情況中,它存儲(chǔ)計(jì)算值。

private enum LazyValue<T> {
    case NotYetComputed(() -> T)
    case Computed(T)
}

現(xiàn)在我們將封裝該枚舉到一個(gè)類中,命名為 LazyBox,這樣我們就能夠獨(dú)立地在它內(nèi)部做一些改變。而 LazyBox實(shí)例的擁有者依舊能夠保持不可變狀態(tài)。實(shí)現(xiàn)如下:

final class LazyBox<T> {
    init(computation: () -> T) {
        _value = .NotYetComputed(computation)
    }

    private var _value: LazyValue<T>

    var value: T {
        switch self._value {
        case .NotYetComputed(let computation):
            let result = computation()
            self._value = .Computed(result)
            return result
        case .Computed(let result):
            return result
        }
    }
}

LazyBox 類構(gòu)造方法接收一個(gè)閉包作為變量,用于計(jì)算值。我們將該函數(shù)存儲(chǔ)到一個(gè)私有的 LazyValue 屬性中,等待需要時(shí)進(jìn)行訪問。該類的公有接口是一個(gè)只讀屬性 value。在 valuegetter 方法中我們檢查是否已經(jīng)擁有一個(gè)計(jì)算屬性,如果是則返回該值。否則我們執(zhí)行一次計(jì)算函數(shù)并緩存結(jié)果值到 _value 等待之后的讀取操作。

我們可以像這樣使用 LazyBox,并且驗(yàn)證計(jì)算函數(shù)確實(shí)僅被執(zhí)行了一次:

var counter = 0
let box = LazyBox<Int> {
    counter += 1;
    return counter * 10
}
assert(box.value == 10)
assert(box.value == 10)
assert(counter == 1)// 倘若你把 1 改成 2,就會(huì)報(bào)錯(cuò)

上面這種方式的優(yōu)勢在于它能夠應(yīng)用到常量結(jié)構(gòu)體中。而在其他方面,它相比較 lazy 變量使用略遜一籌,因?yàn)橛脩粜枰ㄟ^ value 屬性才能訪問結(jié)果值。如果感覺這樣不直觀的話,我們可以隱藏具體的實(shí)現(xiàn),而向用戶提供另外一個(gè)計(jì)算屬性返回 LazyBox.value,同時(shí)我們將 LazyBox 屬性設(shè)置為私有。

struct Image {
    // 延遲存儲(chǔ)
    private let _metadata = LazyBox<[String:AnyObject]> {
        // 加載圖片和解析 metadata,相當(dāng)占內(nèi)存
        // ...
        return ...
    }

    var metadata: [String:AnyObject] {
        return _metadata.value
    }
}

let image = Image()
print(image.metadata) // 不報(bào)錯(cuò)

這樣該結(jié)構(gòu)體依舊保留了它的值語義。值類型內(nèi)部使用引用類型是慣用伎倆,以這種方式實(shí)現(xiàn)能夠保證值語義。標(biāo)準(zhǔn)庫中許多集合類型都是以這種方式實(shí)現(xiàn)的。

并發(fā)性

這種實(shí)現(xiàn)方式還存在最后一個(gè)潛在問題,也就是未考慮并發(fā)性。倘若 LazyBox.value 還未完成值計(jì)算操作,同時(shí)被多個(gè)線程訪問,那么計(jì)算函數(shù)就將被執(zhí)行多次。這些都是你需要避免的情況,萬一計(jì)算函數(shù)會(huì)產(chǎn)生副作用或相當(dāng)耗內(nèi)存呢,對吧?

我們可以將內(nèi)部 _value 屬性的所有讀寫操作都安排到一個(gè)私有串行隊(duì)列中,這樣就能夠保證函數(shù)僅被執(zhí)行一次。下面是新的實(shí)現(xiàn)方式:

final class LazyBox<T> {
    init(computation: () -> T) {
        _value = .NotYetComputed(computation)
    }

    private var _value: LazyValue<T>

    /// 所有對于 `_value` 的讀寫都在這個(gè)線程隊(duì)列中。
    private let queue = dispatch_queue_create(
        "LazyBox._value", DISPATCH_QUEUE_SERIAL)

    var value: T {
        var returnValue: T? = nil
        dispatch_sync(queue) {
            switch self._value {
            case .NotYetComputed(let computation):
                let result = computation()
                self._value = .Computed(result)
                returnValue = result
            case .Computed(let result):
                returnValue = result
            }
        }
        assert(returnValue != nil)
        return returnValue!
    }
}

使用這種方式的缺點(diǎn)是每次訪問 value 時(shí)都會(huì)造成一個(gè)小的性能影響,倘若多個(gè)線程同時(shí)讀取該值時(shí)可能引起爭用,畢竟它們都要經(jīng)過同一個(gè)串行隊(duì)列。鑒于每次緩存值被計(jì)算后隊(duì)列中要執(zhí)行的工作量相當(dāng)少,在大多數(shù)情況中都可以忽略不計(jì)。

值得注意的是 Swift 并沒有為 lazy 關(guān)鍵字提供上述保證。官方文檔里是這么說的:

注意:倘若一個(gè)標(biāo)記為 lazy 的屬性同時(shí)被多個(gè)線程修改時(shí),且該屬性還未進(jìn)行初始化操作,那么將無法保證該屬性僅被初始化一次。

最新更新:就在我發(fā)布該文章之后的一個(gè)小時(shí),Joe Groff 在 Swift evolution 中為屬性行為發(fā)布了一個(gè)影響深遠(yuǎn)的提議,郵件內(nèi)容請點(diǎn)擊這里,一旦被采納,將實(shí)現(xiàn)我在本文中討論的內(nèi)容(且遠(yuǎn)遠(yuǎn)不止),并以一個(gè)更自然的方式來實(shí)現(xiàn)。我強(qiáng)烈建議你看一下。

本文由 SwiftGG 翻譯組翻譯,已經(jīng)獲得作者翻譯授權(quán),最新文章請?jiān)L問 http://swift.gg

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容