讓我們構建一個Swift.Array

SwiftGG翻譯組發表於2019-03-02

作者:Mike Ash,原文連結,原文日期:2015-04-17
譯者:灰s;校對:numbbbbbForelax;定稿:Forelax

Swift 1.2 現已經作為 Xcode 6.3 的一部分而釋出,在新的 API 中有一個允許我們使用值型別建立高效的資料結構,比如 Swift 標準庫中的 Array 型別。今天,我們將重新現實 Array 的核心功能。

值型別和引用型別

在我們開始之前,快速的複習一下值型別和引用型別。在 objc 以及大部分其他物件導向的語言中,我們所使用物件的指標或者引用都屬於 引用型別。你可以把一個物件的引用賦值給一個變數:

    MyClass *a = ...;
複製程式碼

現在你可以將這個變數的值複製給另一個變數:

    MyClass *b = a;
複製程式碼

現在 a 和 b 都指向同一個物件。如果這個物件是可變的,那麼對其中一個變數的改變同樣會發生在另一個變數上。

值型別 就是類似 Objective-C 中 int 這樣的存在。使用值型別,變數包含的是真實的值,並不是值的引用。當你用 = 給另一個變數賦值,是將值的拷貝副本賦值給了另一個變數。比如:

    int a = 42;
    int b = a;
    b++;
複製程式碼

現在,b 的值是 43,但是 a 依然是 42。

在 Swift 中,class 屬於引用型別,struct 屬於值型別。
如果你使用 = 將一個 class 例項的引用賦值給另一個變數,你將得到這個例項的一個新引用。對這個例項的修改對每一個引用可見。
如果你使用 = 將一個 struct 例項的引用賦值給另一個變數,你將得到這個例項的一個副本,與原始資料相互獨立。

與大多數語言不同,Swift 標準庫中的陣列和字典都是值型別。比如:

    var a = [1, 2, 3]
    var b = a
    b.append(4)
複製程式碼

在大部分語言中,這段程式碼(或者等效的程式碼)執行以後, a 和 b 將都是一個指向陣列 [1, 2, 3, 4] 的引用。但是在 Swift 中,a 是指向陣列 [1, 2, 3] 的引用,b 是指向陣列 [1, 2, 3, 4] 的引用。

值型別的實現

如果你的物件擁有的屬性是固定的,在 Swift 中把它申明成值型別是很簡單的:只需要把所有的屬性放入一個 struct 中即可。比如,如果你需要一個 2D 的 Point 值型別,你可以簡單的申明一個 struct 包含 x 和 y :

    struct Point {
        var x: Int
        var y: Int
    }
複製程式碼

很快的,你就申明瞭一個值型別。但是如何實現類似 Array 這樣的值型別呢?你無法把陣列裡面所有資料放入 struct 的申明中,因為在寫程式碼的過程中你無法預料你將會在陣列中放多少資料。你可以建立一個指標指向所有的資料:

    struct Array<T> {
        var ptr: UnsafeMutablePointer<T>
    }
複製程式碼

同時,你需要在該 struct 每次分配銷燬的時候進行一些特殊操作。

  • 在分配的過程中,你需要把包含的資料拷貝一份放到一個新的記憶體地址,這時新的 struct 就不會和原資料共享同一份資料了。
  • 在銷燬的過程中,ptr 指標也需要正常銷燬。

在 Swift 中不允許對 struct 的分配和銷燬過程進行自定義。

銷燬操作可以使用一個 class 實現,它提供了 deinit。同時可以在這裡對指標進行銷燬。class 並不是值型別,但是我們可以將 class 作為一個內部屬性提供給 struct 使用,並把陣列作為 struct 暴露給外部介面。看起來就像這樣:

    class ArrayImpl<T> {
        var ptr: UnsafeMutablePointer<T>

        deinit {
            ptr.destroy(...)
            ptr.dealloc(...)
        }
    }

    struct Array<T> {
        var impl: ArrayImpl<T>
    }
複製程式碼

這時在 Array 中申明的方法,它的實際操作都是在 ArrayImpl 上進行的。

到這裡就可以結束了嗎?儘管我們使用的是 struct, 但是使用的依舊是引用型別。如果將這個 struct 拷貝一份,我們將獲得一個新的 struct,持有的仍然是之前的 ArrayImpl。由於我們無法自定義 struct 的分配過程,所以沒有辦法同樣把 ArrayImpl 也拷貝一份。

這個問題的解決方法是放棄在分配的過程中進行拷貝,而是在 ArrayImpl 發生改變的時候進行拷貝。關鍵在於,就算一個拷貝副本與原始資料共享一個引用,但是隻要這個引用的資料不發生改變,值型別的語義就依舊成立。只有當這個共享資料的值發生改變時,值型別和引用型別才有了明顯的區別。

比如,在實現 append 方法的時候你可以先對 ArrayImpl 進行 copy (假設 ArrayImpl 的實現中有一個 copy 方法,那麼將 impl 引用改為原始值的 copy):

    mutating func append(value: T) {
        impl = impl.copy()
        impl.append(value)
    }
複製程式碼

這樣 Array 就是一個值型別了。儘管 a 和 b 在剛賦值完時仍然共享同一個 impl 引用 ,但是任何會改變 impl 的方法都將對其進行一次 copy,因此保留了不共享資料的錯覺。

現在可以正常工作了,但是效率卻非常低。比如:

    var a: [Int] = []
    for i in 0..<1000 {
        a.append(i)
    }
複製程式碼

儘管使用者無法看到它,但是它將在迴圈的每次迭代中複製記憶體中的資料,然後立即銷燬之前的資料記憶體。如何才能優化它呢?

isUniquelyReferenced

這是一個 Swift 1.2 中新引入的 API。它漂亮的實現了它字面上的意思。賦予它一個物件的引用然後它將告訴你這個引用是否為獨立的。具體來說,當這個物件有且僅有一個強引用時,就會返回 true

我們猜測這個 API 會檢查物件的引用計數,並且在引用計數為 1 的時候返回 true 。那 Swift 為什麼不直接提供一個介面來查詢引用計數呢?可能在實現上這個介面不太好做,並且引用計數屬於比較容易被濫用的資訊,所以 Swift 提供了這個封裝過的,更加安全的介面。

使用這個 API,之前對 append 方法的實現將可以改成只有在需要的時候才對記憶體中的資料進行復制:

    mutating func append(value: T) {
        if !isUniquelyReferencedNonObjc(&impl) {
            impl = impl.copy()
        }
        impl.append(value)
    }
複製程式碼

這個 API 實際上是一組三個方法中的一個。存在於 Xcode 自帶的 Swift 標準庫中:

    func isUniquelyReferenced<T : NonObjectiveCBase>(inout object: T) -> Bool
    func isUniquelyReferencedNonObjC<T>(inout object: T?) -> Bool
    func isUniquelyReferencedNonObjC<T>(inout object: T) -> Bool
複製程式碼

這些方法只能作用在純 Swift class 中,並不支援 @objc 型別。第一個方法必須確保 T 為 NonObjectiveCBase 的子類。另外兩個方法對引數的型別並不做要求,只是當型別為 @objc 時直接返回 false。

我無法讓我的程式碼以 NonObjectiveCBase 型別來編譯,所以使用了 isUniquelyReferencedNonObjC 來代替。從功能上來說,它們並沒有區別。

譯者注:
文章中所闡述的 isUniquelyReferencedNonObjC API 已經在 Swift 3.1 的時候被替換為 isKnownUniquelyReferenced
詳情可以參考 swift-evolution 中的這條 建議

ArrayImpl

讓我們開始實現 Swift.Array,首先從 ArrayImpl 開始,然後才是 Array

在這裡我並不會重新實現 Array 完整的 API,只是實現滿足其正常執行的基本功能,並展示它涉及的原理。

ArrayImpl 有三個屬性:指標,陣列元素的總數,以及已申請記憶體空間中的剩餘容量。只有指標和元素的總數是必須的,但是相比申請更多的記憶體空間,實時監控剩餘容量並按需申請,我們可以避免一大筆昂貴的記憶體重新分配。下面是類開始的部分:

    class ArrayImpl<T> {
        var space: Int
        var count: Int
        var ptr: UnsafeMutablePointer<T>
複製程式碼

init 方法中需要一個計數和一個指標,然後將指標所指向的內容複製到新的物件。方法提供了預設值 0 和 nil ,所以 init 可以在不傳入任何引數的情況下被用來建立一個擁有空陣列的例項:

        init(count: Int = 0, ptr: UnsafeMutablePointer<T> = nil) {
            self.count = count
            self.space = count
            
            self.ptr = UnsafeMutablePointer<T>.alloc(count)
            self.ptr.initializeFrom(ptr, count: count)
        }
複製程式碼

initializeFrom 方法可以將資料複製到新的指標。注意區分 UnsafeMutablePointer 處於不同賦值方式之間的不同,這對於確保它們正常工作以及避免崩潰十分重要。不同之處在於資料記憶體是否被處理為初始化或未初始化。在呼叫 alloc 時,生成的指標處於未初始化的狀態,並且可能被垃圾資料填滿。一個簡單的賦值,例如 ptr.memory = ... ,此時是不合法的,因為賦值操作將會在複製新的值之前析構已經存在的值。如果是類似 int 這樣的基礎資料型別將沒什麼問題,但是如果你操作的是一個複雜資料型別它將崩潰。在這裡 initializeFrom 將目標指標視為未初始化的記憶體,而這正是它的本質。

接下來是一個改變過的 append 方法。它做的第一件事是檢查指標是否需要重新分配。如果沒有剩餘的空間可用,我們需要一塊新的記憶體:

        func append(obj: T) {
            if space == count {
                // 在新的記憶體分配中,我們將申請兩倍的容量,並且最小值為 16 :
                let newSpace = max(space * 2, 16)
                let newPtr = UnsafeMutablePointer<T>.alloc(newSpace)
                // 從舊的記憶體中將資料拷貝到新的地址
                newPtr.moveInitializeFrom(ptr, count: count)
                /*
                這是另一種賦值,它不僅把目的指標看做是未初始化的,並且會把資料來源銷燬。  
                它節省了我們單獨寫程式碼來銷燬舊記憶體的操作,同時可能更加高效。  
                隨著資料的移動完成,舊指標將可以被釋放,新的資料將被賦值給類的屬性:
                */
                ptr.dealloc(space)
                ptr = newPtr
                space = newSpace
            }
            // 現在我們確信有足夠的空間,所以可以把新的值放在記憶體的最後面並且遞增 count 屬性的值:
            (ptr + count).initialize(obj)
            count++
        }
複製程式碼

改變過的 remove 方法將更為簡潔,因為沒有必要重新分配記憶體。首先,他將在移除一個值之前先將其銷燬:

譯者注:
這裡的 # 號不用理會,在早期的 Swift 版本中它表示內外引數同名)**

        func remove(# index: Int) {
            (ptr + index).destroy()
            // moveInitializeFrom 方法負責將所有在被移除元素之後的元素往前挪一個位置
            (ptr + index).moveInitializeFrom(ptr + index + 1, count: count - index - 1)
            // 遞減 count 屬性的值來體現這次刪除操作
            count--
        }
複製程式碼

我們同樣需要一個 copy 方法來確保當需要的時候可以從資料記憶體中複製一份。實際關於複製的程式碼存在於 init 方法中,所以我們只需要建立一個例項也就相當於執行了一次複製:

        func copy() -> ArrayImpl<T> {
            return ArrayImpl<T>(count: count, ptr: ptr)
        }
複製程式碼

這樣,我們就基本上完成了所有的事情。我們只需要確保在它自己將要被銷燬,呼叫 deinit 方法之後銷燬所有陣列中的元素並釋放指標:

        deinit {
            ptr.destroy(count)
            ptr.dealloc(space)
        }
    }
複製程式碼

讓我們把它移到 Array struct。它唯一的屬性就是一個 ArrayImpl

    struct Array<T>: SequenceType {
        var impl: ArrayImpl<T> = ArrayImpl<T>()
複製程式碼

所有 mutating 型別的方法都將以檢查 impl 是不是獨立的引用為開始,並在不是的時候進行復制操作。將把它封裝成一個函式提供給其他的方法使用:

        mutating func ensureUnique() {
            if !isUniquelyReferencedNonObjC(&impl) {
                impl = impl.copy()
            }
        }
複製程式碼

append 方法現在只呼叫了 ensureUnique 方法,然後呼叫 ArrayImplappend 方法:

        mutating func append(value: T) {
            ensureUnique()
            impl.append(value)
        }
複製程式碼

remove 方法也是一樣:

        mutating func remove(# index: Int) {
            ensureUnique()
            impl.remove(index: index)
        }
複製程式碼

count 屬性直接通過 ArrayImpl`s 來返回:

        var count: Int {
            return impl.count
        }
複製程式碼

下標操作直接通過底層指標來進行訪問。如果我們是在寫真實的程式碼,在這裡我們會需要進行一個範圍的檢查(remove 方法中也是),但是在這個例子中我們將它省略了:

        subscript(index: Int) -> T {
            get {
                return impl.ptr[index]
            }
            mutating set {
                ensureUnique()
                impl.ptr[index] = newValue
            }
        }
複製程式碼

最後,Array 遵循 SequenceType 協議以支援 for in 迴圈。其必須實現 Generator typealiasgenerate 方法。內建的 GeneratorOf 型別使其很容易實現。GeneratorOf 使用一個程式碼塊確保在其每次被訪問的時候返回集合中的下一個元素,或者當到達結尾的時候返回 nil,並創造一個 GeneratorType 來封裝該程式碼塊:

        typealias Generator = GeneratorOf<T>
複製程式碼

generate 方法從 0 開始遞增直到執行至結尾,然後開始返回 nil

        func generate() -> Generator {
            var index = 0
            return GeneratorOf<T>({
                if index < self.count {
                    return self[index++]
                } else {
                    return nil
                }
            })
        }
    }
複製程式碼

這就是它的全部!

Array

我們的 Array 是一個符合 CollectionType 協議的通用 struct

    struct Array<T>: CollectionType {
複製程式碼

它唯一擁有的屬性是一個底層 ArrayImpl 的引用:

        private var impl: ArrayImpl<T> = ArrayImpl<T>()
複製程式碼

任何一個方法如果會改變這個陣列必須先檢查這個 impl 是否為一個獨立的引用,並在它不是的時候進行復制。這個功能被封裝成一個私有的方法提供給其他的方法使用:

        private mutating func ensureUnique() {
            if !isUniquelyReferencedNonObjC(&impl) {
                impl = impl.copy()
            }
        }
複製程式碼

append 方法會使用 ensureUnique 然後呼叫 impl 中的 append

        mutating func append(value: T) {
            ensureUnique()
            impl.append(value)
        }
複製程式碼

remove 的實現基本是相同的:

        mutating func remove(# index: Int) {
            ensureUnique()
            impl.remove(index: index)
        }
複製程式碼

count 屬性是一個計算性屬性,它將直接通過 impl 來呼叫:

        var count: Int {
            return impl.count
        }
複製程式碼

下標操作將直接通過 impl 來修改底層的資料儲存。通常這種直接從類的外部進行訪問的方式是一個壞主意,但是 ArrayArrayImpl 聯絡的太過緊密,所以看起來並不是很糟糕。subscriptset 的部分會改變陣列,所以需要使用 ensureUnique 來保持值語義:

        subscript(index: Int) -> T {
            get {
                return impl.ptr[index]
            }
            mutating set {
                ensureUnique()
                impl.ptr[index] = newValue
            }
        }
複製程式碼

CollectionType 協議需要一個 Index typealias。對於 Array 來說,這個索引型別就是 Int

    typealias Index = Int
複製程式碼

它同時也需要一些屬性來提供一個開始和結束的索引。對於 Array 倆說,開始的索引為 0 ,結束的索引就是陣列中元素的個數:

        var startIndex: Index {
            return 0
        }
    
        var endIndex: Index {
            return count
        }
複製程式碼

CollectionType 協議包含 SequenceType 協議,它使得物件可以被用於 for/in 迴圈。它的工作原理是讓序列提供一個生成器,該生成器是一個可以返回序列中連續元素的物件。生成器的型別由採用協議的型別來定義。Array 中採用的是 GeneratorOf,它是一個簡單的封裝用來支援使用一個閉包建立生成器:

        typealias Generator = GeneratorOf<T>
複製程式碼

generate 方法將會返回一個生成器。它使用 GeneratorOf 並且提供一個閉包來遞增下標,直到下標到達陣列的結尾。通過在閉包的外面宣告一個 index,使它在呼叫中被捕獲,並且它的值持續存在:

        func generate() -> Generator {
            var index = 0
            return GeneratorOf<T>{
                if index < self.count {
                    return self[index++]
                } else {
                    return nil
                }
            }
        }
    }
複製程式碼

這樣就完成了 Array 的實現。

完整的實現和測試程式碼

這裡提供了完整的實現,附加一些測試來確保所有的這些正常執行,我放在了 GitHub 上面:

gist.github.com/mikeash/63a…

結語

在 Swift 1.2 中新增的 isUniquelyReferenced 是一個廣受好評的改變,它讓我們可以實現很多真正有趣的值型別,包括對標準庫中值型別集合的複製。

今天就到這裡。下次再來找樂趣,功能,以及有趣的功能。如果你有感興趣的主題,請發給我們!郵箱地址:mike@mikeash.com。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章