數組、鏈表及其效能,數組效能
猛戳連結閱讀最終版
病人:醫生醫生,我一啪啪就蛋疼
醫生:那就別啪
我在Twitter上說過:
你提醒過我使用 reduce 的方式構建數組雖然有趣,但有使效能減半的風險。
很多人覺得這句話很奇怪,這讓我非常驚訝。相當一部分人建議將 reduce 版本改成不做數組拷貝的(我不認為這樣是可行的)。也有建議說需要對 + 運算子做最佳化,讓它不做拷貝操作(我同樣不認為這樣做很簡單,而且很快我們就會意識到這一點)。
其他人建議我除非文檔有提到,不然就不需要在意這些細枝末節的問題(而我認為這是在編寫代碼時必須注意的——說什麼“只有在文檔告訴我這裡有問題時我才注意”就好像“只有單元測試結果顯示不正確時我才編寫正確代碼”一樣。)
而其他的反饋中,有一部分是與我之前發的鏈表那篇文章有關,為什麼去實現一個已經過時的資料結構?我們已經有數組了,它的存在還有什麼意義?
所以,你就知道為什麼我有時候會特別提到這不是一個只關於 Mac 和 iOS 編程的部落格?這當然不是一個只關於 Mac 和 iOS 編程的部落格!不要因為我偶然覺得一個包含枚舉類型的鏈表有趣你就把他放到你的 app 裡面。因為我也可能會對你隨之而來的效能問題產生興趣,而你不會。儘管如此,我覺得鏈表的例子非常有意思,而且值得實現和把玩,它有可能為提升數組 reduce 方法的效能提供協助。甚至在某些(少見的)情境下對實際編碼有用。
同時我認為Swift的一些額外特性:比如它的枚舉可以靈活滴在對象和具體方法中自由選擇,以及“預設安全”。這些都促使它成為一門非常好的 CS 教學語言。這本書 未來的版本可能就會用Swift作為實現語言。
所以,總的來說——有時候,你會發現使用 reduce 來構建一個數組(字典或者集合),比如,下面是使用 reduce 的 map 的實現:
extension SequenceType { func mapUsingReduce<T>(transform: Generator.Element->T) -> [T] { return reduce([]) { $0 + [transform($1)] } }}
相對滴,建立可變數組然後使用 for 迴圈做映射的實現:
extension SequenceType { func mapUsingFor<T>(transform: Generator.Element->T) -> [T] { var result: [T] = [] for x in self { result.append(transform(x)) } return result }}
不同點在於, + 運算每次都會拷貝不斷變長的數組。拷貝數組的消耗的時間是線性,即遍曆整個數組,所以隨著映射數組長度的增長,消耗總時間成二次冪增長。
儘管如此,正常來說人們都不會去重新實現 map 函數:你看到的更多的會是這樣一些技巧:告訴你去重或者根據詞頻建立字典。但是最本質的問題仍然存在。
但是這個和鏈表又有什麼關係?因為你可以利用 上次鏈表的代碼 來實現使用 reduce 的 map 版本,就像下面這樣:
extension SequenceType { func mapToList<T>(transform: Generator.Element->T) -> List<T> { return reduce(List()) { $0.cons(transform($1)) }.reverse() }}
結果就是這個版本的效能竟然只有數組版本的一半(因為 reverse 這一步),以至於你的老師都會懷疑你是在測試結果上作弊,而不是實驗產生的結果:
得到這個結果的原因是鏈表是連續的——舊的鏈表和新串連的鏈表之間永遠都是用節點相連。所以不需要拷貝。但是代價是只能從頭部增加鏈表的長度(所以才需要 reverse),而且鏈表必須是完全不變的。所以就算鏈表只有一個引用的時候,也需要先拷貝再對它進行修改。這和 Array 是有區別的, Array 可以檢測它的緩衝區何時是單獨被訪問的,然後就可以直接修改,而不需要拷貝。使用鏈表還有其他的代價——統計鏈表節點的個數所需要的時間是統計數組元素個數時間的兩倍,因為遍曆鏈表時的間接定址方式是需要消耗時間的。
所以數組在 + 操作時做完全拷貝的問題是可以解決的嗎?在考慮這個問題之前,我們先來看一個寫時拷貝數組能有什麼協助。Mike Ash 的 一篇牛x博文 已經實現了一個寫時拷貝數組,所以我們稍作改變,使用標準庫中的 ManagedBuffer 類來實現寫時拷貝數組。
ManagedBuffer
ManagedBuffer 是一個可繼承,用於簡化分配/釋放記憶體操作和堆上記憶體管理的類。它是泛型,有 Value 和 Element 這兩個獨立預留位置, Element 儲存了 n 個元素的 block 所屬的類型,它在建立執行個體時就被動態分配出來。Value 則是附加用來儲存其他資訊的變數所屬的類型——比如,如果要實現數組,你需要儲存元素的個數,因為在記憶體釋放之前需要把元素銷毀掉。對元素的訪問是通過 withUnsafeMutablePointerToElements ,而對 value 的訪問則可以通過一個簡單的非安全方法,或者直接使用 .value 屬性。
下面的代碼實現了一個極簡的自銷毀數組緩衝區:
private class MyArrayBuffer<Element>: ManagedBuffer<Int,Element> { deinit { self.withUnsafeMutablePointerToElements { elems->Void in elems.destroy(self.value) } }}
這樣一來, MyArrayBuffer 儲存的元素仍然是泛型, 但是把 ManagedBuffer 的 Value 設定為 Int, 用來儲存緩衝區元素的個數(有一點需要銘記於心,我們會分配比數組中元素更多的空間,用來避免頻繁的重分配操作)。
當緩衝區被析構時,MyArrayBuffer.deinit 會在 ManagedBuffer.deinit 之前調用,ManagedBuffer.deinit 會釋放記憶體。這樣的話 MyArrayBuffer 就有機會銷毀其所有對象。如果 Element 不單單是一個被動的結構體,銷毀對象就非常有必要了——比如,如果數組裡麵包含了其他寫時拷貝類型,銷毀它們會觸發它們去釋放自身的記憶體。
現在我們可以建立一個數群組類型的結構體,這個結構體使用一個私人的緩衝區來進行儲存:
public struct MyArray<Element> { private var _buf: MyArrayBuffer<Element> public init() { _buf = MyArrayBuffer<Element>.create(8) { _ in 0 } as! MyArrayBuffer<Element> }}
我們不直接使用 MyArrayBuffer 的 init——而使用 ManagedBuffer 的類方法。因為這個方法返回父類,然後我們將其向下強制轉換為正確的類型。
然後我們將 MyArray 變成一個集合類型:
extension MyArray: CollectionType { public var startIndex: Int { return 0 } public var endIndex: Int { return _buf.value } public subscript(idx: Int) -> Element { guard idx < self.endIndex else { fatalError("Array index out of range") } return _buf.withUnsafeMutablePointerToElements { $0[idx] } }}
下一步,我們需要為緩衝區添加兩個相當相似的方法,一個用來拷貝記憶體,另一個用來調整記憶體大小。拷貝方法會在有檢測到共用儲存時調用,調整大小方法則會在需要更多記憶體時調用:
extension MyArrayBuffer { func clone() -> MyArrayBuffer<Element> { return self.withUnsafeMutablePointerToElements { oldElems->MyArrayBuffer<Element> in return MyArrayBuffer<Element>.create(self.allocatedElementCount) { newBuf in newBuf.withUnsafeMutablePointerToElements { newElems->Void in newElems.initializeFrom(oldElems, count: self.value) } return self.value } as! MyArrayBuffer<Element> } } func resize(newSize: Int) -> MyArrayBuffer<Element> { return self.withUnsafeMutablePointerToElements { oldElems->MyArrayBuffer<Element> in let elementCount = self.value return MyArrayBuffer<Element>.create(newSize) { newBuf in newBuf.withUnsafeMutablePointerToElements { newElems->Void in newElems.moveInitializeFrom(oldElems, count: elementCount) } self.value = 0 return elementCount } as! MyArrayBuffer<Element> } }}
同時構建和填充緩衝區是有些苛刻的——首先我們需要獲得指向已存在元素的非安全指標,然後調用 create,這個方法擁有的閉包會接收一個只構建了一部分的對象(比如,分配了記憶體但是沒有初始化),這個對象隨後需要調用 newBuf.withUnsafeMutablePointerToElements 來把記憶體從舊的緩衝區拷貝到新的緩衝區。
這兩個方法最主要的不同點是 clone 不會改變舊的緩衝區中的元素,而只是把新的拷貝載入到新的緩衝區。 resize 則會把元素從舊的記憶體移動到新的記憶體(通過 UnsafeMutablePointer 的 moveInitializeFrom 方法),然後更新舊的緩衝區,告訴它已經不需要管理任何元素——不然,它會試圖在 deinit 時銷毀它們。
最後,我們給 MyArray 添加一個 append 和 extend 方法:
extension MyArray { public mutating func append(x: Element) { if !isUniquelyReferencedNonObjC(&_buf) { _buf = _buf.clone() } if _buf.allocatedElementCount == count { _buf = _buf.resize(count*2) } _buf.withUnsafeMutablePointers { (val, elems)->Void in (elems + val.memory++).initialize(x) } } public mutating func extend<S: SequenceType where S.Generator.Element == Element>(seq: S) { for x in seq { self.append(x) } }}
這隻是一段範例代碼。事實上,你可能會把唯一性判斷代碼和調整大小代碼單獨抽出來,這樣你就可以在下標集和其他稍微有變化的方法中重用,而我只是簡單起見所以就把他們都塞在 append 方法裡面了。而且有可能的話你會為 append 保留足夠的空間讓它進行擴充,這樣就可以防止在同時共用且空間太小時緩衝區被雙重拷貝。但是所有這些事情對於我們的偉大藍圖都沒有太大的影響。
好了現在就是操作符了。首先, += ,賦值操作符,它左側是 inout 的,使用右側的內容對左側進行擴充:
func +=<Element, S: SequenceType where S.Generator.Element == Element> (inout lhs: MyArray<Element>, rhs: S) { lhs.extend(rhs)}
最後是 + 操作符。我們可以根據 += 操作符的方式來實現它。這個操作符傳入兩個不可變的數組,然後將它們合并成一個新的數組。它依賴於寫時拷貝動作來為左側內容建立一個可變拷貝,然後使用右側的內容來進行擴充:
func +<Element, S: SequenceType where S.Generator.Element == Element> (lhs: MyArray<Element>, rhs: S) -> MyArray<Element> { var result = lhs result += rhs return result}
事實上你可以在 lhs 變數之前使用 var 標識符來進一步縮短代碼:
func +<Element, S: SequenceType where S.Generator.Element == Element> (var lhs: MyArray<Element>, rhs: S) -> MyArray<Element> { lhs += rhs return lhs}
我提到第二個版本是因為有人提議一個更好的 reduce 策略可能是為累加參數上添加 var 標識符。而這剛好和 lhs 所發生的事情是類似的: var 所做的事情只是聲明傳入的參數是可變的。它仍然是拷貝——它不是以某種方式傳遞過來原值的引用。
+ 操作符是可以最佳化的嗎?
現在我們有了一個完全可用的寫時拷貝數組的雛形,你可以對它做 append 操作,它也實現了 + 操作符。這也就意味著我們可以用它來重寫 reduce 版的 map 方法:
func mapUsingMyReduce<T>(transform: Generator.Element->T) -> MyArray<T> { return reduce([]) { $0 + [transform($1)] }}func mapUsingMyFor<T>(transform: Generator.Element->T) -> MyArray<T> { var result = MyArray<T>() for x in self { result.append(transform(x)) } return result}
如果你用圖表對效能進行記錄,你會發現這兩段代碼和使用數組實現的方式的表現完全類似。
所以,現在的情況是我們擁有一個完全受我們自己控制的實現,我們可以改變 + 操作符然後讓它不做拷貝嗎?我不認為我們做到了。
來看一個更簡單的例子,我們可以改變下面的代碼:
var a = MyArray<Int>()a.extend(0..<3)let b = a + [6,7,8]
然後讓它不做拷貝嗎?很明顯我們不能。 b 必須是一個新的數組拷貝,目的是不影響 a。即使我們在建立 b 之後不對 a 做任何修改, + 操作符的實現也是沒有辦法知道這些的。也許編譯器會知道,然後根據情況進行最佳化,但是 + 方法是不可能知道的。
在此處檢查唯一引用也不會有什麼協助。 a 仍然存在,所以 lhs 不可能是緩衝區的唯一持有人。
reduce 方法也沒什麼不同,下面是一種可能的實現:
extension SequenceType { func myReduce<T>(initial: T, combine: (T,Generator.Element)->T) -> T { var result = initial for x in self { result = combine(result,x) } return result }}
假設這裡的 combine 是 { $0 + [transform($1)] },你會發現 + 操作符同樣不知道我們直接將結果賦值給了 result 變數。檢查代碼我們就知道,如果有可能的話把右側的內容添加到左側的內容中是可行的(理論上來說是的,因為儘管數組是以不可變的值傳遞的,因為它的緩衝區是一個類,這樣它就有了引用的語義,從而可以被改變)。但是 + 操作符單通過它的位置是不可能知道這點的。它只是明確滴知道左側內容的拷貝不是緩衝區唯一的持有人。還有另外一個持有人:reduce 持有 result 的一份拷貝——而且馬上就要將其摒棄然後使用新的結果來替換它,但是這都是在 + 操作執行之後。
還有一線希望就是如果每個數組剛好是它們的分區(然而並不是——而是有一個叫 ArraySlice 的東西,它需要額外的開銷來把分區的起始和結束點記錄到父數組中)。如果它們是的話,也許它們就可以被修改成允許其中一個,也只能是一個數組在做 append 操作時被其他數組忽略。但是這通常都可能會增加數組的開銷,而整個數組的目的就是要快速——你肯定不會為了這種情況就讓它們變慢吧。
也許有一種非常聰明的辦法可以解決所有的這些問題,可能需要編譯器的協助也可能不需要。但儘管如此這仍然不是一個很好的主意。+ 操作符語義是建立一個新數組。而想讓它在某種非常特定情況下隱式滴修改一個已經存在的數組顯然不是正確解決方案。如果有需要的話你可以把 var 封裝在一個小的虛方法中,就好像沒有使用它一樣。但是它仍然可以讓你的代碼效率變得更高。