你懂 JavaScript 嗎?#5 值(Values)Part 1 - 陣列、字串、數字

你所不知道的 JS

本文主要會談到關於陣列、字串、數字的錯誤操作方式與疑難雜症的解法。

寫程式粗心大意可是會爆炸的喔!

爆炸

陣列(Array)

陣列是由數值做索引,可由任何型別值所構成的群集。在這裡要先提到兩個容易誤用的重點-(1) 稀疏陣列誤存 undefined 的元素 和 (2) 使用「很像數字」的字串當成鍵值來存資料時,鍵值被強制轉型為數字的狀況,最後會提到「類陣列」的操作。

稀疏陣列(Sparse Array)

稀疏陣列是指陣列中有插槽(slot)可能未定義其值或被略過而導致存放 undefined 元素的狀況,範例如下。

const list = [];
list[0] = 'Hello';
list[2] = 'World';

list[1]; // undefined
list.length; // 3

這會有什麼問題呢?

由於這可能是一些疏忽或錯誤操作所造成的,因此會對 lenghth 有錯誤的期待,例如,可能原本期待 list 的長度為 2,但因錯置了字串 'World' 的位置,導致 list 的長度為 3,在之後陣列的操作上可能會出現很難發現的 bug。

這種 bug 就是所謂的 地雷,你永遠不知道它什麼時候會爆炸,一旦爆炸就死傷慘重、很難挽救。

地雷

鍵值的強制轉型

若使用「很像數字」的字串當成鍵值來存資料,鍵值會被強制轉型為數字,這也會造成後續處理上的難題,像是產生剛剛提到的稀疏矩陣的狀況(又是地雷一枚)。

const list = [];
list[0] = 'Hello';
list['20'] = 'World';

list['20']; // 'World'
list.length; // 21

陣列其實也就是物件的子型別而已,所以若想用字串當成鍵值來存放資料也是可以的,只是鍵值會被強制轉型為數字。如上所示,鍵值 '20' 被強制轉為數字 20,導致 list 成為稀疏陣列,其長度就被誤判了。因此,若索引值是數字就用陣列,而非數字就用物件吧!

是不是讓你想到身邊的某些人呢?還是其實就是你自己?

老話一句,「加油,好嗎?」

加油,好嗎?

類陣列(Array-Like)

類陣列是指以數值索引的值所成的群集,它可能是串列但並非真正的陣列,例如:DOM 物件操作後所得到的串列、函式引數所形成的串列(ES6 已棄用)。而為了能操作這些類陣列的元素,就必須將類陣列轉為真正的陣列,這樣就能進行 indexOf、concat、forEach 等的操作了。

DOM 物件操作後所得到的串列,範例如下。

const list = document.getElementsByTagName('div');
list; // HTMLCollection(3) [div, div, div]
list.length; // 3

函式引數所形成的串列,範例如下,取得不定個數的引數。

function foo() {
  const arr = Array.prototype.slice.call(arguments);
  console.log(arguments); // (1)
  console.log(arr); // (2)
}

foo('hello', 'world', 'bar', 'baz');

得到

以上可知,函數引數所形成的類陣列,在經過 slice 轉換後可得到真正的陣列以供後續操作。注意,slice 會回傳一個指定開始到結束部份的新陣列,因此在不傳入任何參數的狀況下等同於複製陣列。

或使用 Array.from 也會有同樣的效果。

function foo() {
  const arr = Array.from(arguments);
  console.log(arguments); // (1)
  console.log(arr); // (2)
}

foo('hello', 'world', 'bar', 'baz');

// Arguments(4) ["hello", "world", "bar", "baz", callee: ƒ, Symbol(Symbol.iterator): ƒ]
// (4) ["hello", "world", "bar", "baz"]

字串(String)

這部份還是繼續來看關於類陣列的處理。

可變(Mutable)與不可變(Immutable)

JavaScript 在創建變數、賦值後是可變的(mutable);相較於 mutable,不可變(immutable) 就是指在創建變數、賦值後便不可改變,若對其有任何變更(例如:新增、修改、刪除),就會回傳一個新值。

當需要更新一個變數的時候,若值的型態為基本型態,則是不可變的,意即只要改變就會回傳一個新的值;若值的型態為物件型態,則由於物件是使用 call by reference 的方式共享資料來源,因此只是就地更新而已,或說是更新這個位置所儲存的值,而非回傳一個新的值。

字串的類陣列處理

字串可不可以當成陣列來處理呢?可以的,而且可以借用陣列的方法來做些事情,只是要注意,不能變更陣列的內容

插入間隔字元

如下,借用陣列的 join 來實作在字串間插入字元。join 和 map 都不會變動到原始陣列的內容,因為回傳的結果是一個新的值。

const str = 'foo';

const str_another = Array.prototype.join.call(str, '--');

const str_the_other = Array.prototype.map
  .call(str, (char) => {
    return `${char.toUpperCase()}.`;
  })
  .join('');

str_another; // f--o--o
str_the_other; // F.O.O.

反轉

但 reverse 是會改變原始陣列資料的,因此字串就不能借用。如下所示,arr 經反轉由 ['b', 'a', 'r'] 改變為 ["r", "a", "b"]

const arr = ['b', 'a', 'r'];
arr.reverse(); // ["r", "a", "b"]
arr; // ["r", "a", "b"]

所以若想借用陣列的 reverse 來反轉字串,就會被報錯了。

const str = 'foo';
const str_another = Array.prototype.reverse.call(str);

// Uncaught TypeError: Cannot assign to read only property '0' of object '[object String]' at String.reverse

面對無法借用陣列方法的狀況,可先將字串轉為陣列,在進行操作(像是反轉),最後再轉回字串即可。

const str = 'foo';
const str_the_other = str
  .split('')
  .reverse()
  .join('');
str_the_other; // 'oof'

但以上是不是看起來很醜陋又麻煩?因此最好的方法是先把資料存成陣列,再使用陣列的方法操作,後續若需要使用字串表示,再用 join 打平串起來就可以了!

看到這裡是不是覺得人生很難?

人生很難

數字(Number)

JavaScript 的數字(number)型別包含兩種-整數和帶有小數的浮點數,其中數字的實作是以 IEEE 754 為標準,也就是浮點數(floating-point number)的雙精度(double precision)格式,意即 64 位元的二進位數字。

以下來探討一些疑難雜症。

如何表達「非常大」或「非常小」的數字?

非常大或非常小的數值以「指數」的方式呈現。

const a = 1e20;
const b = a * 100;
const c = a / 0.001;

a; // 100000000000000000000
b; // 1e+22
c; // 1e+23

// 使用 toExponential 手動轉指數呈現
a.toExponential(); // "1e+20"

如何指定小數位數?

使用 toFixed 指定要顯示的小數位數,會做四捨五入,不足會補零,注意結果會以「字串」格式呈現。

const a = 123.456789;

a.toFixed(1); // "123.5"
a.toFixed(2); // "123.46"
a.toFixed(3); // "123.457"
a.toFixed(10); // "123.4567890000"

如何指定有效位數?

使用 toPrecision 指定有效位數,會做四捨五入,不足會補零,注意結果會以「字串」格式呈現。

const a = 123.456789;

a.toPrecision(1); // "1e+2"
a.toPrecision(2); // "1.2e+2"
a.toPrecision(3); // "123"
a.toPrecision(4); // "123.5"
a.toPrecision(5); // "123.46"
a.toPrecision(10); // "123.4567890"

注意,數字後加上 . 會讓 JavaScript 引擎先判定為小數點,而非屬性存取。因此,若希望 100.toPrecision(1) 能正常顯示,應該為 100..toPrecision(1)(100).toPrecision(1)

如何表示其他基數的數字?

0xab; // 171
0o65; // 53
0b11; // 3

頭昏眼花了嗎?0x0o0b 可不是表情符號喔!

如何表示十進位小數?

只要是使用 IEEE 754 來表示二進位浮點數的程式語言都有一個夢靨-無法精準地表示十進位的小數,範例如下。

0.1 + 0.2 === 0.3; // false

將 0.1、0.2 和 0.3 分別轉為二進位來看

因此 0.1 + 0.2 永遠不會剛好等於 0.3。

解法是取一個很小的誤差當作容許值,若運算結果小於這個誤差值就判斷為等於,在 ES6 中已定義好這個常數 Number.EPSILON 其值為 2^-52,或實作 polyfill 如下。

if (!Number.EPSILON) {
  Number.EPSILON = Math.pow(2, -52);
}

那…要怎麼使用這個 Number.EPSILON 呢?先實作一個函式 equal,它會判斷誤差是否小於容許值-先將兩輸入值的差取絕對值,再與 Number.EPSILON 做比對,若小於這個誤差值就判斷為兩數相等。

function equal(n1, n2) {
  return Math.abs(n1 - n2) < Number.EPSILON;
}

var a = 0.1 + 0.2;
var b = 0.3;

equal(a, b); // true
equal(0.0000001, 0.0000002); // false

備註

ES6 定義所謂「安全」的數值範圍為

如何知道數值是個整數?如何知道數值位在安全範圍內?

使用 Number.isInteger 來測試數值是否為整數。

Number.isInteger(42); // true
Number.isInteger(42.0); // true
Number.isInteger(42.3); // false

使用 Number.isSafeInteger 來測試數值是否位在安全範圍內。

Number.isSafeInteger(Number.MAX_SAFE_INTEGER); // true
Number.isSafeInteger(Math.pow(2, 53)); // false
Number.isSafeInteger(Math.pow(2, 53) - 1); // true

polyfill。

if (!Number.isSafeInteger) {
  Number.isSafeInteger = function (num) {
    return Number.isInteger(num) && Math.abs(num) <= Number.MAX_SAFE_INTEGER;
  };
}

32 位元有號整數(32-bit Signed Integer)

部份運算(例如:位元運算子 bitwise operator)只允許使用 32 位元的有號整數,其範圍為 Math.pow(-2,31)Math.pow(2,31)-1

在做這類運算前必須先把數值使用 | 0 轉為 32 位元的有號整數

const integer = 123456789;
const signed_integer = integer | 0;

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

推薦閱讀

References


同步發表於2019 鐵人賽


javascript array operator 運算子 iterator 迭代器 You-Dont-Know-JS javascript 2019鐵人賽 你所不知道的JS 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-Types-and-Grammar 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文