2023 年 12 月 27 日

數字

在現代 JavaScript 中,有兩種數字類型

  1. JavaScript 中的常規數字儲存在 64 位元格式 IEEE-754 中,也稱為「雙精度浮點數」。這些數字是我們大部分時間使用的數字,我們將在本章中討論它們。

  2. BigInt 數字表示任意長度的整數。有時需要它們,因為常規整數無法安全地超過 (253-1) 或小於 -(253-1),如我們在本章 資料類型 中前面提到的。由於 bigint 在一些特殊領域中使用,因此我們將它們專門用於一個章節 BigInt

所以這裡我們將討論常規數字。讓我們擴展對它們的了解。

更多撰寫數字的方法

想像一下,我們需要寫下 10 億。顯而易見的方法是

let billion = 1000000000;

我們也可以使用底線 _ 作為分隔符號

let billion = 1_000_000_000;

這裡的底線 _ 扮演「語法糖」的角色,讓數字更易於閱讀。JavaScript 引擎會忽略數字之間的 _,因此它與上面的一百萬完全相同。

然而,在實際應用中,我們會盡量避免撰寫長串的零。我們太懶了。我們會嘗試寫一些像 "1bn" 表示十億或 "7.3bn" 表示七十三億。大多數大數字也是如此。

在 JavaScript 中,我們可以透過在數字後加上字母 "e" 並指定零的數量來縮短數字

let billion = 1e9;  // 1 billion, literally: 1 and 9 zeroes

alert( 7.3e9 );  // 7.3 billions (same as 7300000000 or 7_300_000_000)

換句話說,e 會將數字乘以 1 並加上指定的零的數量。

1e3 === 1 * 1000; // e3 means *1000
1.23e6 === 1.23 * 1000000; // e6 means *1000000

現在讓我們寫一些非常小的數字。例如,1 微秒(一秒的百萬分之一)

let mсs = 0.000001;

就像之前一樣,使用 "e" 可以有所幫助。如果我們想避免明確寫出零,我們可以寫成

let mcs = 1e-6; // five zeroes to the left from 1

如果我們計算 0.000001 中的零,有 6 個。所以自然而然地,它就是 1e-6

換句話說,"e" 後面的負數表示除以 1 並加上指定的零的數量

// -3 divides by 1 with 3 zeroes
1e-3 === 1 / 1000; // 0.001

// -6 divides by 1 with 6 zeroes
1.23e-6 === 1.23 / 1000000; // 0.00000123

// an example with a bigger number
1234e-2 === 1234 / 100; // 12.34, decimal point moves 2 times

十六進位、二進位和八進位數字

十六進位數字廣泛用於 JavaScript 中表示顏色、編碼字元,以及許多其他用途。因此,自然而然地存在一種更簡短的寫法:0x,然後是數字。

例如

alert( 0xff ); // 255
alert( 0xFF ); // 255 (the same, case doesn't matter)

二進位和八進位數字系統很少使用,但也可以使用 0b0o 前綴來支援

let a = 0b11111111; // binary form of 255
let b = 0o377; // octal form of 255

alert( a == b ); // true, the same number 255 at both sides

只有 3 個數字系統具有這種支援。對於其他數字系統,我們應該使用函式 parseInt(我們將在本章稍後看到)。

toString(base)

方法 num.toString(base) 會以具有給定 base 的數字系統中 num 的字串表示形式傳回。

例如

let num = 255;

alert( num.toString(16) );  // ff
alert( num.toString(2) );   // 11111111

base 可以從 236。預設值為 10

這方面的常見使用案例為

  • base=16 用於十六進位顏色、字元編碼等,數字可以是 0..9A..F

  • base=2 主要用於除錯位元運算,數字可以是 01

  • base=36 是最大值,數字可以是 0..9A..Z。整個拉丁字母用於表示數字。一個有趣但對 36 有用的情況是,當我們需要將一個長的數字識別碼轉換成較短的識別碼時,例如,建立一個短網址。可以用 base 36 的數字系統來表示它

    alert( 123456..toString(36) ); // 2n9c
兩個點呼叫一個方法

請注意,在 123456..toString(36) 中的兩個點不是錯字。如果我們想直接在數字上呼叫一個方法,例如上面的範例中的 toString,那麼我們需要在數字後面加上兩個點 ..

如果我們只放一個點:123456.toString(36),就會出現錯誤,因為 JavaScript 語法表示第一個點之後的小數部分。如果我們再放一個點,那麼 JavaScript 就知道小數部分是空的,現在進入方法。

也可以寫成 (123456).toString(36)

捨入

在使用數字時,最常用的運算之一是捨入。

有幾個內建函式可以進行捨入

Math.floor
向下捨入:3.1 變成 3-1.1 變成 -2
Math.ceil
向上捨入:3.1 變成 4-1.1 變成 -1
Math.round
捨入到最接近的整數:3.1 變成 33.6 變成 4,中間值:3.5 也向上捨入到 4
Math.trunc(Internet Explorer 不支援)
移除小數點後的所有內容,不捨入:3.1 變成 3-1.1 變成 -1

以下是表格,用於總結它們之間的差異

Math.floor Math.ceil Math.round Math.trunc
3.1 3 4 3 3
3.6 3 4 4 3
-1.1 -2 -1 -1 -1
-1.6 -2 -1 -2 -1

這些函式涵蓋了處理數字小數部分的所有可能方式。但是,如果我們想將數字捨入到小數點後第 n 位數,該怎麼辦?

例如,我們有 1.2345,並希望將其捨入到 2 位數,只取得 1.23

有兩種方法可以做到這一點

  1. 乘法和除法。

    例如,要將數字捨入到小數點後第 2 位數,我們可以將數字乘以 100,呼叫捨入函式,然後再除回去。

    let num = 1.23456;
    
    alert( Math.round(num * 100) / 100 ); // 1.23456 -> 123.456 -> 123 -> 1.23
  2. 方法 toFixed(n) 將數字捨入到小數點後第 n 位數,並傳回結果的字串表示形式。

    let num = 12.34;
    alert( num.toFixed(1) ); // "12.3"

    這會四捨五入到最近的值,類似於 Math.round

    let num = 12.36;
    alert( num.toFixed(1) ); // "12.4"

    請注意,toFixed 的結果是一個字串。如果小數部分比要求的短,零會附加到結尾

    let num = 12.34;
    alert( num.toFixed(5) ); // "12.34000", added zeroes to make exactly 5 digits

    我們可以使用一元加法或 Number() 呼叫將其轉換為數字,例如,寫入 +num.toFixed(5)

不精確的計算

在內部,一個數字以 64 位元格式表示 IEEE-754,因此有正好 64 位元來儲存一個數字:其中 52 位元用於儲存數字,11 位元儲存小數點的位置,1 位元用於符號。

如果一個數字真的很大,它可能會溢位 64 位元的儲存空間,並變成一個特殊的數字值 Infinity

alert( 1e500 ); // Infinity

可能不太明顯,但經常發生的是精度的損失。

考慮這個(錯誤的!)相等性測試

alert( 0.1 + 0.2 == 0.3 ); // false

沒錯,如果我們檢查 0.10.2 的總和是否為 0.3,我們得到 false

奇怪!如果不是 0.3,那會是什麼?

alert( 0.1 + 0.2 ); // 0.30000000000000004

哎呀!想像一下,你正在製作一個電子商務網站,訪客將 $0.10$0.20 的商品放入他們的購物車中。訂單總額將為 $0.30000000000000004。這會讓任何人感到驚訝。

但為什麼會這樣?

一個數字以其二進位形式儲存在記憶體中,一個位元序列——一和零。但是像 0.10.2 這樣的分數在十進位數字系統中看起來很簡單,實際上它們的二進位形式是無窮的分數。

alert(0.1.toString(2)); // 0.0001100110011001100110011001100110011001100110011001101
alert(0.2.toString(2)); // 0.001100110011001100110011001100110011001100110011001101
alert((0.1 + 0.2).toString(2)); // 0.0100110011001100110011001100110011001100110011001101

什麼是 0.1?它是十進位 1/10,十分之一。在十進位數字系統中,這樣的數字很容易表示。將其與三分之一進行比較:1/3。它變成了一個無窮的分數 0.33333(3)

因此,在十進位系統中,除以 10 的次方保證能正常工作,但除以 3 則不行。出於同樣的原因,在二進位數字系統中,除以 2 的次方保證能正常工作,但 1/10 變成了一個無窮的二進位分數。

使用二進位系統根本無法準確儲存 0.10.2,就像無法將三分之一儲存為十進位分數一樣。

數字格式 IEEE-754 通過四捨五入到最接近的可能數字來解決這個問題。這些四捨五入規則通常不允許我們看到「微小的精度損失」,但它確實存在。

我們可以看到它的實際運作

alert( 0.1.toFixed(20) ); // 0.10000000000000000555

當我們對兩個數字求和時,它們的「精度損失」會累加。

這就是為什麼 0.1 + 0.2 不完全等於 0.3

不只 JavaScript

許多其他程式語言也存在相同的問題。

PHP、Java、C、Perl 和 Ruby 給出完全相同的結果,因為它們基於相同的數字格式。

我們可以解決這個問題嗎?當然,最可靠的方法是使用 toFixed(n) 方法來捨入結果。

let sum = 0.1 + 0.2;
alert( sum.toFixed(2) ); // "0.30"

請注意,toFixed 始終會傳回字串。它確保小數點後有 2 位數。如果我們有電子商務並需要顯示 $0.30,這實際上很方便。對於其他情況,我們可以使用一元加號將其強制轉換為數字。

let sum = 0.1 + 0.2;
alert( +sum.toFixed(2) ); // 0.3

我們也可以暫時將數字乘以 100(或更大的數字)將它們轉換為整數,進行運算,然後再除回去。然後,由於我們對整數進行運算,因此誤差會略微減少,但我們仍然會在除法中得到誤差。

alert( (0.1 * 10 + 0.2 * 10) / 10 ); // 0.3
alert( (0.28 * 100 + 0.14 * 100) / 100); // 0.4200000000000001

因此,乘除法方法會減少誤差,但不會完全消除誤差。

有時我們可以嘗試完全避開分數。例如,如果我們在經營商店,那麼我們可以將價格儲存在美分而不是美元中。但是,如果我們應用 30% 的折扣怎麼辦?在實務上,完全避開分數很少見。在需要時,只需將它們捨入以去除「小數點後尾數」即可。

有趣的事

嘗試執行這個

// Hello! I'm a self-increasing number!
alert( 9999999999999999 ); // shows 10000000000000000

這會產生相同的問題:精度損失。數字有 64 位元,其中 52 位元可用於儲存數字,但這還不夠。因此,最低有效數字會消失。

JavaScript 在此類事件中不會觸發錯誤。它會盡力將數字放入所需的格式,但不幸的是,此格式不夠大。

兩個零

數字內部表示的另一個有趣後果是存在兩個零:0-0

這是因為符號由單一位元表示,因此可以設定或不設定任何數字,包括零。

在大多數情況下,這種區別並不明顯,因為運算子適合將它們視為相同。

測試:isFinite 和 isNaN

還記得這兩個特殊的數字值嗎?

  • Infinity(和 -Infinity)是一個特殊的數字值,大於(小於)任何東西。
  • NaN 代表錯誤。

它們屬於 number 類型,但不是「正常」數字,因此有特殊函式可以檢查它們。

  • isNaN(value) 將其參數轉換為數字,然後測試它是否為 NaN

    alert( isNaN(NaN) ); // true
    alert( isNaN("str") ); // true

    但我們需要這個函式嗎?我們不能只使用 === NaN 比較嗎?很不幸地,不行。NaN 值的獨特之處在於它不等於任何東西,包括它自己。

    alert( NaN === NaN ); // false
  • isFinite(value) 將其參數轉換為數字,如果它是一個常規數字,而不是 NaN/Infinity/-Infinity,則傳回 true

    alert( isFinite("15") ); // true
    alert( isFinite("str") ); // false, because a special value: NaN
    alert( isFinite(Infinity) ); // false, because a special value: Infinity

有時 isFinite 用於驗證字串值是否為一般數字

let num = +prompt("Enter a number", '');

// will be true unless you enter Infinity, -Infinity or not a number
alert( isFinite(num) );

請注意,在所有數字函式(包括 isFinite)中,空白或僅包含空白的字串會被視為 0

Number.isNaNNumber.isFinite

Number.isNaNNumber.isFinite 方法是 isNaNisFinite 函式的「更嚴格」版本。它們不會自動將其參數轉換為數字,而是檢查它是否屬於 number 類型。

  • 如果參數屬於 number 類型且為 NaNNumber.isNaN(value) 會傳回 true。在任何其他情況下,它會傳回 false

    alert( Number.isNaN(NaN) ); // true
    alert( Number.isNaN("str" / 2) ); // true
    
    // Note the difference:
    alert( Number.isNaN("str") ); // false, because "str" belongs to the string type, not the number type
    alert( isNaN("str") ); // true, because isNaN converts string "str" into a number and gets NaN as a result of this conversion
  • 如果參數屬於 number 類型且不為 NaN/Infinity/-InfinityNumber.isFinite(value) 會傳回 true。在任何其他情況下,它會傳回 false

    alert( Number.isFinite(123) ); // true
    alert( Number.isFinite(Infinity) ); // false
    alert( Number.isFinite(2 / 0) ); // false
    
    // Note the difference:
    alert( Number.isFinite("123") ); // false, because "123" belongs to the string type, not the number type
    alert( isFinite("123") ); // true, because isFinite converts string "123" into a number 123

在某種程度上,Number.isNaNNumber.isFiniteisNaNisFinite 函式更簡單且更直接。但在實務上,isNaNisFinite 最常被使用,因為它們的寫法較短。

Object.is 的比較

有一個特殊的內建方法 Object.is,它會像 === 一樣比較值,但對於兩個邊界情況更可靠

  1. 它適用於 NaNObject.is(NaN, NaN) === true,這很好。
  2. 0-0 不同:Object.is(0, -0) === false,技術上來說這是正確的,因為數字在內部有一個符號位元,即使所有其他位元都是零,它也可能不同。

在所有其他情況下,Object.is(a, b)a === b 相同。

我們在此提到 Object.is,因為它經常在 JavaScript 規範中使用。當內部演算法需要比較兩個值是否完全相同時,它會使用 Object.is(在內部稱為 SameValue)。

parseInt 和 parseFloat

使用加號 +Number() 的數字轉換很嚴格。如果值不完全是數字,它就會失敗

alert( +"100px" ); // NaN

唯一的例外是字串開頭或結尾的空格,因為它們會被忽略。

但在現實生活中,我們經常有單位值,例如 CSS 中的 "100px""12pt"。此外,在許多國家中,貨幣符號會出現在金額之後,因此我們有 "19€" 並希望從中提取數字值。

這就是 parseIntparseFloat 的用途。

它們會從字串中「讀取」數字,直到無法讀取為止。如果發生錯誤,則會傳回已收集的數字。函式 parseInt 會傳回整數,而 parseFloat 會傳回浮點數

alert( parseInt('100px') ); // 100
alert( parseFloat('12.5em') ); // 12.5

alert( parseInt('12.3') ); // 12, only the integer part is returned
alert( parseFloat('12.3.4') ); // 12.3, the second point stops the reading

有時候 parseInt/parseFloat 會傳回 NaN。這會發生在無法讀取任何數字時

alert( parseInt('a123') ); // NaN, the first symbol stops the process
parseInt(str, radix) 的第二個參數

parseInt() 函式有一個可選的第二個參數。它指定數字系統的基底,因此 parseInt 也能剖析十六進制數字、二進制數字等等的字串

alert( parseInt('0xff', 16) ); // 255
alert( parseInt('ff', 16) ); // 255, without 0x also works

alert( parseInt('2n9c', 36) ); // 123456

其他數學函式

JavaScript 有內建的 Math 物件,其中包含一個小型數學函式和常數程式庫。

幾個範例

Math.random()

傳回 0 到 1 之間的亂數(不含 1)。

alert( Math.random() ); // 0.1234567894322
alert( Math.random() ); // 0.5435252343232
alert( Math.random() ); // ... (any random numbers)
Math.max(a, b, c...)Math.min(a, b, c...)

傳回任意數量的參數中最大和最小的值。

alert( Math.max(3, 5, -10, 0, 1) ); // 5
alert( Math.min(1, 2) ); // 1
Math.pow(n, power)

傳回 n 乘以給定次方的值。

alert( Math.pow(2, 10) ); // 2 in power 10 = 1024

Math 物件中還有更多函式和常數,包括三角函數,您可以在 Math 物件的說明文件 中找到。

摘要

要寫入有許多零的數字

  • 在數字後加上 "e" 和零的數量。例如:123e6123 加上 6 個零 123000000 相同。
  • "e" 後面為負數會讓數字除以 1 和給定的零。例如:123e-6 表示 0.000123(123 百萬分之一)。

對於不同的數字系統

  • 可以直接寫入十六進制(0x)、八進制(0o)和二進制(0b)系統的數字。
  • parseInt(str, base) 會將字串 str 剖析成給定 base 的數字系統中的整數,2 ≤ base ≤ 36
  • num.toString(base) 會將數字轉換成給定 base 的數字系統中的字串。

對於一般數字測試

  • isNaN(value) 將其參數轉換為數字,然後測試它是否為 NaN
  • Number.isNaN(value) 會檢查其參數是否屬於 number 類型,如果是,則測試它是否為 NaN
  • isFinite(value) 會將其參數轉換成數字,然後測試它是否不是 NaN/Infinity/-Infinity
  • Number.isFinite(value) 會檢查其參數是否屬於 number 類型,如果是,則測試它是否不是 NaN/Infinity/-Infinity

對於將 12pt100px 等值轉換成數字

  • 對於「軟性」轉換,請使用 parseInt/parseFloat,它會從字串中讀取數字,然後傳回在錯誤發生前可以讀取的值。

對於分數

  • 使用 Math.floorMath.ceilMath.truncMath.roundnum.toFixed(precision) 進行四捨五入。
  • 務必記住,在處理分數時會產生精確度損失。

更多數學函數

  • 當您需要時,請參閱 Math 物件。此函式庫非常小,但可以滿足基本需求。

任務

重要性:5

建立一個腳本,提示訪客輸入兩個數字,然後顯示其總和。

執行示範

附註:類型中有一個陷阱。

let a = +prompt("The first number?", "");
let b = +prompt("The second number?", "");

alert( a + b );

請注意 prompt 前的單元正號 +。它會立即將值轉換為數字。

否則,ab 將會是字串,其總和將會是其串接,也就是:"1" + "2" = "12"

重要性:4

根據文件,Math.roundtoFixed 都會四捨五入到最接近的數字:0..4 捨去,而 5..9 捨入。

例如

alert( 1.35.toFixed(1) ); // 1.4

在以下類似的範例中,為什麼 6.35 會捨入到 6.3,而不是 6.4

alert( 6.35.toFixed(1) ); // 6.3

如何正確捨入 6.35

在內部,小數分數 6.35 是無限二進位。與此類情況一樣,它會儲存在精確度損失中。

讓我們看看

alert( 6.35.toFixed(20) ); // 6.34999999999999964473

精確度損失可能會導致數字增加或減少。在此特定情況下,數字會變小一點,這就是它會捨去的原因。

1.35 呢?

alert( 1.35.toFixed(20) ); // 1.35000000000000008882

在此,精確度損失使數字變大了一點,所以它會捨入。

如果我們想要正確捨入 6.35,我們該如何解決這個問題?

我們應該在捨入之前將它更接近整數

alert( (6.35 * 10).toFixed(20) ); // 63.50000000000000000000

請注意,63.5 沒有任何精確度損失。這是因為小數部分 0.5 實際上是 1/2。除以 2 的次方的分數在二進位系統中可以精確表示,現在我們可以捨入它

alert( Math.round(6.35 * 10) / 10 ); // 6.35 -> 63.5 -> 64(rounded) -> 6.4
重要性:5

建立一個函數 readNumber,它會提示輸入數字,直到訪客輸入有效的數字值。

產生的值必須以數字形式傳回。

訪客也可以透過輸入空白列或按「取消」來停止這個程序。在這種情況下,函數應傳回 null

執行示範

開啟一個包含測試的沙盒。

function readNumber() {
  let num;

  do {
    num = prompt("Enter a number please?", 0);
  } while ( !isFinite(num) );

  if (num === null || num === '') return null;

  return +num;
}

alert(`Read: ${readNumber()}`);

這個解決方案比它可能有的更複雜一點,因為我們需要處理 null/空白列。

因此,我們實際上接受輸入,直到它是一個「正規數字」。null(取消)和空白列也符合這個條件,因為在數字形式中,它們是 0

在我們停止後,我們需要特別處理 null 和空行(傳回 null),因為將它們轉換為數字會傳回 0

在沙箱中開啟有測試的解決方案。

重要性:4

這個迴圈是無限的。它永遠不會結束。為什麼?

let i = 0;
while (i != 10) {
  i += 0.2;
}

那是因為 i 永遠不會等於 10

執行它以查看 i實際

let i = 0;
while (i < 11) {
  i += 0.2;
  if (i > 9.8 && i < 10.2) alert( i );
}

它們沒有任何一個等於 10

這種情況會發生,是因為在加法分數時,例如 0.2,會發生精確度損失。

結論:在使用小數分數時,避免等號檢查。

重要性:2

內建函式 Math.random() 會產生一個從 01 的隨機值(不包含 1)。

撰寫函式 random(min, max) 以產生一個從 minmax 的隨機浮點數(不包含 max)。

其運作範例

alert( random(1, 5) ); // 1.2345623452
alert( random(1, 5) ); // 3.7894332423
alert( random(1, 5) ); // 4.3435234525

我們需要將從區間 0…1 的所有值「對應」到從 minmax 的值。

這可以用兩個階段來完成

  1. 如果我們將一個從 0…1 的隨機數字乘以 max-min,則可能值的區間會從 0..1 增加到 0..max-min
  2. 現在,如果我們加上 min,則可能的區間會從 min 變成 max

函式

function random(min, max) {
  return min + Math.random() * (max - min);
}

alert( random(1, 5) );
alert( random(1, 5) );
alert( random(1, 5) );
重要性:2

建立一個函式 randomInteger(min, max),它會產生一個從 minmax 的隨機整數,包含 minmax 作為可能的值。

從區間 min..max 的任何數字都必須以相同的機率出現。

其運作範例

alert( randomInteger(1, 5) ); // 1
alert( randomInteger(1, 5) ); // 3
alert( randomInteger(1, 5) ); // 5

你可以使用前一個任務的解決方案作為基礎。

簡單但錯誤的解決方案

最簡單但錯誤的解決方案是產生一個從 minmax 的值,並將其四捨五入

function randomInteger(min, max) {
  let rand = min + Math.random() * (max - min);
  return Math.round(rand);
}

alert( randomInteger(1, 3) );

這個函式可以運作,但它是不正確的。得到邊緣值 minmax 的機率比任何其他值低兩倍。

如果你多次執行上面的範例,你會很容易看到 2 出現最頻繁。

這是因為 Math.round() 會從區間 1..3 取得隨機數字,並將它們四捨五入如下

values from 1    ... to 1.4999999999  become 1
values from 1.5  ... to 2.4999999999  become 2
values from 2.5  ... to 2.9999999999  become 3

現在,我們可以清楚地看到 1 的值比 2 少兩倍。3 也是一樣。

正確的解法

這個任務有很多正確的解法。其中之一是調整區間邊界。為了確保區間相同,我們可以從 0.5 到 3.5 產生值,從而將所需的機率加到邊緣

function randomInteger(min, max) {
  // now rand is from  (min-0.5) to (max+0.5)
  let rand = min - 0.5 + Math.random() * (max - min + 1);
  return Math.round(rand);
}

alert( randomInteger(1, 3) );

另一種方法是對從 minmax+1 的隨機數使用 Math.floor

function randomInteger(min, max) {
  // here rand is from min to (max+1)
  let rand = min + Math.random() * (max + 1 - min);
  return Math.floor(rand);
}

alert( randomInteger(1, 3) );

現在所有區間都以這種方式對應

values from 1  ... to 1.9999999999  become 1
values from 2  ... to 2.9999999999  become 2
values from 3  ... to 3.9999999999  become 3

所有區間長度相同,使得最後的分配均勻。

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有建議要改進 - 請 提交 GitHub 議題 或發起 pull request,而不是留言。
  • 如果你看不懂文章中的某些內容 – 請說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行 – 將它們包在 <pre> 標籤中,對於超過 10 行 – 使用沙盒 (plnkrjsbincodepen…)