# 第五章:集合
結構化的集合與數據訪問對于任何JS程序來說都是一個關鍵組成部分。從這門語言的最開始到現在,數組和對象一直都是我們創建數據結構的主要機制。當然,許多更高級的數據結構作為用戶方的庫都曾建立在這些之上。
到了ES6,最有用(而且優化性能的!)的數據結構抽象中的一些已經作為這門語言的原生組件被加入了進來。
我們將通過檢視?*類型化數組(TypedArrays)*?來開始這一章,技術上講它與幾年前的ES5是同一時期的產物,但是僅僅作為WebGL的同伴被標準化了,而不是作為JavaScript自身的一部分。到了ES6,這些東西已經被語言規范直接采納了,這給予了它們頭等的地位。
Map就像對象(鍵/值對),但是與僅能使用一個字符串作為鍵不同的是,你可以使用任何值 —— 即使是另一個對象或map!Set與數組很相似(值的列表),但是這些值都是唯一的;如果你添加一個重復的值,它會被忽略。還有與之相對應的weak結構(與內存/垃圾回收有關聯):WeakMap和WeakSet。
## 類型化數組(TypedArrays)
正如我們在本系列的?*類型與文法*?中講到過的,JS確實擁有一組內建類型,比如`number`和`string`。看到一個稱為“類型化的數組”的特性,可能會誘使你推測它意味著一個特定類型的值的數組,比如一個僅含字符串的數組。
然而,類型化數組其實更多的是關于使用類似數組的語義(索引訪問,等等)提供對二進制數據的結構化訪問。名稱中的“類型”指的是在大量二進制位(比特桶)的類型之上覆蓋的“視圖”,它實質上是一個映射,控制著這些二進制位是否應當被看作8位有符號整數的數組,還是被看作16位有符號整數的數組,等等。
你怎樣才能構建這樣的比特桶呢?它被稱為一個“緩沖(buffer)”,而你可以用`ArrayBuffer(..)`構造器直接地構建它:
```source-js
var buf = new ArrayBuffer( 32 );
buf.byteLength; // 32
```
現在`buf`是一個長度為32字節(256比特)的二進制緩沖,它被預初始化為全`0`。除了檢查它的`byteLength`屬性,一個緩沖本身不會允許你進行任何操作。
提示:?有幾種web平臺特性都使用或返回緩沖,比如`FileReader#readAsArrayBuffer(..)`,`XMLHttpRequest#send(..)`,和`ImageData`(canvas數據)。
但是在這個數組緩沖的上面,你可以平鋪一層“視圖”,它就是用類型化數組的形式表現的。考慮如下代碼:
```source-js
var arr = new Uint16Array( buf );
arr.length; // 16
```
`arr`是一個256位的`buf`緩沖在16位無符號整數的類型化數組的映射,意味著你得到16個元素。
### 字節順序
明白一個事實非常重要:`arr`是使用JS所運行的平臺的字節順序設定(大端法或小端法)被映射的。如果二進制數據是由一種字節順序創建,但是在一個擁有相反字節數序的平臺被解釋時,這就可能是個問題。
字節順序指的是一個多字節數字的低位字節(8個比特位的集合) —— 比如我們在早先的代碼段中創建的16位無符號整數 —— 是在這個數字的字節序列的左邊還是右邊。
舉個例子,讓我們想象一下用16位來表示的10進制的數字`3085`。如果你只有一個16位數字的容器,無論字節順序怎樣它都將以二進制表示為`0000110000001101`(十六進制的`0c0d`)。
但是如果`3085`使用兩個8位數字來表示的話,字節順序就像會極大地影響它在內存中的存儲:
* `0000110000001101`?/?`0c0d`?(大端法)
* `0000110100001100`?/?`0d0c`?(小端法)
如果你從一個小端法系統中收到表示為`0000110100001100`的`3085`,但是在一個大端法系統中為它上面鋪一層視圖,那么你將會看到值`3340`(10進制)和`0d0c`(16進制)。
如今在web上最常見的表現形式是小端法,但是絕對存在一些與此不同的瀏覽器。你明白一塊二進制數據的生產者和消費者的字節順序是十分重要的。
在MDN上有一種快速的方法測試你的JavaScript的字節順序:
```source-js
var littleEndian = (function() {
var buffer = new ArrayBuffer( 2 );
new DataView( buffer ).setInt16( 0, 256, true );
return new Int16Array( buffer )[0] === 256;
})();
```
`littleEndian`將是`true`或`false`;對大多數瀏覽器來說,它應當返回`true`。這個測試使用`DataView(..)`,它允許更底層,更精細地控制如何從你平鋪在緩沖上的視圖中訪問二進制位。前面代碼段中的`setInt16(..)`方法的第三個參數告訴`DataView`,對于這個操作你想使用什么字節順序。
警告:?不要將一個數組緩沖中底層的二進制存儲的字節順序與一個數字在JS程序中被暴露時如何被表示搞混。舉例來說,`(3085).toString(2)`返回`"110000001101"`,它被假定前面有四個`"0"`因而是大端法表現形式。事實上,這個表現形式是基于一個單獨的16位視圖的,而不是兩個8位字節的視圖。上面的`DataView`測試是確定你的JS環境的字節順序的最佳方法。
### 多視圖
一個單獨的緩沖可以連接多個視圖,例如:
```source-js
var buf = new ArrayBuffer( 2 );
var view8 = new Uint8Array( buf );
var view16 = new Uint16Array( buf );
view16[0] = 3085;
view8[0]; // 13
view8[1]; // 12
view8[0].toString( 16 ); // "d"
view8[1].toString( 16 ); // "c"
// 調換(好像字節順序一樣!)
var tmp = view8[0];
view8[0] = view8[1];
view8[1] = tmp;
view16[0]; // 3340
```
類型化數組的構造器擁有多種簽名。目前我們展示過的只是向它們傳遞一個既存的緩沖。然而,這種形式還接受兩個額外的參數:`byteOffset`和`length`。換句話講,你可以從`0`以外的位置開始類型化數組視圖,也可以使它的長度小于整個緩沖的長度。
如果二進制數據的緩沖包含規格不一的大小/位置,這種技術可能十分有用。
例如,考慮一個這樣的二進制緩沖:在開頭擁有一個2字節數字(也叫做“字”),緊跟著兩個1字節數字,然后跟著一個32位浮點數。這是你如何在同一個緩沖,偏移量,和長度上使用多視圖來訪問數據:
```source-js
var first = new Uint16Array( buf, 0, 2 )[0],
second = new Uint8Array( buf, 2, 1 )[0],
third = new Uint8Array( buf, 3, 1 )[0],
fourth = new Float32Array( buf, 4, 4 )[0];
```
### 類型化數組構造器
除了前一節我們檢視的`(buffer,[offset, [length]])`形式之外,類型化數組的構造器還支持這些形式:
* [constructor]`(length)`:在一個長度為`length`字節的緩沖上創建一個新視圖
* [constructor]`(typedArr)`:創建一個新視圖和緩沖,并拷貝`typedArr`視圖中的內容
* [constructor]`(obj)`:創建一個新視圖和緩沖,并迭代類數組或對象`obj`來拷貝它的內容
在ES6中可以使用下面的類型化數組構造器:
* `Int8Array`(8位有符號整數),`Uint8Array`(8位無符號整數)
* `Uint8ClampedArray`(8位無符號整數,每個值都被卡在`0 - 255`范圍內)
* `Int16Array`(16位有符號整數),`Uint16Array`(16位無符號整數)
* `Int32Array`(32位有符號整數),`Uint32Array`(32位無符號整數)
* `Float32Array`(32位浮點數,IEEE-754)
* `Float64Array`(64位浮點數,IEEE-754)
類型化數組構造器的實例基本上和原生的普通數組是一樣的。一些區別包括它有一個固定的長度并且值都是同種“類型”。
但是,它們共享絕大多數相同的`prototype`方法。這樣一來,你很可能將會像普通數組那樣使用它們而不必進行轉換。
例如:
```source-js
var a = new Int32Array( 3 );
a[0] = 10;
a[1] = 20;
a[2] = 30;
a.map( function(v){
console.log( v );
} );
// 10 20 30
a.join( "-" );
// "10-20-30"
```
警告:?你不能對類型化數組使用沒有意義的特定`Array.prototype`方法,比如修改器(`splice(..)`,`push(..)`,等等)和`concat(..)`。
要小心,在類型化數組中的元素被限制在它被聲明的位長度中。如果你有一個`Uint8Array`并試著向它的一個元素賦予某些大于8為的值,那么這個值將被截斷以保持在相應的位長度中。
這可能造成一些問題,例如,如果你試著對一個類型化數組中的所有值求平方。考慮如下代碼:
```source-js
var a = new Uint8Array( 3 );
a[0] = 10;
a[1] = 20;
a[2] = 30;
var b = a.map( function(v){
return v * v;
} );
b; // [100, 144, 132]
```
在被平方后,值`20`和`30`的結果會位溢出。要繞過這樣的限制,你可以使用`TypedArray#from(..)`函數:
```source-js
var a = new Uint8Array( 3 );
a[0] = 10;
a[1] = 20;
a[2] = 30;
var b = Uint16Array.from( a, function(v){
return v * v;
} );
b; // [100, 400, 900]
```
關于被類型化數組所共享的`Array.from(..)`函數的更多信息,參見第六章的“`Array.from(..)`靜態方法”一節。特別地,“映射”一節講解了作為第二個參數值被接受的映射函數。
一個值得考慮的有趣行為是,類型化數組像普通數組一樣有一個`sort(..)`方法,但是這個方法默認是數字排序比較而不是將值強制轉換為字符串進行字典順序比較。例如:
```source-js
var a = [ 10, 1, 2, ];
a.sort(); // [1,10,2]
var b = new Uint8Array( [ 10, 1, 2 ] );
b.sort(); // [1,2,10]
```
就像`Array#sort(..)`一樣,`TypedArray#sort(..)`接收一個可選的比較函數作為參數值,它們的工作方式完全一樣。
## Maps
如果你對JS經驗豐富,那么你一定知道對象是創建無序鍵/值對數據結構的主要機制,這也被稱為map。然而,將對象作為map的主要缺陷是不能使用一個非字符串值作為鍵。
例如,考慮如下代碼:
```source-js
var m = {};
var x = { id: 1 },
y = { id: 2 };
m[x] = "foo";
m[y] = "bar";
m[x]; // "bar"
m[y]; // "bar"
```
這里發生了什么?`x`和`y`這兩個對象都被字符串化為`"[object Object]"`,所以只有這一個鍵被設置為`m`。
一些人通過在一個值的數組旁邊同時維護一個平行的非字符串鍵的數組實現了山寨的map,比如:
```source-js
var keys = [], vals = [];
var x = { id: 1 },
y = { id: 2 };
keys.push( x );
vals.push( "foo" );
keys.push( y );
vals.push( "bar" );
keys[0] === x; // true
vals[0]; // "foo"
keys[1] === y; // true
vals[1]; // "bar"
```
當然,你不會想親自管理這些平行數組,所以你可能會定義一個數據解構,使它內部帶有自動管理的方法。除了你不得不自己做這些工作,主要的缺陷是訪問的時間復雜度不再是O(1),而是O(n)。
但在ES6中,不再需要這么做了!使用`Map(..)`就好:
```source-js
var m = new Map();
var x = { id: 1 },
y = { id: 2 };
m.set( x, "foo" );
m.set( y, "bar" );
m.get( x ); // "foo"
m.get( y ); // "bar"
```
唯一的缺點是你不能使用`[]`方括號訪問語法來設置或取得值。但是`get(..)`和`set(..)`可以完美地取代這種語法。
要從一個map中刪除一個元素,不要使用`delete`操作符,而是使用`delete(..)`方法:
```source-js
m.set( x, "foo" );
m.set( y, "bar" );
m.delete( y );
```
使用`clear()`你可清空整個map的內容。要得到map的長度(也就是,鍵的數量),使用`size`屬性(不是`length`)。
```source-js
m.set( x, "foo" );
m.set( y, "bar" );
m.size; // 2
m.clear();
m.size; // 0
```
`Map(..)`的構造器還可以接受一個可迭代對象(參見第三章的“迭代器”),它必須產生一個數組的列表,每個數組的第一個元素是鍵,第二元素是值。這種用于迭代的格式與`entries()`方法產生的格式是一樣的,`entries()`方法將在下一節中講解。這使得制造一個map的拷貝十分簡單:
```source-js
var m2 = new Map( m.entries() );
// 等同于:
var m2 = new Map( m );
```
因為一個map實例是一個可迭代對象,而且它的默認迭代器與`entries()`相同,第二種稍短的形式更理想。
當然,你可以在`Map(..)`構造器形式中手動指定一個?*entries*?列表:
```source-js
var x = { id: 1 },
y = { id: 2 };
var m = new Map( [
[ x, "foo" ],
[ y, "bar" ]
] );
m.get( x ); // "foo"
m.get( y ); // "bar"
```
### Map 值
要從一個map得到值的列表,使用`values(..)`,它返回一個迭代器。在第二和第三章,我們講解了幾種序列化(像一個數組那樣)處理一個迭代器的方法,比如`...`擴散操作符和`for..of`循環。另外,第六章的“Arrays”將會詳細講解`Array.from(..)`方法。考慮如下代碼:
```source-js
var m = new Map();
var x = { id: 1 },
y = { id: 2 };
m.set( x, "foo" );
m.set( y, "bar" );
var vals = [ ...m.values() ];
vals; // ["foo","bar"]
Array.from( m.values() ); // ["foo","bar"]
```
就像在前一節中討論過的,你可以使用`entries()`(或者默認的map迭代器)迭代一個map的記錄。考慮如下代碼:
```source-js
var m = new Map();
var x = { id: 1 },
y = { id: 2 };
m.set( x, "foo" );
m.set( y, "bar" );
var vals = [ ...m.entries() ];
vals[0][0] === x; // true
vals[0][1]; // "foo"
vals[1][0] === y; // true
vals[1][1]; // "bar"
```
### Map 鍵
要得到鍵的列表,使用`keys()`,它返回一個map中鍵的迭代器:
```source-js
var m = new Map();
var x = { id: 1 },
y = { id: 2 };
m.set( x, "foo" );
m.set( y, "bar" );
var keys = [ ...m.keys() ];
keys[0] === x; // true
keys[1] === y; // true
```
要判定一個map中是否擁有一個給定的鍵,使用`has(..)`:
```source-js
var m = new Map();
var x = { id: 1 },
y = { id: 2 };
m.set( x, "foo" );
m.has( x ); // true
m.has( y ); // false
```
實質上map讓你將一些額外的信息(值)與一個對象(鍵)相關聯,而不用實際上將這些信息放在對象本身中。
雖然在一個map中你可以使用任意種類的值作為鍵,但是你經常使用的將是對象,就像字符串和其他在普通對象中可以合法地作為鍵的基本類型。換句話說,你可能將想要繼續使用普通對象,除非一些或全部的鍵需要是對象,在那種情況下map更合適。
警告:?如果你使用一個對象作為一個map鍵,而且這個對象稍后為了能夠被垃圾回收器(GC)回收它占用的內存而被丟棄(解除所有的引用),那么map本身將依然持有它的記錄。你需要從map中移除這個記錄來使它能夠被垃圾回收。在下一節中,我們將看到對于作為對象鍵和GC來說更好的選擇 —— WeakMaps。
## WeakMaps
WeakMap是map的一個變種,它們的大多數外部行為是相同的,而在底層內存分配(明確地說是它的GC)如何工作上有區別。
WeakMap(僅)接收對象作為鍵。這些對象被?*弱*?持有,這意味著如果對象本身被垃圾回收掉了,那么在WeakMap中的記錄也會被移除。這是觀察不到的,因為一個對象可以被垃圾回收的唯一方法是不再有指向它的引用 —— 一旦不再有指向它的引用,你就沒有對象引用可以用來檢查它是否存在于這個WeakMap中。
除此以外,WeakMap的API是相似的,雖然限制更多:
```source-js
var m = new WeakMap();
var x = { id: 1 },
y = { id: 2 };
m.set( x, "foo" );
m.has( x ); // true
m.has( y ); // false
```
WeakMap沒有`size`屬性和`clear()`方法,它們也不對它們的鍵,值和記錄暴露任何迭代器。所以即便你解除了`x`引用,它將會因GC從`m`中移除它的記錄,也沒有辦法確定這一事實。你只能相信JavaScript會這么做!
就像map一樣,WeakMap讓你將信息與一個對象軟關聯。如果你不能完全控制這個對象,比如DOM元素,它們就特別有用。如果你用做map鍵的對象可以被刪除并且應當在被刪除時成為GC的回收對象,那么一個WeakMap就是更合適的選項。
要注意的是WeakMap只弱持有它的?*鍵*,而不是它的值。考慮如下代碼:
```source-js
var m = new WeakMap();
var x = { id: 1 },
y = { id: 2 },
z = { id: 3 },
w = { id: 4 };
m.set( x, y );
x = null; // { id: 1 } 是可以GC的
y = null; // 由于 { id: 1 } 是可以GC的,因此 { id: 2 } 也可以
m.set( z, w );
w = null; // { id: 4 } 不可以GC
```
因此,我認為WeakMap被命名為“WeakKeyMap”更好。
## Sets
一個set是一個集合,其中的值都是唯一的(重復的會被忽略)。
set的API與map很相似。`add(..)`方法(有點諷刺地)取代了`set(..)`,而且沒有`get(..)`方法。
考慮如下代碼:
```source-js
var s = new Set();
var x = { id: 1 },
y = { id: 2 };
s.add( x );
s.add( y );
s.add( x );
s.size; // 2
s.delete( y );
s.size; // 1
s.clear();
s.size; // 0
```
`Set(..)`構造器形式與`Map(..)`相似,它可以接收一個可迭代對象,比如另一個set或者一個值的數組。但是,與`Map(..)`期待一個?*記錄*?的列表(鍵/值數組的數組)不同的是,`Set(..)`期待一個?*值*?的列表(值的數組):
```source-js
var x = { id: 1 },
y = { id: 2 };
var s = new Set( [x,y] );
```
一個set不需要`get(..)`,因為你不會從一個set中取得值,而是使用`has(..)`測試一個值是否存在:
```source-js
var s = new Set();
var x = { id: 1 },
y = { id: 2 };
s.add( x );
s.has( x ); // true
s.has( y ); // false
```
注意:?`has(..)`中的比較算法與`Object.is(..)`(見第六章)幾乎完全相同,除了`-0`和`0`被視為相同而非不同。
### Set 迭代器
set和map一樣擁有相同的迭代器方法。set的行為有所不同,但是與map的迭代器的行為是對稱的。考慮如下代碼:
```source-js
var s = new Set();
var x = { id: 1 },
y = { id: 2 };
s.add( x ).add( y );
var keys = [ ...s.keys() ],
vals = [ ...s.values() ],
entries = [ ...s.entries() ];
keys[0] === x;
keys[1] === y;
vals[0] === x;
vals[1] === y;
entries[0][0] === x;
entries[0][1] === x;
entries[1][0] === y;
entries[1][1] === y;
```
`keys()`和`values()`迭代器都會給出set中唯一值的列表。`entries()`迭代器給出記錄數組的列表,記錄數組中的兩個元素都是唯一的set值。一個set的默認迭代器是它的`values()`迭代器。
一個set天生的唯一性是它最有用的性質。例如:
```source-js
var s = new Set( [1,2,3,4,"1",2,4,"5"] ),
uniques = [ ...s ];
uniques; // [1,2,3,4,"1","5"]
```
set的唯一性不允許強制轉換,所以`1`和`"1"`被認為是不同的值。
## WeakSets
一個WeakMap弱持有它的鍵(但強持有它的值),而一個WeakSet弱持有它的值(不存在真正的鍵)。
```source-js
var s = new WeakSet();
var x = { id: 1 },
y = { id: 2 };
s.add( x );
s.add( y );
x = null; // `x` 可以GC
y = null; // `y` 可以GC
```
警告:?WeakSet的值必須是對象,在set中被允許的基本類型值是不行的。
## 復習
ES6定義了幾種有用的集合,它們使得處理解構化的數據更加高效和有效。
類型化數組提供了二進制數據緩沖的“視圖”,它使用各種整數類型對齊,比如8位無符號整數和32位浮點數。二進制數據的數組訪問使得操作更加容易表達和維護,它可以讓你更簡單地處理如視頻,音頻,canvas數據等復雜的數組。
Map是鍵-值對集合,它的鍵可以是對象而非只可以是字符串/基本類型。Set是(任何類型的)唯一值的列表。
WeakMap是鍵(對象)被弱持有的map,所以如果它是最后一個指向這個對象的引用,GC就可以自由地回收這個記錄。WeakSet是值被弱持有的set,所以同樣地,如果它是最后一個指向這個對象的引用,GC就可以移除這個記錄。