Array、Set、Map 與 ArrayBuffer

August 9, 2022

來看看幾個 API,以及 ES6 以後,內建的群集 API,善加利用可以省不少功夫。

Array

陣列是經常使用的資料結構,ES6 以後 Array 上新增了一些 API 值得介紹一下。首先是替換 Array 建構式用的 Array.of

> Array.of(1);
[ 1 ]
> Array.of(1, 2);
[ 1, 2 ]
> Array.of(1, 2, 3);
[ 1, 2, 3 ]
> Array.of(1, 2, 3, undefined, 4);
[ 1, 2, 3, undefined, 4 ]
>

在只有一個引數的時候,不會像 Array(1)new Array(1) 有別於其他引數長度時的行為,如果中間有元素從缺,使用 Array.of 必須明確指定從缺的值是什麼(nullundefined?),如果你寫 [1, 2, 3, ,4 ] 會如何呢?索引 3 處會是空項目(empty item),不是 undefined,在〈數字為特性的陣列〉談過,空項目並不是 undefined

除此之外,大概就是在某些場合需要指定回呼函式,可以將 Array.of 傳入(指定 Array?認真的嗎?),因為你不能將 [] 當成函式傳入。

在〈數字為特性的陣列〉談過,在 JavaScript 的世界中,會有很多機會遇到許多長得像陣列(Array-like)的物件,而有時候,需要將這些物件轉成陣例,使之具有真正陣列該有的行為,這不單只是將 forEach 之類的方法設給該物件,還有許多瑣碎的事要做。

這時 ES6 的 Array.from 可以省許多功夫:

> let o = {
...    '0' : 100,
...    '1' : 200,
...    '2' : 300,
...    length : 3
... };
undefined
> let arr = Array.from(o);
undefined
> arr.forEach(elem => console.log(elem));
100
200
300
undefined
> arr instanceof Array;
true
>

Array.from 遇到有空項目,或轉換過程會發生空項目的情況,會明確使用 undefined

> Array.from([1,,2]);
[ 1, undefined, 2 ]
> Array.from({length : 4});
[ undefined, undefined, undefined, undefined ]
>

除此之外,Array.from 可以接受一個回呼函式,在轉換過程可以進行 map 的動作:

> Array.from([1, , 3], elem => elem ? elem * 2 : 0);
[ 2, 0, 6 ]
>

如果要用某個值填滿陣列,那麼 Array.fill 很方便:

> Array(10).fill(0);
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
> [1, 2, 3, 4, 5].fill(0, 2, 4);
[ 1, 2, 0, 0, 5 ]
>

Array.fill 第一個參數接受要填充的值,之後選擇性地接受 startend,用來指定填充的索引起始與結束。

Arrayfind 方法其實是 findFirst 的概念,可以找到第一個符合條件的數,找不到就傳回 undefinedfindIndex 則是找到第一個符合條件的元素索引位置,找不到傳回 -1:

> [10, 20, -30, 40].find(n => n < 0);
-30
> [10, 20, -30, 40].findIndex(n => n < 0);
2
>

findfindIndex 的回呼函式,其實可以接受三個引數,分別是元素值、索引與原陣列參考,上面的範例只使用了元素值的部份。

Set

ES6 以後有了 Set API,它內部的元素不重複:

> let set = new Set([1, 2, 3, 4, 5, 1, 2, 3]);
undefined
> set;
Set { 1, 2, 3, 4, 5 }
> set.add(6);
Set { 1, 2, 3, 4, 5, 6 }
> set.has(3);
true
> set.forEach(elem => console.log(elem));
1
2
3
4
5
6
undefined
> for(let elem of set) {
...     console.log(elem);
... }
1
2
3
4
5
6
undefined
> [...set];
[ 1, 2, 3, 4, 5, 6 ]
> let [a, b, c] = set;
undefined
> a;
1
> b;
2
> c;
3
>

Set 本身是可迭代的,因此可以使用 for/of,當然,也可以適用 ... 來 Spread 元素,或者解構語法。

Set 是無序的,因此沒有直接可取值的 get 之類的方法,除了上面方法示範之外,Set 還有 delete 可用來刪除元素,clear 可用來清空元素,size 可用來查看 Set 中的元素數量。

那麼問題來了,Set 中判定元素是否重複的依據是什麼?如果是基本型態,顯然就是值是不是相同,這沒有問題,那麼物件呢?

> let o1 = {x: 10};
undefined
> let o2 = {x: 10};
undefined
> let o3 = o2;
undefined
> let set = new Set([o1, o2, o3]);
undefined
> set;
Set { { x: 10 }, { x: 10 } }
>

在上面的例子中,最後的 set 有兩個物件,顯然地,並不是判定物件的特性實際上是否相等,那麼有 equals、hashCode 之類的協定,可以定義物件實質的內含值是否相同嗎?沒有!對於物件,基本上就是相當於 === 比較。

因為 JavaScript 的物件特性很容易變更,如果你瞭解其他語言中 equals、hashCode 之類的協定,也應該知道,一個狀態可變的物件,在實作 equals、hashCode 之類的協定時會有許多坑,因此就目前來說,Set 是特意這麼做的(並不是忽略了),這會造成一些不便,如果你真的需要能依 equals、hashCode 之類的協定來判定物件相等性,那必須自行實作或者是尋求既有的程式庫。

對於 Set 來說,NaN 是可以判定相等的:

> let set = new Set([NaN, NaN, 0, -0]);
undefined
> set;
Set { NaN, 0 }
>

之後談到 ECMAScript 6 以後相等性判定時,會看到像 Object.is 會將 0 與 -0 視為不相等,然而,對於 0、-0,Set 認定是相等的,具體來說,Set 是採用所謂的 SameValueZero 演算來判定相等性,詳情會在下一篇文件中說明。

在談到 Set 時,通常會一併談到 WeakSet,簡單來說,垃圾收集時不會考慮物件是否被 WeakSet 包含著,只要物件沒有其他名稱參考著,就會回收物件,如果 WeakSet 中本來有該物件,會自動消失,這可以用來避免必須使用 Set 管理物件,而後忘了從 Set 中清除而發生記憶體洩漏的問題。

WeakSet 中的元素只能是物件,不能是 numberbooleanstringsymbol 等,也不能是 null,由於物件可能被垃圾回收,因此它不能被迭代(也不能使用 forEach)、不能使用 sizeclear 方法,只能使用 addhasdelete 方法。

Map

接著來談談 ES6 以後的 Map,雖然 JavaScript 中的物件,本身就是鍵與值的集合體,然而,鍵的部份基本上就是字串,ES6 以後多了個 Symbol 可以做為特性,除此之外,就算使用 [] 指定物件作為鍵,它會取得字串描述作為特性:

> let o = {x: 10};
undefined
> let map = {
...     [o] : 'foo'
... };
undefined
> for(let p in map) {
...     console.log(p);
... }
[object Object]
undefined
>

Map 的鍵可以是物件:

> let o = {x: 10};
undefined
> let map = new Map();
undefined
> map.set(o, 'foo');
Map { { x: 10 } => 'foo' }
> map.set({y : 10}, 'foo2');
Map { { x: 10 } => 'foo', { y: 10 } => 'foo2' }
> for(let [key, value] of map) {
...     console.log(key, value);
... }
{ x: 10 } 'foo'
{ y: 10 } 'foo2'
undefined
> map.get(o);
'foo'
> map.delete(o);
true
> map;
Map { { y: 10 } => 'foo2' }
>

Map 本身可迭代,使用 for...of 的話,迭代出來的元素會是個包含鍵與值的物件,也可以使用 ... 來解構。除了以上示範的方法之外,可以使用 has 判定是否具有某個鍵,delete 刪除某鍵(與對應的值),使用 clear 清空 Map,使用 keys 取得全部的鍵,使用 values 取得全部的值,使用 entries 取得鍵值對,使用 size 取得鍵值數量,也可以使用 forEach 等。

建構 Map 時,可以使用陣列,其中必須是 [[鍵, 值], [鍵, 值]] 的形式:

> let map = new Map([['k1', 'v1'], ['k2', 'v2']]);
undefined
> map;
Map { 'k1' => 'v1', 'k2' => 'v2' }
>

Map 中的鍵必須是唯一的,判定的方式是 SameValueZero。

在談到 Map 時,通常會一併談到 WeakMap,簡單來說,垃圾收集時不會考慮物件是否被 WeakMap 作為鍵,只要物件沒有其他名稱參考著,就會回收物件,如果 WeakMap 中本來有該物件作為鍵,會自動消失,這可以用來避免必須使用 Map 管理物件,而後忘了從 Map 中清除而發生記憶體洩漏的問題。

WeakMap 中的鍵只能是物件,不能是 numberbooleanstringsymbol 等,也不能是 null,由於鍵物件可能被垃圾回收,因此它不能被迭代(也不能使用 forEach)、不能使用 sizeclear 方法,只能使用 getsethasdelete 方法。

ArrayBuffer

如果要儲存一組二進位資料,可以使用 ArrayBuffer,它從 ES6 以後成為標準之一,建構 ArrayBuffer 實例時,必須指定位元組長度,例如:

let buf = new ArrayBuffer(32); // 32 個位元組
console.log(buf.byteLength);  // 顯示 32

你沒辦法直接改變 ArrayBuffer 實例的內容,只能透過 byteLength 特性取得長度,slice 方法切割 ArrayBuffer

若要改變或取得 ArrayBuffer 內容,方式之一是透過 TypedArray,有以下幾個子類型:

  • Int8Array:8 位有號整數,長度一位元組
  • Uint8Array:8 位無號整數,長度一位元組
  • Uint8ClampedArray:8 位無號整數,長度一位元組,超出 [0, 255] 的值,會設定為 0 或 255。
  • Int16Array:16 位有號整數,長度二位元組。
  • Uint16Array:16 位無號整數,長度二位元組。
  • Int32Array:32 位有號整數,長度四位元組。
  • Uint32Array:32 位無號整數,長度四位元組。
  • Float32Array:32 位浮點數,長度四位元組。
  • Float64Array:64 位浮點數,長度八位元組。

不同的 TypedArray 子類型,檢視、操作 ArrayBuffer 的觀點(View)不同,例如同一個 ArrayBuffer 實例,在不同的 TypedArray 觀點下,每個元素的位元長度不同,因而透過 length 取得元素個數也就不同:

let buf = new ArrayBuffer(32);
let i8arr = new Int8Array(buf);
let i16arr = new Int16Array(buf);
let i32arr = new Int32Array(buf);

console.log(i8arr.length);  // 顯示 32
console.log(i16arr.length); // 顯示 16
console.log(i32arr.length); // 顯示 8

buf 有 32 個位元組,也就是 256 個位元,就 Int8Array 來說,8 個位元為一個元素,因此會有 32 個元素,就 Int16Array 來說,16 位元為一個元素,因而會有 16 個元素,同樣地,對 Int32Array 來說,會有 8 個元素。

在設定元素時,對每個元素的觀點,也視不同的 TypedArray 子類型而不同,例如,透過 Uint8Array 來寫入與讀出:

let buf = new ArrayBuffer(5);
let ui8arr = new Uint8Array(buf);
ui8arr[0] = 72;  // H
ui8arr[1] = 101; // e
ui8arr[2] = 108; // l
ui8arr[3] = 108; // l
ui8arr[4] = 111; // 0

// 顯示 Hello
console.log(String.fromCharCode.apply(null, ui8arr));

在建構 TypedArray 子類型時,也可以使用類陣列物件,例如:

let ui8arr = new Uint8Array([72, 101, 108, 108, 111]);

// 顯示 Hello
console.log(String.fromCharCode.apply(null, ui8arr));

在建構 TypedArray 之後,操作方法和特性,與 Array 幾乎是完全相同的,可參考 TypedArray API 文件

TypedArray 的子類型,對待每個元素的觀點是一致的,如果資料中包含了不同類型的元素,例如,同時包含了 8 位整數與 16 位整數,可以使用 DataView 來處理:

let buf = new ArrayBuffer(3);
let dataView =  new DataView(buf);

dataView.setInt16(0, 72);
dataView.setInt8(2, 105);

console.log(dataView.getInt16(0));
console.log(dataView.getInt8(2));

由於資料長度不固定,在呼叫 setXXXgetXXX 時,必須指定位元組偏移量。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter