Persistent and Transient Data Structures in Clojure

此文已由做者張佃鵬受權網易雲社區發佈。
html

歡迎訪問網易雲社區,瞭解更多網易技術產品運營經驗。java


最近在項目中用到了Transient數據結構,使用該數據結構對程序執行效率會有必定的提升。剛剛接觸Transient Data Stuctures,下面將本身關於對其的瞭解總結以下:node

1.clojure的不可變數據特性及存儲方式:數據庫

  clojure中的數據結構具備不可變特性(Persistent),也就是對一個數據結構添加元素、刪除元素、更改元素,返回的是一個新的數據結構,而原來的數據結構不會變:數組

;;;定義一個向量
(def data [1 2 3 4 5])
;;=> #'user/data;;更改向量中的元素,返回新的向量,而原有向量不變
(update data 3 str)
;;=> [1 2 3 "4" 5]
data
;;=> [1 2 3 4 5]

;;給向量增長一個元素,返回新的向量,原有向量的數據結構不變
(assoc data 5 6)
;;=> [1 2 3 4 5 6]
data
;;=> [1 2 3 4 5]複製代碼

  以上對data=[1 2 3 4 5]做增長和更改操做,data數據自己都沒有變,而是生成了新的數據,這樣的數據不可變性很是有利於數據的安全性,不會出現更改對象帶來的反作用。這樣的特性必然對數據的存儲方式有很高的要求,clojure中的數據結構採起idea hash trees(lampwww.epfl.ch/papers/idea…sass

  vector中的全部元素都放在Leaf node中,而Internal node不存放元素,只是存放指向兒子節點的指針,用於尋找葉子節點,其中有個Head指針指向樹的根節點,該Head指針存放着數據爲該vector的大小,根據vector的size,咱們即可以沿着Internal node找到存放在任何序號的元素。   clojure中的vector數據結構具備不可變性,因此爲了減小複製的成本,clojure對其存儲採起高效的共享模式:安全

;;
(def brown [0 1 2 3 4 5 6 7 8])
(def blue (assoc brown 5 'beef))複製代碼

  上面定義了一個brown的向量,而後更改brown的第6個元素生成新的向量blue,brown和blue之間的存儲結構以下:bash

  如上圖所示,在brown數據結構上更改元素後,原有的brown數據結構從其head指針開始徹底沒有改變,每個vector都有本身的head指針,所以blue必須構造本身的head指針,在構造的過程當中,儘可能共享brown已有的數據結構,只是新增長了一個被更改的葉子節點,減小了不必的存儲空間的浪費。   這樣理想的存儲方式很是有利於不可變數據結構增刪改操做,時間複雜度是O(log2 n),實際存儲中,clojure採起不是2個子節點的存儲方式,而是32個子節點的存儲方式,相應的時間複雜度是 O(log32 n)。咱們知道對於n很是大的狀況下,O(log32 n)和O(log n)的複雜度是同樣的,可是對於相對較小的數據來講,O(log32 n)能夠近似O(1),這也是爲何clojure爲何說本身對於vector的增刪改操做接近常數時間的緣由。數據結構


2.爲何要有Transient Data Structures:併發

  儘管vector數據結構的存儲方式效率已經很高了,可是它依然須要頻繁的分配存儲空間和對存儲空間進行垃圾回收,好比咱們執行如下操做:

;;以此將0到9加入到一個vector中
    (reduce conj [] (range 10))
    ;;=> [0 1 2 3 4 5 6 7 8 9]複製代碼

  在將這10個數依次加入到數組中,每加入一個數便生產一個帶有head指針的新的vector,又由於前面一個vector已經不會再被用到,系統須要對其空間進行垃圾回收,雖然先後數據結構中的存儲空間有必定的共享,可是這樣的操做仍是會有必定時間的浪費,對於效率要求比較高的代碼難以接受。   所以爲了提升效率,clojure增長了一種Transient數據類型,transient使clojure的數據結構能夠改變,transient不只可使用在vector中,還能夠在set和map中使用,可是不能用於list中。下面經過更新一個vector中的元素操做來對比transient與persistent數據類型的區別,將[1 2 3 4 5 6]更新爲[1 2 F 4 5 6],兩種不一樣數據類型之間的變化過程以下:

  persistent更新操做後,具備兩個head指針,也就兩個不一樣的vector,而transient更新操做後,只是在原有數據結構的基礎上,更改了一個葉子節點,head指針不變,原有的vector中存放的內容發生了改變,因此transient在必定程度上減小了存儲空間的浪費,提升了代碼執行效率。


3.Transient Data Structures的相關操做函數:

  對於只讀操做,由於不會改變數據內容,transient data和persistent data共享一套只讀操做函數,好比:nth, get, count等函數,可是對於更改數據的函數,會有另一套操做函數,下面是關於transient data structures數據結構相關操做函數的詳解:

  • transient函數:

  該函數是將一個persistent數據格式轉換爲transient的數據格式,該操做的時間複雜度接近於O(1),若是咱們對轉換後的作更改操做,不會影響原有數據內容,原有數據依然是persistent。

  • persistent!函數:

  該函數剛好與transient函數相反,將一個transient的數據格式轉換爲persistent格式,不一樣的是:轉換後會影響原有的transient數據,使原有的transient數據變爲不可用:

(def a [1 2 3])
    ;;=> #'insight.main/a
    ;;用transient函數生成transient格式的數據
    (def a' (transient a)) ;;=> #'insight.main/a' ;;獲取其中的函數 (nth a' 2)
    ;;=> 3
    ;;增長數據
    (conj! a' 4) ;;用persistent!函數返回不可變數據格式內容 (persistent! a')
    ;=> [1 2 3 4]
    ;;這個時候原有的a'數據將變爲不可用數據,對其讀寫都會拋出異常 (nth a' 2)
    IllegalAccessError Transient used after persistent! call  clojure.lang.PersistentVector$TransientVector.ensureEditable (PersistentVector.java:548)
    (conj! a' 4) IllegalAccessError Transient used after persistent! call clojure.lang.PersistentVector$TransientVector.ensureEditable (PersistentVector.java:548)複製代碼
  • 相關「寫」操做函數:

  對於transient相關「寫」操做函數有:assoc!/conj!/disassoc!/pop!/disj!,這些寫操做函數只是在對於persistent相關函數後加上「!」,他們的函數參數格式與去掉「!」後的函數如出一轍,下面列舉了相關操做代碼:

;;將0到9以此加入到一個transient類的vector中,每次加入一個元素時,不會創建新的vector,
    (loop [i 0 v (transient [])]
      (if (< i 10)
        (recur (inc i) (conj! v i))
        (persistent! v)))
    ;;=> [0 1 2 3 4 5 6 7 8 9]複製代碼
  • 特別須要注意的地方:

  雖然在增長元素時,是在原有結構上增長元素,可是這也並不意味着原有數據結構的頭指針(Head)必定不變,若是增長的元素特別多的狀況下,須要重新調整數據層次結構,那麼頭指針就會發生改變,而原有數據結構的頭指針與該數據結構的名字一一對應,因此對該transient數據進行操做時,必定要將操做後的數據賦值給原有數據的名字:

;;連續8次給t添加key-value對,返回結果是正確的
    (let [t (transient {})]
      (dotimes [i 8]
        (assoc! t i i))
      (persistent! t))
    ;;=> {0 0, 1 1, 2 2, 3 3, 4 4, 5 5, 6 6, 7 7}
    ;;當連續9次給t添加key-value對時,便返回錯誤的結果,由於當9次添加元素時,該map的頭指針發生了變化,因此新的數據內容不是之前的t
    (let [t (transient {})]
      (dotimes [i 9]
        (assoc! t i i))
      (persistent! t))
    ;;=> {0 0, 1 1, 2 2, 3 3, 4 4, 5 5, 6 6, 7 7}複製代碼

  正確的使用添加方式應該以下:

;;可使用reduce函數,每次添加元素是在assoc!函數的返回結果上進行添加,這樣便會返回正確的內容
    (persistent!
      (reduce (fn [t i] (assoc! t i i))
              (transient {})
              (range 10)))
    ;;=>{0 0, 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 5 5, 8 8}複製代碼


4.clojure.core庫中使用transient data相關函數及其效率:

  在什麼狀況下會適用Transient Data Structurese呢?首先,咱們只在意更改後的數據,原始數據對咱們來講不重要,也不會再被用到。其次,Transient Data Structures適用於單線程的程序,由於每一個線程共享相同的數據時,同時更改會形成併發問題,這也是clojure爲何採用persistent數據結構的緣由之一;最後,瞬態數據結構主要爲了提升代碼效率而設計,因此對於屢次連續添加元素,能夠考慮使用transient數據格式。   通過對clojure.core庫中相關函數定義源碼的搜索,找出了該中使用了transient data structure相關函數有:set函數/into函數/mapv函數/filterv函數/group-by函數/frequencies函數,咱們能夠發現這些函數的特色都是對一個序列進行更改操做,可是並不關心原始數據的內容,如下咱們用criterium.core庫中的quick-bench函數來測試代碼運行時間,從而證實transient data的效率:

;;into函數的效率提升效果:
    ;;用concat函數合併兩個一個vector和list,將合併結果轉換爲vector,平均消耗時間爲:4.354145 µs
    (quick-bench (vec (concat [1 2 3] (range 100 200))))
    ;;Evaluation count : 154098 in 6 samples of 25683 calls.
    ;;Execution time mean : 4.354145 µs

    ;;直接使用into函數將一個list函數中的內容插入到vector中,平均消耗時間只須要1.549213 µs
    (quick-bench (into [1 2 3] (range 100 200)))
    ;;Evaluation count : 382944 in 6 samples of 63824 calls.
    ;;Execution time mean : 1.549213 µs

    ;;mapv函數的效率提升效果:

    ;;咱們本身定義個mapv'函數與mapv函數操做效果同樣 (defn mapv' [f coll]
      (loop [result [] r coll]
        (if (nil? (seq r))
          result
          (recur (conj result (f (first r))) (rest r))
          )))

    ;;使用咱們本身定義的函數,平均消耗時間爲794.484682 µs
    (quick-bench (mapv' inc (range 10000))) ;;Evaluation count : 780 in 6 samples of 130 calls. ;;Execution time mean : 794.484682 µs ;;使用系統的mapv函數,平均消耗時間爲197.386935 µs (quick-bench (mapv inc (range 10000))) ;;Evaluation count : 3090 in 6 samples of 515 calls. ;;Execution time mean : 197.386935 µs ;;使用map和vec操做函數,平均消耗時間爲418.949804 µs (quick-bench (vec (map inc (range 10000)))) ;;Evaluation count : 1482 in 6 samples of 247 calls. ;;Execution time mean : 418.949804 µs複製代碼

  經過以上對函數的對比,咱們發現,transient函數在操做大數據的狀況下,確實會給咱們節省不少時間,因此在平時寫代碼時必定要養成好的習慣:爲了提升代碼效率,儘可能使用以上提過的函數。


5.總結:

  今天主要對剛剛學習的transient data structures進行了概括總結,瞬態數據結構對於代碼效率的提升有很大的做用,該數據類型能夠應用到map,vector,map上,若是咱們對原始數據絕不關心,則關心改變後的數據,尤爲是連續屢次的進行這樣的操做,那麼咱們就能夠考慮使用瞬態數據結構,clojure.core中有些函數用到了瞬態數據結構,因此咱們儘可能在編碼時使用這些函數來提升代碼效率。


免費體驗雲安全(易盾)內容安全、驗證碼等服務

更多網易技術、產品、運營經驗分享請點擊




相關文章:
【推薦】 基於Redis+Kafka的首頁曝光過濾方案
【推薦】 數據庫路由中間件MyCat - 使用篇(6)
【推薦】 abtest-system後臺系統設計與搭建

相關文章
相關標籤/搜索