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" 的内容,或为 73 亿编写 "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) 返回 num 在具有给定 base 的数字系统中的字符串表示形式。

例如

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。这会让任何人感到惊讶。

但为什么会发生这种情况呢?

一个数字以其二进制形式存储在内存中,即一系列位——1 和 0。但是像 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.1正好 0.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 类型并且它是 NaN,则 Number.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/-Infinity,则 Number.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" 和零的计数。比如:123e6 等于 123 后面有 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

创建一个脚本,提示访客输入两个数字,然后显示它们的和。

运行演示

P.S. 类型存在一个陷阱。

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,它会提示输入数字,直到访客输入一个有效的数字值。

返回的值必须为数字。

访问者还可以通过输入空行或按“CANCEL”来停止进程。在这种情况下,该函数应返回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. 如果我们用max-min乘以 0…1 之间的随机数,则可能值的区间会从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 问题或提交拉取请求,而不是发表评论。
  • 如果您无法理解文章中的某些内容 - 请详细说明。
  • 要插入一些代码,请使用<code>标记,对于多行 - 将其包装在<pre>标记中,对于超过 10 行 - 使用沙盒 (plnkrjsbincodepen…)