Swift Talk:理解值型別

Sunxb 發表於 2022-07-04

我們使用寫時複製 copy on write 的思想,對 NSMutableData 進行封裝,以此來理解我們的標準庫的實現方式。

標準庫中提供的所有的基本集合型別都是值型別,通過寫時複製的思想保證了他的高效性。集合型別是我們比較常用到的資料型別,所以瞭解他的效能特性很重要,我們來一起看一下寫時複製是如何工作的,並且嘗試自己手動實現一個。

引用型別

舉個例子,我們比較一下Swift的Data(結構體)和Foundation庫中的NSMutableData(類)。首先我們使用一些位元組資料來初始化 NSMutableData 例項。

var sampleBytes: [UInt8] = [0x0b,0xad,0xf0,0x0d]
let nsData = NSMutableData(bytes: sampleBytes, length: sampleBytes.count)

我們使用了 let 來宣告 nsData,但是像 NSMutableData 這樣的引用型別不受let/var 的控制。對於引用型別來說,用 let 宣告代表 nsData 這個指標不能在指向別的記憶體,但是他指向的這個記憶體中的資料是可以變化的。也就是說我們依然可以往 nsData 中 append 資料。

nsData.append(sampleBytes, length: sampleBytes.count)

當我們再宣告一個物件,改變其中一個物件,另一個物件也會發生變化。

let nsOtherData = nsData
nsData.append(sampleBytes, length: sampleBytes.count)
// nsOtherData 也會變

如果我們想產生一個獨立的副本,我們需要使用 mutableCopy(返回一個 Any 型別),我們需要把返回值強轉成我們需要的 NSMutableData 型別。

let nsOtherData = nsData.mutableCopy() as! NSMutableData
nsData.append(sampleBytes, length: sampleBytes.count)
// nsOtherData 不變

值型別

首先我們也是通過 sampleBytes 來初始化一個 Data。

let data = Data(bytes: sampleBytes, count: sampleBytes.count)

如果我們使用 let 關鍵字,那編譯器就不會允許我們呼叫型別 append 這樣的方法。所以如果要改變 data 的值,要使用 var 。

var data = Data(bytes: sampleBytes, count: sampleBytes.count)
data.append(contentsOf: sampleBytes)

Data 和 NSData 最主要的不同之處是:把值賦給另一個變數時或者作為引數傳到方法中,Data 總是會生成一個新的副本,但是 NSData 只會生成一個新的引用,但是兩個引用指向同一個記憶體區域。

當我們建立 Data 的一個副本的時候,他的所有的欄位都會被複制,但是又不是立刻複製,因為 Data 記憶體有對實際記憶體空間的引用,所以當結構體被複制時,也只是會生成一個新的引用,只有我們對這個新的引用修改資料是,實際的資料才會被複制。

實現寫時複製

我們自己實現一個 Data 型別來幫我們理解寫時複製是如何工作的,我們內部使用 NSMutableData 來實際的儲存資料(只是為了更快的完成,實際的Data 內部肯定是用到更底層的資料結構來儲存資料)。改變資料的方法我們只實現一個 append 方法。

struct MyData {
    var data = NSMutableData()
    
    func append(_ bytes: [UInt8]) {
        data.append(bytes, length: bytes.count)
    }
}

我們可以建立一個 MyData

let data = MyData()

為了能更好的列印出 data 中儲存的資料,我們可以讓 MyData 實現 CustomDebugStringConvertible 協議。

extension MyData: CustomDebugStringConvertible {
    var debugDescription: String {
        return String(describing: data)
    }
}

現在我們可以呼叫 append 方法了。

data.append(sampleBytes)

但這是有問題的,首先我們的MyData是結構體,而且建立 data 使用的是let,我們不應該可以修改他的值。

而且看下面的程式碼,他的複製行為也是有問題的,在我們宣告瞭一個新的引用時,並沒有獲得一個完全獨立的副本。

var copy = data
copy.append(sampleBytes)

print(data)
print(copy)
// copy 呼叫 append, data 也會改變

所以說我們雖然建立了一個結構體,但是他並沒有表現出值語義來。

目前,我們在把 data 賦給一個新的變數時,雖然他是所有欄位都複製,但是我們MyData內部的 data 是一個 NSMutableData 引用型別,所以說 data 和 copy 這兩個變數的值現在都包含對同一個 NSMutableData 例項的引用。

為了解決這個問題,我們要先處理寫時複製的’寫時‘問題。當我們在呼叫 append 方法新增資料時,我們要把內部進行實際儲存功能的data進行深拷貝,此時 我們的 append 方法就必須加上 mutating 關鍵字,要不然編譯器不允許修改結構體的變數。

struct MyData {
    var data = NSMutableData()
    
    mutating func append(_ bytes: [UInt8]) {
        print("make a copy")
        data = data.mutableCopy() as! NSMutableData
        data.append(bytes, length: bytes.count)
    }
}

現在我們要重新生成一個 var 型別的 data 來呼叫 append 方法,因為編譯器不允許let 型別的呼叫帶 mutating 關鍵字的方法。

var data = MyData()
var copy = data
copy.append(sampleBytes)

在我們繼續之前,進行一個小的重構,並將生成 NSMutableData 例項副本的程式碼提取到一個單獨的屬性中。

struct MyData {
    var data = NSMutableData()
    var dataForWriting: NSMutableData {
        mutating get {
            print("make a copy")
            data = data.mutableCopy() as! NSMutableData
            return data
        }
    }
    
    mutating func append(_ bytes: [UInt8]) {
        dataForWriting.append(bytes, length: bytes.count)
    }
}

讓寫時複製更高效

目前我們的寫時複製是非常簡單的,就是每次當我們呼叫 append 的時候,都會拷貝,不管我們是不是這個例項的唯一持有者。

for _ in 0..<10 {
    data.append(sampleBytes)
}
// making a copy 會列印10次

其實真正需要執行復制操作的是當我們把data賦值給另一個變數後,這時呼叫append 方法,因為此時有兩個引用,所以需要進行深拷貝。當拷貝結束後,這兩個都是引用指向的都是完全獨立的備份了,所以再一次呼叫時就不需要拷貝了。

所以說我們的MyData結構沒有問題,但是多次拷貝會降低效能。我們可以使用 isKnownUniquelyReferenced 這個方法來幫助我們實現想要的效果。

var dataForWriting: NSMutableData {
    mutating get {
        if isKnownUniquelyReferenced(&data) {
            return data
        }
        print("make a copy")
        data = data.mutableCopy() as! NSMutableData
        return data
    }
}

雖然我們現在加上了 isKnownUniquelyReferenced 檢查,但是執行一下測試程式碼還是會copy多次,那是因為 isKnownUniquelyReferenced 方法只是對Swift型別有效果,如果是傳入的OC型別的物件,總是會返回false,所以我們應該使用一個Swift型別來包裝一下這個data型別。

final class Box<A> {
    let unbox: A
    init(_ value: A) {
        self.unbox = value
    }
}

我們使用這個Box類來包裝 NSMutableData , 最終我們的MyData 變成下面這樣子

struct MyData {
    var data = Box(NSMutableData())
    var dataForWriting: NSMutableData {
        mutating get {
            if isKnownUniquelyReferenced(&data) {
                return data.unbox
            }
            print("make a copy")
            data = Box(data.unbox.mutableCopy() as! NSMutableData)
            return data.unbox
        }
    }
    
    mutating func append(_ bytes: [UInt8]) {
        dataForWriting.append(bytes, length: bytes.count)
    }
}

現在我們的程式碼只對 NSMutableData 例項copy一次。

var data = MyData()
var copy = data
for _ in 0..<10 {
    data.append(sampleBytes)
}
// Prints:
// making a copy 一次

標準庫中陣列和字典的實現方式其實也是類似的,只是他們用了更低階的資料結構來儲存,我們這樣手動實現一次寫時複製,有助於我們更好理解他們內部的效能。

寫時複製注意點

寫時複製很高效,但是他不是適應於所有的場景,比如說我們上面的for迴圈是可以的,但是如果我們使用reduce來實現上面的迴圈,他就不起作用了。

(0..<10).reduce(data) { result, _ in
    var copy = result
    copy.append(sampleBytes)
    return copy
}

這個實現方式會生成 10 個副本,因為當我們呼叫 append 時,總是有兩個變數——copy 和 result——引用指向同一個例項。

所以我們應該注意我們程式碼中那些產品大量不必要副本的地方,不過我們一般都不會這麼寫,所以說問題不大。