Immutable.js 採用了持久化數據結構
和結構共享
,保證每個對象都是不可變的,任何添加、修改、刪除等操做都會生成一個新的對象,且經過結構共享
等方式大幅提升性能。
網上已經有不少文章簡單介紹了 Immutable.js 的原理,但基本都是淺嘗輒止,我也是搜了好久沒找到針對 Immutable.js 原理的相對深刻詳細的文章,中英文都沒有,針對 Clojure 或 Go 中持久化數據結構實現的文章卻是有一些。本文會集合多方資料以及我本身的一些理解,深刻一些探究 Immutable.js 實現機制。文章可能會分2-3篇完成。javascript
Immutable.js 部分參考了 Clojure 中的
PersistentVector
的實現方式,並有所優化和取捨,本文的一些內容也是基於它,想了解的能夠閱讀
這裏(共五篇,這是第一篇)
在深刻研究前,咱們先看個簡單的例子:java
let map1 = Immutable.Map({}); for (let i = 0; i < 800; i++) { map1 = map1.set(Math.random(), Math.random()); } console.log(map1);
這段代碼前後往map裏寫入了800對隨機生成的key和value。咱們先看一下控制檯的輸出結果,對它的數據結構有個大體的認知(粗略掃一眼就好了):node
能夠看到這是一個樹的結構,子節點以數組的形式放在nodes
屬性裏,nodes的最大長度彷佛是32個。這裏的bitmap
涉及到對於樹寬度的壓縮,這些後面會說。
其中一個節點層層展開後長這樣:git
這個ValueNode
存的就是一組值了,entry[0]
是key,entry[1]
是value。github
大體看個形狀就好了,下面來由淺入深研究一下。數組
咱們先看下維基對於持久化數據結構
的定義:數據結構
In computing, a persistent data structure is a data structure that always preserves the previous version of itself when it is modified.
通俗點解釋就是,對於一個持久化數據結構
,每次修改後咱們都會獲得一個新的版本,且舊版本能夠無缺保留。app
Immutable.js 用樹實現了持久化數據結構
,先看下圖這顆樹:dom
假如咱們要在g
下面插入一個節點h
,如何在插入後讓原有的樹保持不變?最簡單的方法固然是從新生成一顆樹:函數
但這樣作顯然是很低效的,每次操做都須要生成一顆全新的樹,既費時又費空間,於是有了以下的優化方案:
咱們新生成一個根節點,對於有修改的部分,把相應路徑上的全部節點從新生成,對於本次操做沒有修改的部分,咱們能夠直接把相應的舊的節點拷貝過去,這其實就是結構共享
。這樣每次操做一樣會得到一個全新的版本(根節點變了,新的a
!==舊的a
),歷史版本能夠無缺保留,同時也節約了空間和時間。
至此咱們發現,用樹實現持久化數據結構
仍是比較簡單的,Immutable.js提供了多種數據結構,好比回到開頭的例子,一個map如何成爲持久化數據結構
呢?
實際上對於一個map,咱們徹底能夠把它視爲一顆扁平的樹,與上文實現持久化數據結構
的方式同樣,每次操做後生成一個新的對象,把舊的值全都依次拷貝過去,對須要修改或添加的屬性,則從新生成。這其實就是Object.assign
,然而這樣顯然效率很低,有沒有更好的方法呢?
在實現持久化數據結構
時,Immutable.js 參考了Vector Trie
這種數據結構(其實更準確的叫法是persistent bit-partitioned vector trie
或bitmapped vector trie
,這是Clojure裏使用的一種數據結構,Immutable.js 裏的相關實現與其很類似),咱們先了解下它的基本結構。
假如咱們有一個 map ,key 全都是數字(固然你也能夠把它理解爲數組){0: 'banana', 1: 'grape', 2: 'lemon', 3: 'orange', 4: 'apple'}
,爲了構造一棵二叉Vector Trie
,咱們能夠先把全部的key轉換爲二進制的形式:{'000': 'banana', '001': 'grape', '010': 'lemon', '011': 'orange', '100': 'apple'}
,而後以下圖構建Vector Trie
:
能夠看到,Vector Trie
的每一個節點是一個數組,數組裏有0
和1
兩個數,表示一個二進制數,全部值都存在葉子節點上,好比咱們要找001
的值時,只需順着0
0
1
找下來,便可獲得grape
。那麼想實現持久化數據結構
固然也不難了,好比咱們想添加一個5: 'watermelon'
:
可見對於一個 key 全是數字的map,咱們徹底能夠經過一顆Vector Trie
來實現它,同時實現持久化數據結構
。若是key不是數字怎麼辦呢?轉成數字就好了。 Immutable.js 實現了一個hash函數,能夠把一個值轉換成相應數字。
這裏爲了簡化,每一個節點數組長度僅爲2,這樣在數據量大的時候,樹會變得很深,查詢會很耗時,因此能夠擴大數組的長度,Immutable.js 選擇了32。爲何不是31?40?其實數組長度必須是2的整數次冪,這裏涉及到實現Vector Trie
時的一個優化,接下來咱們先研究下這點。
數字分區
指咱們把一個 key 做爲數字對應到一棵前綴樹上,正如上節所講的那樣。
假如咱們有一個 key 9128
,以 7 爲基數,即數組長度是 7,它在Vector Trie
裏是這麼表示的:
須要5層數組,咱們先找到3
這個分支,再找到5
,以後依次到0
。爲了依次獲得這幾個數字,咱們能夠預先把9128
轉爲7進制的35420
,但其實沒有這個必要,由於轉爲 7 進制形式的過程就是不斷進行除法並取餘獲得每一位上的數,咱們無須預先轉換好,相似的操做能夠在每一層上依次執行。
運用進制轉換相關的知識,咱們能夠採用這個方法key / radixlevel - 1 % radix
獲得每一位的數(爲了簡便,本文除代碼外全部/
符號皆表示除法且向下取整),其中radix
是每層數組的長度,即轉換成幾進制,level
是當前在第幾層,即第幾位數。好比這裏key
是9128
,radix
是7
,一開始level
是5
,經過這個式子咱們能夠獲得第一層的數3
。
代碼實現以下:
const RADIX = 7; function find(key) { let node = root; // root是根節點,在別的地方定義了 // depth是當前樹的深度。這種計算方式跟上面列出的式子是等價的,但能夠避免屢次指數計算 for (let size = Math.pow(RADIX, (depth - 1)); size > 1; size /= RADIX) { node = node[Math.floor(key / size) % RADIX]; } return node[key % RADIX]; }
顯然,以上數字分區
的方法是有點耗時的,在每一層咱們都要進行兩次除法一次取模,顯然這樣並不高效,位分區
就是對其的一種優化。位分區
其實是數字分區
的一個子集,全部以2的整數次冪(2,4,8,16,32...)爲基數的數字分區
前綴樹,均可以轉爲位分區
。基於一些位運算相關的知識,咱們就能避免一些耗時的計算。數字分區
把 key 拆分紅一個個數字,而位分區
把 key 分紅一組組 bit。好比一個 32 路的前綴樹,數字分區
的方法是把 key 以 32 爲基數拆分(實際上就是32進制),而位分區
是把它以 5bit 拆分,實際上就是把 32 進制數的每一位看作 5 個 bit ,或者說把 32 進制數看作2進制進行操做,這樣本來的不少計算就能夠用更高效的位運算的方式代替。由於如今基數是 32,即radix
爲 32,因此前面的式子如今是key / 32level - 1 % 32
,而 32 又能夠寫做25
,那麼該式子能夠轉成這樣key / 25 × (level - 1) % 25
。根據位運算相關的知識咱們知道a / 2n === a >>> n
、a % 2n === a & 2n-1
。
其實舉個例子最好理解:好比數字666666
的二進制形式是10100 01011 00001 01010
,這是一個20位的二進制數。若是咱們要獲得第二層那五位數01011
,咱們能夠先把它右移>>>
(左側補0)10位,獲得00000 00000 10100 01011
,再&
一下00000 00000 00000 11111
,就獲得了01011
。
這樣咱們能夠獲得下面的代碼:
const SHIFT = 5; const WIDTH = 1 << SHIFT, // 32 const MASK = WIDTH - 1; // 31,即11111 function find(key) { let node = root; for (let shift = (depth - 1) * SHIFT; shift > 0; shift -= SHIFT) { node = node[(key >>> shift) & MASK]; } return node[key & MASK]; }
這樣咱們每次查找的速度就會獲得提高。能夠看一張圖進行理解,爲了簡化展現,假設咱們只有2位分區即4路的前綴樹,對於626
,咱們的查找過程以下:
626
的二進制形式是10 01 11 00 10
,因此經過以上的位運算,咱們便依次獲得了10
、01
...
說了這麼多,咱們看一下 Immutable.js 的源碼吧。雖然具體的代碼較長,但主要看一下查找的部分就夠了,這是Vector Trie
的核心。
get(shift, keyHash, key, notSetValue) { if (keyHash === undefined) { keyHash = hash(key); } const idx = (shift === 0 ? keyHash : keyHash >>> shift) & MASK; const node = this.nodes[idx]; return node ? node.get(shift + SHIFT, keyHash, key, notSetValue) : notSetValue; }
能夠看到, Immutable.js 也正是採用了位分區的方式,經過位運算獲得當前數組的 index 選擇相應分支。
不過它的實現方式與上文所講的有一點不一樣,上文中對於一個 key ,咱們是「正序」存儲的,好比上圖那個626
的例子,咱們是從根節點往下依次按照10 01 11 00 10
去存儲,而 Immutable.js 裏則是「倒序」,按照10 00 11 01 10
存儲。因此經過源碼這段你會發現 Immutable.js 查找時先獲得的是 key 末尾的 SHIFT 個 bit ,而後再獲得它們以前的 SHIFT 個 bit ,依次往前下去,而前面咱們的代碼是先獲得 key 開頭的 SHIFT 個 bit,依次日後。
至於爲何這麼作,我一開始也沒理解,但仔細想一想這的確是最好的一種方式了,用這種方式的根本緣由是key的大小(二進制長度)不固定,不固定的緣由又是爲了減少計算量,同時也能減少空間佔用並讓樹更「平衡」。仔細思考一下的話,你應該能理解。關於這塊內容,若是有時間我會放到以後的文章裏說。
由於採用了結構共享
,在添加、修改、刪除操做後,咱們避免了將 map 中全部值拷貝一遍,因此特別是在數據量較大時,這些操做相比Object.assign
有明顯提高。
然而,查詢速度彷佛減慢了?咱們知道 map 里根據 key 查找的速度是O(1)
,這裏因爲變成了一棵樹,查詢的時間複雜度變成了O(log N)
,準確說是O(log32 N)
。
等等, 32 叉樹?這棵樹可不是通常地寬啊,Javascript裏對象能夠擁有的key的最大數量通常不會超過232
個(ECMA-262第五版裏定義了JS裏因爲數組的長度自己是一個 32 位數,因此數組長度不該大於 232 - 1 ,JS裏對象的實現相對複雜,但大部分功能是創建在數組上的,因此在大部分場景下對象裏 key 的數量不會超過 232 - 1。相關討論見這裏),這樣就能夠把查找的時間複雜度當作是「O(log32 232)
」,差很少就是「O(log 7)
」,因此咱們能夠認爲在實際運用中,5bit (32路)的 Vector Trie 查詢的時間複雜度是常數級的,32 叉樹就是用了空間換時間。
空間...這個 32 叉樹佔用的空間也太大了吧?即使只有三層,咱們也會有超過32 × 32 × 32 = 32768
個節點。固然 Immutable.js 在具體實現時確定不會傻乎乎的佔用這麼大空間,它對樹的高度和寬度都作了「壓縮」,此外,還對操做效率進行了其它一些優化,好比對 list 進行了「tail優化」。相關內容下一篇再討論。
若是文章裏有什麼問題歡迎指正。
該文章是我正在更新的深刻探究immutable.js系列的第一篇,我花了很多功夫才完成這篇文章,若是對你有幫助,但願能點個贊~
而後也請期待下一篇吧~預計一共會分2-3篇寫完。該文章裏有不懂的地方不要緊,以後的文章會討論更多內容,同時會有助於對該文章的理解。
第二篇更新到這裏了:https://juejin.im/post/5ba4a6...
參考:
https://hypirion.com/musings/...
https://io-meter.com/2016/09/...
https://cdn.oreillystatic.com...
https://michael.steindorfer.n...
https://github.com/facebook/i...