首页 > 技术文章 > 【译文】JS中的整数和移位运算符

hanshuai 2021-02-28 22:29 原文

在JS中只有浮点数。这篇文章解释了在js中整数运算是如何处理的,特别是移位操作。也解释了: n >>> 0 是否是一个好的方式去将一个数字转为一个非负整数。

1 准备工作

因为我们经常查看二进制数字,我们定义了以下方法来帮助我们:

String.prototype.bin = function () {
    return parseInt(this, 2);
};
Number.prototype.bin = function () {
    var sign = (this < 0 ? "-" : "");
    var result = Math.abs(this).toString(2);
    while(result.length < 32) {
        result = "0" + result;
    }
    return sign + result;
}

使用方法如下:

> "110".bin()
6
> 6.bin()
'00000000000000000000000000000110' // 32位

2 JS中的整数

所有的整数操作(比如任何一种按位运算)都遵循相同的模式:将操作数转换为浮点数,然后转换为整数;执行相应的运算;最后将整数结果转换回浮点数。内部使用四种整数类型:

  • 整形:范围为 [−2**53, +2**53] 的值。用于:大多数整数参数(索引,日期等)。可以表示更高和更低的整数,但是只有区间内的整数是连续的。
  • Uint16:16位无符号整数,范围为 [0, 2**16 - 1]。用于:字符代码。
  • Uint32:32位无符号整数,范围为 [0, 2**32 - 1]。用于:数组长度。
  • Int32:32位有符号整数,范围为 [-2**31, +2**31 - 1]。用于:按位取反,二进制按位操作符,无符号移位

2.1 数字转整数

一个数字 n 通过以下公式转为整数:

sign(n) ⋅ floor(abs(n))

直观地,去掉所有小数位。取符号和取绝对值的技巧是必须的,因为floor将一个浮点数转为下一个较低的整数。

> Math.floor(3.2)
3
> Math.floor(-3.2)
-4 // 实际期望的结果 -3

我们使用如下方法来转为整数:

function ToInteger(x) {
    x = Number(x);
    return x < 0 ? Math.ceil(x) : Math.floor(x);
}

我们偏离了常规做法:ECMASscript5.1规范规定(非构造函数)函数名应该以小写字母开头。

2.2 将数字转为Uint32

第一步,将数字转为整数。如果其本身在Uint32的范围内,本身就是整数了(无需转换)。如果不在范围内(比如是个负数),然后我们用模 2**32 来计算。这里注意下,这里的模操作不是JS中的取余运算符 % ,这里的模计算会使数字具有第二个操作数的符号(跟第二个操作数符号相同,要为正也为正,反之为负)。因此,模 2**32 始终为正。直观地解释就是,一个数加上或者减去 2**32 直到数字范围在 [0, 2**32 - 1] 内。下边就是 ToUnit32 的具体实现。

function modulo(a, b) {
    return a - Math.floor(a/b)*b;
}
function ToUint32(x) {
    return modulo(ToInteger(x), Math.pow(2, 32));
}

模操作在计算 2**32 附近的整数的时候结果很明朗。

> ToUint32(Math.pow(2,32))
0
> ToUint32(Math.pow(2,32)+1)
1

> ToUint32(-Math.pow(2,32))
0
> ToUint32(-Math.pow(2,32)-1)
4294967295

> ToUint32(-1)
4294967295

如果我们看一下其二进制数表示形式,转换负数的结果会显得更有意义。取反二进制数,进行按位取反然后再加1(负数的二进制补码表示取反加一)。先求反码然后再计算补码。用4位数字说明下过程:

 0001   1
 1110   ones’ complement of 1  // 取反
 1111   −1, twos’ complement of 1 // 加一
10000   −1 + 1

最后一行解释了为什么再位数固定的情况下补码是负数:将 1 加到 1111 上的结果是0,忽略第五位。ToUint32 产生的32位二进制补码为:

> ToUint32(-1).bin()
'11111111111111111111111111111111'

// 补充 
> (4294967295).bin()
"11111111111111111111111111111111"

2.3 将数字转为Int32

转一个数字为Int32,我们首先把它转为Uint32。如果设置了它的最高位(如果大于或等于 231),则减去232将其变为负数(232 = 232 + 1 = 4294967295 + 1)。(想不通的同学可以以8个bit位去思考下,[-128, 127] [0, 255] 区间的个数都是256)

function ToInt32(x) {
    var uint32 = ToUint32(x);
    if (uint32 >= Math.pow(2, 31)) {
        return uint32 - Math.pow(2, 32)
    } else {
        return uint32;
    }
}

结果:

> ToInt32(-1)
-1
> ToInt32(4294967295)
-1

3 移位操作符

JS总共有3种移位操作符:

  • 有符号左移 <<
  • 有符号右移 >>
  • 无符号右移 >>>

3.1 有符号右移

有符号右移x位相当于除以 2**x。

> -4 >> 1
-2 // 相当于 -4 / (2**1)
> 4 >> 1
2 // 相当于 4 / (2**1)

在二进制级别,我们看到数字右移的时候,最高位是保持不变的(符号位填充空位)

> ("10000000000000000000000000000010".bin() >> 1).bin()
'11000000000000000000000000000001'

3.2 无符号右移

无符号右移很简单:只移动比特位,0填充左侧。

> ("10000000000000000000000000000010".bin() >>> 1).bin()
'01000000000000000000000000000001'

符号位没有保留,返回的结果总是Uint32.

> -4 >>> 1
2147483646

3.3 左移

左移x位相当于乘以 2**x。

> 4 << 1
8 // 相当于 -4 * (2**1)
> -4 << 1
-8 // 相当于 -4 * (2**1)

对于左移,有符号和无符号操作是无法区分的。

> ("10000000000000000000000000000010".bin() << 1).bin()
'00000000000000000000000000000100'

为了了解原因,我们再次转向4个bit位的二进制数的移动1位。有符号左移意味着如果在移位前符号位是1,移位后也是1.如果有一个数字可以观察到有符号和无符号左移之间的差异,那么这个数的第二个最高位必须位0(否则在任何情况下最高位都为1)。也就是说,它必须看起来像这样:

10____

无符号左移的结果是 0____0。对于有符号移动1位,我们可以假设它试图保持负号,因此将最高位保留最高位位1。给定这样的移位我们应该乘以2,我们将 1001(-7)移位为 1010(-6)为例。

另一种看待它的方式是,对于负数,最高位是1。剩余的位越低,则数字越小。比如,最低的4位负数

1000 (−8, the twos’ complement of itself)

任何 10____ 格式的可能值为 (-5(-1011), -6(-1010), -7(-1001), -8(-1000)).但是这些乘以2会超出范围。因此,有符号的移位是没有意义的。

4 ToUint32和ToInt32的替代实现

无符号移位将其左侧转换为Uint32,有符号移位转为Int32。移位位0位就会返回转换后的值。

function ToUint32(x) {
    return x >>> 0;
}
function ToInt32(x) {
    return x >> 0;
}

5 总结

你是否需要执行此处所示的ToInteger,ToUint32,ToInt32 方法之一?在这三个中,只有ToInteger在开发中常用。但是你还有其他选择可以转换为整数:

  • Math.floor()转换它的参数为最接近的低整数
> Math.floor(3.8)
3
> Math.floor(-3.8)
-4
  • Math.ceil()转换它的参数为最接近的高整数
> Math.ceil(3.2)
4
> Math.ceil(-3.2)
-3
  • Math.round()转换它的参数为最接近整数(四舍五入),比如:
> Math.round(3.2)
3
> Math.round(3.5)
4
> Math.round(3.8)
4

对-3.5进行四舍五入的结果有些不符合预期

> Math.round(-3.2)
-3
> Math.round(-3.5)
-3
> Math.round(-3.8)
-4

因此,Math。round(x)类似于

Math.ceil(x + 0.5)

避免使用parseInt()传递预期的结果,但是可以通过将其参数转换为字符串,然后解析任何有效整数的前缀来实现。

在实际中,由ToUint32和ToInt32执行的模运算很少有用。以下等效于ToUint32的值有时用于将任何值转换为非负整数。

value >>> 0

单个表达式具有很多魔力!通常将其拆分为多个语句表达式。如果值小于0或者不是数字,你甚至可能想抛出个异常。这样可以避免 >>> 操作符的一些情况:

> -1 >>> 0
4294967295

6 参考

  1. How numbers are encoded in JavaScript

原文地址:Integers and shift operators in JavaScript
作者:Dr. Axel Rauschmayer

推荐阅读