在 JavaScript 的型別系統中,數值型別(Number)應該是數一數二的地雷型別。今天這篇文章,我想來深度探討 Number 型別的各種魔鬼般的細節,也談談 Number 的常見地雷與建議作法。
數值的表示法
要透過 JavaScript 表示一個數值,比較常見的語法有:
-
16 進制
let num = 0xFFF;
-
10 進制
let num = 100;
-
8 進制
let num = 0700;
這裡有點需要注意,從 ES2015 開始,在嚴格模式下的 JS 就不允許使用這種 8 進制數值表示法 (Octal literals)。
(function() {
'use strict';
let num = 0700;
console.log(num);
})();
如果要的話,必須改用 ES2015 全新的 8 進制表示法,必須以 0o
或 0O
為前綴:
(function() {
'use strict';
let num = 0o700;
console.log(num);
})();
-
2 進制
使用 2 進制必須以 0b
或 0B
為前綴,此語法是 ES2015 才新增的表示法:
let num = 0b1111;
-
科學記號表示法 (Scientific notation)
以下這段數值等於 10 乘以 10 的 5 次方:
let num = 10e5;
以下這段數值等於 3.1415 乘以 10 的 -3 次方:
let num = 3.1415e-3;
-
無窮大 (Infinity)
正無窮大有兩種表示法,一種是瀏覽器內建的 Infinity
全域變數。另一種則是 ECMAScript 定義的 Number.POSITIVE_INFINITY
也可以直接使用:
let num = Infinity;
let inf = Number.POSITIVE_INFINITY;
let sho = 1/0; // 更短的正無窮大寫法
負無窮大也有兩種表示法:
let num = -Infinity;
let inf = Number.NEGATIVE_INFINITY;
let sho = -1/0; // 更短的負無窮大寫法
在瀏覽器中,這裡的 Infinity
其實是 window
根物件下的一個屬性,可以被視為全域變數,所以你也可以寫成:
let num = window.Infinity;
在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isFinite()
函式來判斷該數值物件是否為有限的數值!
Number.isFinite(46872394293) // true
-
NaN (Not a Number)
這是一個相當特別的「數值」物件,從名稱上來看 NaN
就是 Not a Number
的縮寫,但是透過 typeof(NaN)
你又會得到 "number"
的結果,代表這是一個 number
型別,但本身不是一個數值,你無法對 NaN
物件做任何四則運算!
let num = NaN;
你要判斷一個變數是否為 NaN
,絕對不能用以下判斷式:
let num = NaN;
num === NaN // false
要判斷某數是否為 NaN
只能透過以下方法:
let num = NaN;
window.isNaN(num); // true
// 由於 isNaN 是 window 下的一個屬性,因此可以視為全域的存在,因此可以簡化成以下寫法
isNaN(num); // true
在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isNaN()
函式來判斷該數值物件是否為NaN!
Number.isNaN(num); // true
目前 TC39 有個 Numeric Separators 提案已經抵達 Stage 3 階段 (瀏覽器相容性)。這份提案可以讓你在撰寫 數字 (numeric literals) 的時候,可以加上分隔符號 _
(底線),大幅提升可讀性!👍
1_000_000_000 // Ah, so a billion
101_475_938.38 // And this is hundreds of millions
let fee = 123_00; // $123 (12300 cents, apparently)
let fee = 12_300; // $12,300 (woah, that fee!)
let amount = 12345_00; // 12,345 (1234500 cents, apparently)
let amount = 123_4500; // 123.45 (4-fixed financial)
let amount = 1_234_500; // 1,234,500
數值型別轉換
你可以將任意物件「嘗試轉型」為「數值型別」,但要注意並非每種物件都可以順利轉成 number
型別,只要轉不成功就會變成 NaN
(非數值的數值)!
-
字串轉數字
你可以透過 Number
函式來轉換字串為數值
Number('3.14') // 3.14
Number('100') // 100
Number(' ') // 0
Number('') // 0
Number('a123') // NaN
Number('1,000') // NaN
也可以透過 +
(加號) 來轉換數值,這算是一個相當常見的數值轉換技巧:
+'3.14' // 3.14
+'100' // 100
+' ' // 0
+'' // 0
+'a123' // NaN
+'1,000' // NaN
另一種常見字串轉數值的方式,則是使用 parseInt 或 parseFloat 函式來解析字串。
這裡你必須特別注意這兩個函式的定義:
-
parseInt(str [, radix])
第 2 個參數 radix
(基數) 代表第 1 個參數的內容代表哪種進制,預設值為 10
進制。你可以設定 2 ~ 36 的數字都可以,最大值不能超過 36
進制。
所謂 36 進制,代表你可以用 0-9
與 A-Z
等 36 個字元當成數值表示。
-
parseFloat(str)
這個函式只能固定轉換十進制的實數。
這兩個函式在傳入第 1 個參數時,都要務必確認此參數只能是 字串 (string
) 型態,千萬不要傳入 非字串 的物件進來,否則你就會得到以下詭異的結果:
parseInt(0.1) // 0
parseInt(0.01) // 0
parseInt(0.001) // 0
parseInt(0.0001) // 0
parseInt(0.00001) // 0
parseInt(0.000001) // 0
parseInt(0.0000001) // 1
為什麼數值小到一定程度,就會導致 parseInt
的結果發生異常呢?那是因為從 0.0000001
開始,該數值會被轉成科學記號表示法,實際上會變成 1e-7
,而 parseInt
需要將第一個參數轉為字串,因此你傳入的 字串 其實是 "1e-7"
,而對 parseInt
來說,他會從字串最左邊開始解析每個認得的數字符號,遇到無法解析的字元就會自動忽略所有剩餘的內容。以這個例子來說,parseInt
指任何 1e-7
的 1
而已,因為預設 radix
為 10
進制,因此 e
這個字元是看不懂的,所以最終結果為 1
,非常意外吧! 😅
如果要解析複雜的數值字串,可以考慮採用 Numeral.js 函式庫。
-
布林轉數值
使用 Number(false)
會將布林值 false
轉為數字為 0
使用 Number(true)
會將布林值 true
轉為數字為 1
因此,以下運算式就不足為奇,不是嗎? 😅
1 < 2 < 3 // true
3 > 2 > 1 // false
-
日期轉數值
在 JavaScript 中的 Date 型別用來指向某一個時間點,但事實上在骨子裡儲存的其實是 number
型態,你可以透過 .valueOf()
方法取得內部的數值:
new Date(2020, 1, 20, 0, 0, 0).valueOf() // 1582128000000
也可以透過 +
(加號) 來轉型:
+new Date(2020, 1, 20, 0, 0, 0) // 1582128000000
或是透過 Number()
來轉型:
Number(new Date(2020, 1, 20, 0, 0, 0)) // 1582128000000
-
數字轉字串 (預設為 10 進制)
let num = 566;
num.toString(); // "566"
-
數字轉字串 (指定基數)
將數值轉為 16 進制的字串
let num = 65535;
num.toString(16); // "ffff"
將數值轉為 36 進制的字串
let num = 65535;
num.toString(36); // "1ekf"
整數運算的地雷
JavaScript 採用 IEEE-754 Floating Point 規定的浮點數計算所有數值,但是有計算上的精準度問題,任何整數數值超過 2^53-1
(9007199254740991
) 就會開始產生誤差,這是一個非常地雷的數值邊界,必須特別注意。
由於這個數值非常特別,又稱為最大安全整數,因此在 JavaScript 中,有個 Number.MAX_SAFE_INTEGER
靜態屬性來代表這個數值:
Number.MAX_SAFE_INTEGER == 2**53-1 // true
我們可以來看一下超過這個數值的誤差情形,絕對會讓你大開眼界:
Number.MAX_SAFE_INTEGER+0 // 9007199254740991
Number.MAX_SAFE_INTEGER+1 // 9007199254740992
Number.MAX_SAFE_INTEGER+2 // 9007199254740992
Number.MAX_SAFE_INTEGER+3 // 9007199254740994
Number.MAX_SAFE_INTEGER+4 // 9007199254740996
Number.MAX_SAFE_INTEGER+5 // 9007199254740996
Number.MAX_SAFE_INTEGER+6 // 9007199254740996
Number.MAX_SAFE_INTEGER+7 // 9007199254740998
Number.MAX_SAFE_INTEGER+8 // 9007199254741000
Number.MAX_SAFE_INTEGER+9 // 9007199254741000
換句話說,若將 9,007,199,254,740,991 辛巴威幣(Zimbabwean dollar)換算成美金大約是 24,888,641,212,326.99 美元 (24 trillion USD),你如果用 JavaScript 來計算「整數金額」的話,這輩子大概沒機會算錯!XDD
所以只要現有的 number
數值超過 Number.MAX_SAFE_INTEGER
的話,建議就不要再算下去了。而且比對 =
(等於) >
(大於) <
(小於) 也都會出現問題。如果我們再將整數加大一點,此時 JavaScript 更會將整數數值轉為科學記號表示法,如此一來數值就更不精準,四則運算就只能取「概略」的數值,不能做精密的計算。你可以參考以下範例:
999999999999999 // 999999999999999
9999999999999999 // 10000000000000000
10000000000000000 + 1.1 // 10000000000000002
9999999999999999999999 + 100 === 9999999999999999999999 // true
在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isSafeInteger()
函式來判斷該數值物件是否為安全整數!
Number.isSafeInteger(46872394293) // true
Number.isSafeInteger(-4362794324) // true
在 ES2015 版本裡,你可以透過一個 Number 型別內建的 Number.isInteger()
函式來判斷該數值物件是否為整數!
Number.isInteger(4687239); // true
Number.isInteger(4687239.4293); // false
超大整數 (BigInt)
由於 JavaScript 擁有最大安全整數的限制,各家瀏覽器也陸續推出解決方案,例如 Google Chrome 瀏覽器從 Chrome 67
版本開始,內建了 BigInt 型別。這是一個目前還在 TC39 Stage 4 的提案階段,但大部分現代主流瀏覽器都已經內建 bigint
型別,只有 IE11 與 Safari 不支援!(業內經常戲稱 Safari 是下一代 IE 並不是沒有道理) (如果想看瀏覽器相容性報告可以點擊這裡)
要透過 JavaScript 表示一個 bigint
數值,只要在整數數值後面加上一個 n
即可,如下範例:
let num = 39837212195743250943287503298475432n
typeof(num); // bigint
如果要對 bigint
進行四則運算,必須確保最終計算結果也必須為「整數」才行,而且不能與傳統 number
型別混用。例如:
50000n/2n === 25000n
如果你想要嘗試 bigint
超大整數的上限,可以試試 2 的 50000 次方,即便這麼大的整數,依然可以順利計算出結果,而且完全沒有誤差,其結果將有 15052
個位數,相當過癮!
2n**50000n
如果要將一個超大整數的字串轉換為 BigInt 型別,可以參考以下寫法:
BigInt('39837212195743250943287503298475432')
我們可以比對一下傳統 number
與 bigint
執行時的差異:
39837212195743250943287503298475432 // 3.983721219574325e+34
39837212195743250943287503298475432n // 39837212195743250943287503298475432n
以下則是一些錯誤的 bigint
用法:
BigInt(999999999999999999999999) // 999999999999999983222784n
BigInt(1.5) // RangeError: 不能有小數
BigInt('1.5') // SyntaxError: 語法錯誤
1 + 1n // TypeError: 不能混用型別
new BigInt(123) // TypeError: 不能用 new 建立物件
浮點數運算的地雷
江湖中有一句話是這樣說的:
算錢用浮點,遲早被人扁!
沒錯,由於 JavaScript 使用浮點數計算所有數值,主要原因還是在於計算機最底層都採用 2 進制描述所有數值,而二進制大於 0
的最小整數為 1
,如果想表示 0.5
怎麼辦?我們可以利用 Number 型別的 toString(radix)
方法,將十進制的數值表示法轉換為二進制,如下範例:
(1).toString(2) // 1
(0.5).toString(2) // 0.1
(0.25).toString(2) // 0.01
(0.125).toString(2) // 0.001
(0.0625).toString(2) // 0.0001
(0.03125).toString(2) // 0.00001
你可以想像一下,如果想用二進制表示十進制的 0.1
要怎樣寫?
(0.1).toString(2) // "0.0001100110011001100110011001100110011001100110011001101"
那用二進制表示十進制的 0.2
呢?
(0.2).toString(2) // "0.001100110011001100110011001100110011001100110011001101"
是的,十進制的 0.1
與 0.2
換算成二進制之後,都是無法精準表達的數值。
若你想透過 JavaScript 的浮點數運算特性,將 0.1
+ 0.2
的話,就會得到一個相當詭異的結果:
0.1 + 0.2 != 0.3
0.2 + 0.4 != 0.6
0.3 + 0.6 != 0.9
0.4 + 0.8 != 1.2
實際上計算的結果是:
0.1 + 0.2 // 0.30000000000000004
0.2 + 0.4 // 0.6000000000000001
0.3 + 0.6 // 0.8999999999999999
0.4 + 0.8 // 1.2000000000000002
所以說啊,這不叫地雷,什麼才叫地雷? 😂
其實這種問題,還可以衍生出更多小數計算的問題,例如:
Math.ceil(0.1*0.2*100) // 3
Math.ceil(0.1*0.2*1000) // 21
Math.ceil(0.1*0.2*10000) // 201
看到這裡,你應該可以知道 算錢用浮點,遲早被人扁!
真正的涵義了吧?! 😅
目前 TC39 也有個 BigDecimal 的提案正在 Stage 1 階段,可以讓你用十進制精準無誤差的計算小數。他跟 BigInt 的用法很像,不過這算是非常早期的提案,也還沒有瀏覽器支援,但值得期待! 👍
如果不想等瀏覽器內建 BigDecimal
型別,可以考慮使用 decimal.js 函式庫。
數值型別的最大值與最小值
我們稍早有看到一個 Number 型別有 最大整數 的限制,也有 正無窮大 與 負無窮大 的數值存在。但在 Number 型別中,還有兩個特別的靜態屬性叫做 Number.MAX_VALUE
與 Number.MIN_VALUE
,其值如下:
Number.MAX_VALUE == 1.7976931348623157e+308
Number.MIN_VALUE == 5e-324
一般人不特別看文件的話,一定會將 Number.MIN_VALUE
想像成是一個最小的數值,大部分的人都會覺得是一個 非常小的負數,這是一個非常普遍的認知誤差。
事實上,Number.MIN_VALUE
其實是一個超級超級小的正數,而 5e-324
其實是 5 乘以 10 的 -324 次方的結果,所以你會得到以下結果:
Number.MIN_VALUE > 0 // true
而在 Math 物件中,有兩個取最大值與取最小值的函式,其正常的使用方式如下:
Math.max(2, 4, 6, 8); // 8
Math.min(1, 3, 5, 7); // 1
但有人發現底下這種不帶參數的寫法,非常讓人匪夷所思,這怎麼可能呢?
Math.max() > Math.min(); // -> false
Math.min() > Math.max(); // -> true
從可讀性的角度上來看,這種程式碼不但沒有意義,還很容易誤導大家理解。
其實 Math.max()
的意思非常明顯,就是取得參數中的「最大值」,但是在你沒有傳入任何數值時,最大的數值就是 負無窮大。相反的,Math.min()
是取得參數中的「最小值」,但是在你沒有傳入任何數值時,最小的數值就是 正無窮大。這真的是跌破大家眼鏡!👓
這部分語言特性在 ECMAScript 5.1 規格書的 15.8.2.11 max([value1[,value2[,…]]]) 章節中,有非常明確的說明。
其他技巧
結論
在 JavaScript 中使用 number
型別,無論是「整數」或「小數」都有不少地雷,任何一位 JavaScript 開發人員都應該特別深入理解 number
型別的特性與限制,否則未來真的遇到問題時,只會覺得丈二金剛摸不著頭緒。
如果真的不想面對這些地雷,建議多多利用網路上現成的函式庫來處理數值,透過成熟穩定的數值函式庫,可以幫助你寫出不容易出問題的代碼,無論四則運算或解析字串格式的數值,都比較不容易出錯。
我們寫程式的時候,最重要的就是維持程式碼的可讀性,如果過多的開發技巧會干擾程式碼閱讀,那麼就非常建議透過自定函式庫或引用外部函式庫來處理數值計算,減少維護程式碼的難度,也提升程式碼維護性。以下是相關函式庫推薦:
-
Numeral.js ( GitHub )
numeral('10,000.12').value() // 10000.12
numeral('$10,000.00').value() // 10000
numeral(974) // 974
numeral(0.12345) // 0.12345
numeral('10,000.12') // 10000.12
numeral('23rd') // 23
numeral('$10,000.00') // 10000
numeral('100B') // 100
numeral('3.467TB') // 3467000000000
numeral('-76%') // -0.76
numeral('2:23:57') // NaN
-
decimal.js ( GitHub )
+Decimal(0.1).add(Decimal(0.2)) // 0.3
x = new Decimal(9) // '9'
y = new Decimal(x) // '9'
new Decimal('5032485723458348569331745.33434346346912144534543')
new Decimal('4.321e+4') // '43210'
new Decimal('-735.0918e-430') // '-7.350918e-428'
new Decimal('5.6700000') // '5.67'
new Decimal(Infinity) // 'Infinity'
new Decimal(NaN) // 'NaN'
new Decimal('.5') // '0.5'
new Decimal('-0b10110100.1') // '-180.5'
new Decimal('0xff.8') // '255.5'
new Decimal(0.046875) // '0.046875'
new Decimal('0.046875000000') // '0.046875'
new Decimal(4.6875e-2) // '0.046875'
new Decimal('468.75e-4') // '0.046875'
new Decimal('0b0.000011') // '0.046875'
new Decimal('0o0.03') // '0.046875'
new Decimal('0x0.0c') // '0.046875'
new Decimal('0b1.1p-5') // '0.046875'
new Decimal('0o1.4p-5') // '0.046875'
new Decimal('0x1.8p-5') // '0.046875'
-
bignumber.js ( GitHub )
new BigNumber(43210) // '43210'
new BigNumber('4.321e+4') // '43210'
new BigNumber('-735.0918e-430') // '-7.350918e-428'
new BigNumber('123412421.234324', 5) // '607236.557696'
new BigNumber('-Infinity') // '-Infinity'
new BigNumber(NaN) // 'NaN'
new BigNumber(-0) // '0'
new BigNumber('.5') // '0.5'
new BigNumber('+2') // '2'
new BigNumber(-10110100.1, 2) // '-180.5'
new BigNumber('-0b10110100.1') // '-180.5'
new BigNumber('ff.8', 16) // '255.5'
new BigNumber('0xff.8') // '255.5'
BigNumber.config({ DECIMAL_PLACES: 5 })
new BigNumber(1.23456789) // '1.23456789'
new BigNumber(1.23456789, 10) // '1.23457'
new BigNumber('5032485723458348569331745.33434346346912144534543')
new BigNumber('4.321e10000000')
new BigNumber('.1*') // 'NaN'
new BigNumber('blurgh') // 'NaN'
new BigNumber(9, 2) // 'NaN'
-
big.js ( GitHub )
x = new Big(9) // '9'
y = new Big(x) // '9'
Big(435.345) // 'new' is optional
new Big('5032485723458348569331745.33434346346912144534543')
new Big('4.321e+4') // '43210'
new Big('-735.0918e-430') // '-7.350918e-428'
-
What is the difference between big.js, bignumber.js and decimal.js?
相關連結