我們使用寫時複製 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——引用指向同一個例項。
所以我們應該注意我們程式碼中那些產品大量不必要副本的地方,不過我們一般都不會這麼寫,所以說問題不大。