Functional Go: 持久化資料結構簡介

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

函數式編程模型因其天生對並發具備良好的支援,近些年來越來越受到重視。從這篇文章開始,我將以一個系列的部落格來記錄函數式編程的一個重要組件:持久化資料結構在 Go 語言下的實現。

這篇文章是系列文章的一部分,如果還沒有瀏覽過文章的其它部分請參考:

  1. 持久化資料結構簡介 (本文)
  2. Vector Trie 的實現
  3. Transient 及持久化

函數式編程不是新概念,像 Haskell、Clojure、Scala 等函數式/類函數式程式設計語言也已經出現和存在了很長時間,很多函數式編程的概念現今已經被應用在很多其他領域,比如 Facebook 在 React的基礎上提出的 Flux 應用結構抽象就強調了引入持久化資料結構的好處。事實上,Facebook 還開源了自己的 JavaScript 持久化資料結構實現 ImmutableJS。儘管之前已經有 Elm 這樣更純粹的函數式實現,Facebook 的 React + Flux + ImmutableJS還是為在一般的前端開發提供了第一個被大規模應用的案例和流行的契機。

儘管很早之前就對函數式編程感興趣,但是引起我對持久化資料結構興趣的還是 ImmutableJS,通過在 React 應用裡使用它,我體會到持久化資料結構在很多方面的應用潛力。為了將持久化資料結構應用於 Go語言編寫的後端程式,也為了更好理解這類資料結構的實現,我決定親自動手用編寫這樣一套程式。

作為系列部落格的第一篇,這篇部落格將會先給出一些持久化資料結構的簡介並以最簡單的 List (列表)資料結構為例,介紹一些常見的持久化資料結構實現方法。這一個系列的文章都主要參考了Understanding Persistent Vector這篇非常經典的文章,其中一些章節甚至可以看作是對它內容的翻譯。建議有興趣的讀者瀏覽原文作為參考。

持久化資料結構簡介

持久化(Persistent)資料結構又叫不可變(Immutable)資料結構,顧名思義,這類資料結構的內容是不可變的。也就是說,對於這類資料結構的修改操作,都會返回一個新的副本,而原來的資料結構儲存的內容不會有任何改變。這樣的資料結構是有意義的,比方說我們現在所編寫的所有程式,都可以看作是一個狀態機器,也就是說在程式啟動並執行過程的每一個時刻,程式本身可以被看作存在一個狀態(State),我們的語句作用在目前狀態上,從而不斷地產生出進一步的狀態,由此迴圈往複。如果按照這樣的模型,那麼就存在兩個可能的問題需要解決:

  1. 我們的有些行為可以看作一系列對狀態的修改,比如說通過一個函數內的一系列操作來實現一個功能,先讀出資料庫裡的內容來進行修改等。這時,我們希望這一連串操作是原子的。也就是說整個過程要麼全部成功,要麼全部失敗。這樣的要求在資料庫裡是通過事務(Transaction)來實現的,某些程式設計語言(如Haskell)也提供了類似的方案,而持久化資料結構也是解決這一問題的一種方法——將原來的狀態儲存在不可變的資料結構中,只有當整個操作成功完成再將產生的新狀態替換回去,這樣系統就不會進入過程錯誤導致的中間狀態。
  2. 在並發程式中,一個函數的執行可能會帶有副作用——同樣的輸入和函數,得到的返回結果卻可能不一樣。比方說,將一個數組傳遞進一個函數進行遍曆處理,在函數執行的過程中,另一個線程修改了數組的內容,這樣就產生的線程同步等複雜的問題,增加了程式出現問題的可能性,也增加了 Debug 的難度。由於持久化資料結構本身不會被修改,因此將它傳入一個函數是安全的,任何對他的修改都表現在一個新的對象上,因此你傳入函數的輸出並不會被影響。

有些資料結構的實現為了在並發條件下安全運行,比如使用一些方法為資料結構加鎖,這一行為實際上會增加資料結構實現的難度和運行效能, 持久化資料結構不會改變原來的狀態,自然也就不會有加鎖的必要。 事實上,持久化資料結構天生是一種無鎖的資料結構。

當然,持久化資料結構也有一些缺陷,主要體現在以下幾個方面:

  1. 由於持久化資料結構在修改時需要產生新的對象,因此往往會比普通資料結構更加耗費記憶體空間。因此,任何持久化資料結構在設計上都要考慮如何節省空間的這一問題
  2. 持久化資料結構往往是在未經處理資料結構上的封裝,通過更複雜的操作保證未經處理資料結構的資料的不變性,這不但體現在修改操作上,也體現在讀取操作上。因此持久化資料結構的讀寫速度往往會慢於普通資料結構,其實現也更複雜
  3. 持久化資料結構脫胎於函數式編程,與一般的過程式程式設計語言在思維模式上差別比較大,操作起來也有很大不同,因此對於習慣了傳統過程式程式設計語言的學習者來說接受起來有一定的困難

儘管如此,持久化資料結構仍然有很大的應用空間,下面給出它們的一些應用情境。

持久化資料結構的應用

持久化資料結構在很多情況下是有優勢的,雖然大部分資料庫幫我們實現了事務模型,讓我們可以安全放心的使用,但是在大多數其他軟體系統中並沒有現成的事務工具供我們使用,事務模型依賴於對資料操作的記錄,如果運行失敗需要對狀態進行 Rollback,這不但是一個很難實現的功能,Rollback 過程也需要花費不少時間。相比起來持久化資料結構就成了一種更容易獲得的選擇。實際上,除去 Clojure、Scala 等這種內建了持久化資料結構的程式設計語言,絕大部分程式設計語言都有成熟可靠的開源庫提供了此類資料結構。

在 React + Flux 模型中,不可變資料結構還被用來加速狀態改變的對比,因為 React 依賴於對比前後兩個狀態之間的改變來發現需要對 Virtual DOM 進行的最小改變,因此必須要保留每一次修改之後的狀態。所有的操作都必須通過setState方法進行,很難保證之前的狀態沒有被其他地方意外的修改,而對比本身也是耗時的。但是在引入ImmutableJS後,每個 View 的狀態就可以很安全的儲存起來了。由於在 React 裡,state改變總是從一個最初的狀態衍生而來的一系列狀態,在對前後兩個狀態進行遞迴的比較時,如果兩個對象的引用是一樣的,那麼它們一定是一個不可變的對象,如果兩個對象的引用不一致,那麼一定經過了修改。因此通過這樣的最佳化可以加速比較的過程。

持久化資料結構的另一個應用是實現檔案系統的 Copy on Write 功能,很多檔案系統以及虛擬機器(VirtualBox)和容器(Docker)都提供 Snapshot 的功能,也就是說你可以儲存檔案系統在某一時刻的完整狀態並在未來某個時間方便地恢複到目前狀態。這在部署服務的時候非常有用——如果新上線的系統出現問題,我們可以快速簡單地回複到原來正常的狀態。傳統的 CoW 實現是在某一檔案修改的過程時候再複製它,這種實現在遇到比較大的檔案時是比較浪費空間的。然而很多持久化資料結構的實現本身就考慮到了節省空間的問題,因此可以很大程度上緩解這一問題。也就是說,對於一個檔案,我們可以只在寫入的時候覆制其中一小部分來實現塊層級的 CoW。在接下來的文章中我們可以看到,Vector trie 這種資料結構就很適合實現這一功能。

持久化資料結構的最廣泛的應用還是在並發編程當中,結合函數式編程的模型實現高效能且安全易於預測的代碼編寫。在一些多人聯機系統(協作工具、聯機遊戲)當中,多個使用者會並發地對某一個中心的狀態進行修改,而這個中心狀態還需要週期性儲存。使用不可變資料結構,每一個被傳入的狀態都是當時狀態的不可變的快照,因此我們可以安全的在一個新的線程上執行儲存操作。這提高了我們程式的並發性。

持久化資料結構的實現

在介紹了持久化資料結構的特點和應用之後,我們以最簡單的順序儲存結構 List(列表)為例,從簡入繁介紹幾種實現不可變資料結構的方法和思路。

首先,我們把 List 定義為一種順序儲存結構,它所儲存的元素從 0 開始編號,依次向後儲存。這一儲存結構包含如下幾種基礎的操作:

  1. New 建立一個 List
  2. Get(index) 獲得指定 Index 的元素
  3. Set(index, value) 修改指定 Index 的元素
  4. PushBack(value) 在 List 末尾添加一個元素
  5. RemoveBack(value) 從 List 末尾去除一個元素

此外,這個 List 可能還可以支援如下的一些操作,它們都可以用上面方法來實現(儘管有些資料結構支援更直接的方法):

  1. Insert(index, value) 在指定位置插入一個元素
  2. Remove(index, value) 在指定位置刪除一個元素
  3. Slice(i, j) 獲得 List 當中 ij 之間元素的一個切片
  4. Splice(i, j, List) 將 List 當中 ij 之間的元素替換為傳入的 List 當中的元素

在實現持久化資料結構的過程中,我們主要考慮的問題是每一種操作的時間消耗和資料結構的空間效率。我們的目標自然是尋找一種在空間和時間上都比較有效解決方案,然而也需要注意到各種不同的思路都有比較適合的使用情境,並不存在在各種情況下都最佳的實現。

在討論的過程中,除了使用大 O 標記法來衡量資料結構的時間效率,還使用資料元素所佔空間除以資料結構使用的總空間所得的比例來衡量資料結構的空間效率。

數組

數組是最簡單的線性儲存結構了,它在 C++ 中是 vector ,在 Java 中是 ArrayList,在 Golang 中則是 slice。數組本質上是一段連續的記憶體空間,資料元素一個接著一個的擺放。

一般來講,數組在建立時會預先分配一部分空間,當PushBack操作用完已經分配的所有空間之後,需要分配一塊大小為原來 2 倍(Java 中是 1.5 倍)的空間,再將原來的資料拷貝過來。顯然,在現今的記憶體模型中,對於數組元素進行 GetSet 操作的時間複雜度都是 $O(1)$ 的,也就是說數組資料結構特別適合隨機訪問。儘管在空間耗盡的時候需要進行空間倍增和複製的操作,但是均攤下來,每次 PushBack 操作的時間複雜度也是 $O(1)$ 的。

儘管看起來倍增操作讓數組比較浪費空間,但是實際情況下數組資料結構是空間利用率最高的資料結構之一,譬如說對於 Java 來說,平均的空間效率是 75% 。

這些特點使得數組成為最常用的資料結構之一,它同時也是一些其他資料結構(如 Hash 表)的基礎。但是對於數組來說,InsertRemoveSplice 操作的時間複雜度都比較高($O(N)$)。數組的另一個缺點在於如果資料量較大,倍增時需要分配非常大的空間可能是比較困難的。

數組作為我們通向持久化資料結構的引子,其本身並非一個合適的選擇——如所示,如果我們要保證每次修改時原來的資料不會被修改,唯一的辦法是將所有的資料複製一遍。接下來我們可以看到,在數組基礎上進行的一些改進將有助於解決這一問題。

鏈表

上面我們提到過,數群組類型的資料結構一個比較大的問題就是需要分配大塊連續的空間,這在記憶體比較緊張的環境下可能比較困難,另外一個巨大的缺點在於由於數組是必須是連續的空間,導致如果我們想在它的基礎上實現持久化資料結構比較困難。

一種解決方案是鏈表,鏈表的特點是將儲存的每個資料拆開來存放,對於簡單的單鏈表來說,每一個資料單元包括一個資料欄位和一個指標欄位。每個指標指向當前單元的下一個單元或者 NULL 代錶鏈表的結束。如果使用鏈表結構,GetSet 的平均時間複雜度都是 $O(N)$,儘管如此,在末尾插入和刪除資料的 PushBackRemoveBack 可以實現為 $O(1)$。實際上,鏈表特別適合這種頻繁在頭部和尾部進行增刪操作的使用情境,因此特別適合作為隊列或者棧。如果將鏈表作為順序儲存結構,那麼在進行資料修改的時候,我們可以複用所有當前修改之後的資料單元,如所示,我們將第二個單元的資料 b 改為 e ,只需要將其之前的 a 所在的單元也複製一遍,由此節省了不少空間。

實際上,鏈表實現的棧是持久化棧實現的理想資料結構,假定我們保留一個指向棧頂的HEAD指標,那麼當我們在棧中進行PushPop操作的時候,只需要複製HEAD指標以及修改指向的位置即可,的HEAD0HEAD1HEAD2分別代表原始棧、Pop一次、Push一次之後整個棧的結構關係。可以看到,只要我們記錄下操作過程中的HEAD,就可以獲得對應狀態的一個快照,這些快照本身不知曉其他快照的存在,但是卻共用了大部分空間。

然而鏈表作為一種順序儲存結構,其缺點也是很明顯的。首先就是對隨機資料訪問的支援較差,每次訪問一個資料單元,都要遍曆之前所有的單元,此外鏈表的空間效率也很低——由於每個資料單元必定至少包含一個指標欄位,鏈表的資料效率根本無法超過 50% 。

為瞭解決鏈表存在的問題,一個很自然的想法就是增加一個資料單元當中資料欄位本身所佔有的比例,這就是串的實現原理:在每個結構體當中用一個固定長度的數組儲存資料。這樣的做法不但增加了空間效率,也提高了PushBack操作的時間效率。使用串,在每次之前分配的空間用完的時候,只需要分配一個相對較小的新的資料單元,而不需要像數組那樣倍增並複製所有的資料。串可以看作數組和鏈表相結合所產生的資料結構,它具有很不錯的順序訪問效能,特別是串的長度恰好可以被放進 CPU 的 Cache 當中時它不會像鏈表那樣需要頻繁從記憶體調入下一個單元。用串實現持久化資料結構的時候,需要將所修改資料所在單元以及之前的資料都複製一遍,稍稍比鏈表更冗餘一些,但是由於串本身的空間效率很高,所以實際上還是非常划算的。

串在實際生活中的應用不少,比如早期 Windows 的檔案系統 FAT32 和 NTFS,每個檔案在磁碟上就是組織為一塊塊資料群組成的串,但是串跟鏈表的缺點很像,它們都缺乏隨機訪問資料的能力。有沒有一種資料結構能在GetSetPushBackRemoveBack上都表現出相當好的時間效率呢?其中一種常見的解決方案是平衡樹。

平衡樹

平衡樹是一系列資料結構的統稱,它包括各種平衡二叉樹,如 AVL 樹、紅/黑樹狀結構等,也包括常用的多叉平衡樹如 B+ 型樹狀結構、 B- 樹等。由於這些基本的資料結構不是本文的重點,本文不會對其具體實現進行逐個詳細的介紹,如下是一棵紅/黑樹狀結構的樣本。

以平衡二叉為例,一般的二叉樹都可以保證GetSetInsertRemove 等操作具備 $O(\log N)$ 的最差時間複雜度,在很多情況下已經非常優秀,最重要的一點是一些平衡二叉樹每次操作最多修改 $O(\log N)$ 個內部節點。這啟發我們,如果我們把所有這些修改節點的操作變為複製,那麼就能在不改變原來資料的情況下,獲得新的資料的一個快照!使用這種方式,我們可以在沒有明顯時間效率損失的情況下極大程度地複用原來空間。

事實上,平衡樹確實是非常流行的持久化資料結構實現方案,在很多情況下它的時間效率都令人滿意。然而,平衡樹的空間效率相當低下,拿一般的平衡二叉樹來說,每個資料節點至少包含一個資料欄位和兩個指標欄位,還可能需要其他欄位儲存資訊以便於獲得快速平衡二叉樹的能力,因此它們的空間效率一般最多在 30% 左右,在有些情況下並不能讓人滿意。

Vector trie

終於來到了我們要介紹的重點: Vector trie 。Vector trie 可以看作將前述的幾種資料結構的思路相結合的產物,首先我們可以觀察到如下兩點:

  1. 串資料結構雖然具有較好的空間效率,但是卻缺乏隨機訪問的時間效率
  2. 樹資料結構雖然有較好的操作效能,但是空間效率和順序訪問的時間效率較差

那麼如果我們將兩種資料結構結合起來是否能構造一個在兩方面都表現優秀的資料結構呢?答案是肯定的。將兩種資料結構結合起來的是一種新的資料結構 trie (首碼樹)。關於首碼樹的特點這裡不再贅述,不瞭解的讀者可以先查詢 Wikipedia 上的介紹。是一個 Vector trie 的。

可以看到,在這種資料結構當中,所有的資料都儲存在樹的葉子節點,因此樹的最下一層葉子節點實際上可以被看成是串,唯一的區別是,不同於串使用末尾的指標指向下一個資料單元,Vector trie 使用 Trie樹結構作為每個資料節點的索引。在 Vector trie 當中,每次檢索都從根開始,依次經過多個中間節點到達葉子節點並獲得資料。

在實際使用中,一個內部節點的子節點被組織成數組,那麼我們就可以方便地使用Index 二進位作為 Trie 查詢的依據,以一個固定寬度的視窗依次獲得應該由當前節點進入哪個子節點。如所示,我們以兩位為單位,依次由根訪問到葉子節點,最終到達目的資料所在的位置(為簡便起見,大多數 Trie 節點被省略)。

在第一張圖中我們使用的每個內部節點有兩個孩子節點,因此實際上退化成了二叉樹,這樣幾個基本操作的時間複雜度都在$O(\log N)$。 在實際實現中,Vector trie 一般使用有 32 個分支的內部節點,整個樹的結構更加扁平化,操作的時間效率也更高——一般來說為 $O(\log_{32} N)$,考慮到一般的順序儲存結構的最大容量只有$2^{32}$,因此在 Vector trie 上進行的各項操作的時間複雜度可以認為是 $O(7)$也就是常數時間的的操作。當然,$O(\log_{32} N) \neq O(1)$,但是很多 Vector trie 的實現為了宣傳的目的,都自詡為常數時間的時間複雜度,這也給初學者造成了一定的困惑。

揭示了 Vector trie 如何?持久化,和一般的樹結構一樣,每次修改操作的時候,我們複製從根到葉子節點的路徑而不是直接修改它們,這樣從兩個根我們就可以訪問到對資料不同時刻的兩個快照。

Vector trie 實現持久化資料結構的基本原理由此就介紹清楚了,但是在實際為了進一步進行效能的最佳化還會做一些諸如Tail 節點、Transient 實現等最佳化,這些內容將會留在以後進一步介紹。

那麼 Vector trie 的時間和空間效率如何?根據Persistent Vector Performance 這篇部落格的介紹,對於 GetSet 等操作,Vector trie 確實跟一般宣傳的相似,相比簡單的 Array 只有一個接近常數層級的放大。而如果利用 Transient 最佳化,在PushBack等操作上甚至有超越 Array 的趨勢。更進一步,經過 Benchmark 所選擇的32 這個分支係數,也讓 Vector trie 可以在常見 CPU 結構的 Cache 系統中表現出優異的順序訪問效能。另一方面,在空間使用上Vector trie 平均有一個接近甚至超過 90% 的空間效率,令人十分印象深刻。由此可見 Vector trie是一種理想的用於實現持久化的資料結構。

實際上,包括 Clojure、Scala 在內的多種程式設計語言都選擇了這種資料結構作為持久化數組的實現。同樣,Vector trie 的索引結構也很接近一些檔案系統對檔案的索引結構,因此也就可以方便的被應用於實現檔案系統的 Snapshot和 Copy on Write 功能。

Vector trie 和普通的 Array 一樣,在 InsertSplice 等操作上時間效率很低,這是它主要的問題之一。

總結

本文介紹了函數式編程中常見的持久化資料結構的優點和常見應用,並以持久化數組為例,逐步探討了幾種實現思路。其中 LinkedList 很適合用來實現持久化棧,而平衡樹和 Vector trie 在實現持久化數組上各有優勢。我們最後選擇了 Vector trie 做為我們將要使用 Golang 實現的對象。

當然,常用來實現持久化資料結構的方法不僅限於這些,本文尚未涉及到的一種更進階的資料結構是Finger Tree,這種資料結構在 Haskell 程式設計語言的部分庫中得到了應用。

按照計劃,下一篇部落格將會介紹不帶持久化功能的 Vector trie 的簡單實現過程,再之後將會給出vector trie 實現持久化功能的過程並介紹 Transient 的實現原理。

敬請期待。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.