深入探究Immutable.js的實現機制(一)

顧二凡發表於2018-09-14

本文是我正在更新的深入探究immutable.js系列的第一篇。

深入探究Immutable.js的實現機制(一) 本篇

深入探究Immutable.js的實現機制(二)


Immutable.js 由 Facebook 花費 3 年時間打造,為前端開發提供了很多便利。我們知道 Immutable.js 採用了持久化資料結構,保證每一個物件都是不可變的,任何新增、修改、刪除等操作都會生成一個新的物件,且通過結構共享等方式大幅提高效能。

網上已經有很多文章簡單介紹了 Immutable.js 的原理,但基本都是淺嘗輒止,針對 Clojure 或 Go 中持久化資料結構實現的文章倒是有一些。下面結合多方資料、Immutable.js 原始碼以及我自己的理解,深入一些探究 Immutable.js 實現機制。

本系列文章可能是目前關於 Immutable.js 原理最深入、全面的文章,歡迎點贊收藏σ`∀´)σ。

Immutable.js 部分參考了 Clojure 中的PersistentVector的實現方式,並有所優化和取捨,該系列第一篇的部分內容也是基於它,想了解的可以閱讀這裡(共五篇,這是其一)

簡單的例子

在深入研究前,我們先看個簡單的例子:

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。我們先看一下控制檯的輸出結果,對它的資料結構有個大致的認知(粗略掃一眼就行了):
深入探究Immutable.js的實現機制(一)
可以看到這是一個樹的結構,子節點以陣列的形式放在nodes屬性裡,nodes的最大長度似乎是 32 個。瞭解過 bitmap 的人可能已經猜到了這裡bitmap屬性是做什麼的,它涉及到對樹寬度的壓縮,這些後面會說。

其中一個節點層層展開後長這樣:

深入探究Immutable.js的實現機制(一)
這個ValueNode存的就是一組值了,entry[0]是key,entry[1]是value。

目前大致看個形狀就行了,下面我們會由淺入深逐步揭開它的面紗。(第二篇文章裡會對圖中所有屬性進行解釋)

基本原理

我們先看下維基對於持久化資料結構的定義:

In computing, a persistent data structure is a data structure that always preserves the previous version of itself when it is modified.

通俗點解釋就是,對於一個持久化資料結構,每次修改後我們都會得到一個新的版本,且舊版本可以完好保留。

Immutable.js 用樹實現了持久化資料結構,先看下圖這顆樹:
深入探究Immutable.js的實現機制(一)
假如我們要在g下面插入一個節點h,如何在插入後讓原有的樹保持不變?最簡單的方法當然是重新生成一顆樹:
深入探究Immutable.js的實現機制(一)
但這樣做顯然是很低效的,每次操作都需要生成一顆全新的樹,既費時又費空間,因而有了如下的優化方案:
深入探究Immutable.js的實現機制(一)
我們新生成一個根節點,對於有修改的部分,把相應路徑上的所有節點重新生成,對於本次操作沒有修改的部分,我們可以直接把相應的舊的節點拷貝過去,這其實就是結構共享。這樣每次操作同樣會獲得一個全新的版本(根節點變了,新的a!==舊的a),歷史版本可以完好保留,同時也節約了空間和時間。

至此我們發現,用樹實現持久化資料結構還是比較簡單的,Immutable.js提供了多種資料結構,比如回到開頭的例子,一個map如何成為持久化資料結構呢?

Vector Trie

實際上對於一個map,我們完全可以把它視為一顆扁平的樹,與上文實現持久化資料結構的方式一樣,每次操作後生成一個新的物件,把舊的值全都依次拷貝過去,對需要修改或新增的屬性,則重新生成。這其實就是Object.assign,然而這樣顯然效率很低,有沒有更好的方法呢?

在實現持久化資料結構時,Immutable.js 參考了Vector Trie這種資料結構(其實更準確的叫法是persistent bit-partitioned vector triebitmapped 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
深入探究Immutable.js的實現機制(一)
可以看到,Vector Trie的每個節點是一個陣列,陣列裡有01兩個數,表示一個二進位制數,所有值都存在葉子節點上,比如我們要找001的值時,只需順著0 0 1找下來,即可得到grape。那麼想實現持久化資料結構當然也不難了,比如我們想新增一個5: ‘watermelon’
深入探究Immutable.js的實現機制(一)
可見對於一個 key 全是數字的map,我們完全可以通過一顆Vector Trie來實現它,同時實現持久化資料結構。如果key不是數字怎麼辦呢?用一套對映機制把它轉成數字就行了。 Immutable.js 實現了一個hash函式,可以把一個值轉換成相應數字。

這裡為了簡化,每個節點陣列長度僅為2,這樣在資料量大的時候,樹會變得很深,查詢會很耗時,所以可以擴大陣列的長度,Immutable.js 選擇了32。為什麼不是31?40?其實陣列長度必須是2的整數次冪,這裡涉及到實現Vector Trie時的一個優化,接下來我們先研究下這點。

下面的部分內容對於不熟悉進位制轉換和位運算的人來說可能會相對複雜一些,不過只要認真思考還是能搞通的。

數字分割槽(Digit partitioning)

數字分割槽指我們把一個 key 作為數字對應到一棵字首樹上,正如上節所講的那樣。

假如我們有一個 key 9128,以 7 為基數,即陣列長度是 7,它在Vector Trie裡是這麼表示的:
深入探究Immutable.js的實現機制(一)
需要5層陣列,我們先找到3這個分支,再找到5,之後依次到0。為了依次得到這幾個數字,我們可以預先把9128轉為7進位制的35420,但其實沒有這個必要,因為轉為 7 進位制形式的過程就是不斷進行除法並取餘得到每一位上的數,我們無須預先轉換好,類似的操作可以在每一層上依次執行。

運用進位制轉換相關的知識,我們可以採用這個方法key / radixlevel - 1 % radix得到每一位的數(為了簡便,本文除程式碼外所有/符號皆表示除法且向下取整),其中radix是每層陣列的長度,即轉換成幾進位制,level是當前在第幾層,即第幾位數。比如這裡key9128radix7,一開始level5,通過這個式子我們可以得到第一層的數3

程式碼實現如下:

const RADIX = 7;

function find(key) {
  let node = root; // root是根節點,在別的地方定義了

  // depth是當前樹的深度。這種計算方式跟上面列出的式子是等價的,但可以避免多次指數計算。這個size就是上面的radix^level - 1
  for (let size = Math.pow(RADIX, (depth - 1)); size > 1; size /= RADIX) {
    node = node[Math.floor(key / size) % RADIX];
  }

  return node[key % RADIX];
}複製程式碼

位分割槽(Bit Partitioning)

顯然,以上數字分割槽的方法是有點耗時的,在每一層我們都要進行兩次除法一次取模,顯然這樣並不高效,位分割槽就是對其的一種優化。

位分割槽是建立在數字分割槽的基礎上的,所有以2的整數次冪(2,4,8,16,32…)為基數的數字分割槽字首樹,都可以轉為位分割槽。基於一些位運算相關的知識,我們就能避免一些耗時的計算。

數字分割槽把 key 拆分成一個個數字,而位分割槽把 key 分成一組組 bit。以一個 32 路的字首樹為例,數字分割槽的方法是把 key 以 32 為基數拆分(實際上就是 32 進位制),而位分割槽是把它以 5 個 bits 拆分,因為32 = 25,那我們就可以把 32 進位制數的每一位看做 5 個二進位制位 。實際上就是把 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 BITS = 5;
const WIDTH = 1 << BITS, // 25 = 32
const MASK = WIDTH - 1; // 31,即11111

function find(key) {
  let node = root; 

  for (let bits = (depth - 1) * BITS; bits > 0; bits -= BITS) {
    node = node[(key >>> bits) & MASK];
  }

  return node[key & MASK];
}複製程式碼

這樣我們每次查詢的速度就會得到提升。可以看一張圖進行理解,為了簡化展示,假設我們用了一個4路字首樹,4 = 22,所以用兩位二進位制數分割槽。對於626,查詢過程如下:
深入探究Immutable.js的實現機制(一)
626的二進位制形式是10 01 11 00 10,所以通過上面的位運算方法,我們便可以高效地依次得到1001

原始碼

說了這麼多,我們看一下 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 選擇相應分支。(到這裡我也不由讚歎,短短10行程式碼包含了多少思想呀)

不過它的實現方式與上文所講的有一點不同,上文中對於一個 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),因為是 32 叉樹,所以準確說是O(log32 N)

等等 32 叉樹?這棵樹可不是一般地寬啊,Javascript裡物件可以擁有的key的最大數量一般不會超過232個(ECMA-262第五版裡定義了JS裡由於陣列的長度本身是一個 32 位數,所以陣列長度不應大於 232 - 1 ,JS裡物件的實現相對複雜,但大部分功能是建立在陣列上的,所以在大部分場景下物件裡 key 的數量不會超過 232 - 1。相關討論見這裡。而且假設我們有 232 個值、每個值是一個32bit的 Number ,只算這些值的話總大小也有17g了,前端一般是遠不需要操作這個量級的資料的),這樣就可以把查詢的時間複雜度當做是“O(log32 232)”,差不多就是“O(log 7)”,所以我們可以認為在實際運用中,5bits (32路)的 Vector Trie 查詢的時間複雜度是常數級的,32 叉樹就是用了空間換時間。

空間…這個 32 叉樹佔用的空間也太大了吧?即便只有三層,我們也會有超過32 × 32 × 32 = 32768個節點。當然 Immutable.js 在具體實現時肯定不會傻乎乎的佔用這麼大空間,它對樹的高度和寬度都做了“壓縮”,此外,還對操作效率進行了其它一些優化。相關內容我們在下一篇裡討論。


如果文章裡有什麼問題歡迎指正。


第二篇裡會介紹進一步優化後的不可變資料結構—— HAMT (“壓縮”空間佔用),以及在不可變資料結構中實現“臨時”的可變結構—— Transient ,還有老生常談的對於 hash 衝突的解決方式。


深入探究Immutable.js的實現機制(一) 本篇

深入探究Immutable.js的實現機制(二)



參考:

hypirion.com/musings/und…

io-meter.com/2016/09/03/…

cdn.oreillystatic.com/en/assets/1…

michael.steindorfer.name/publication…

github.com/facebook/im…


相關文章