你知道但不完全知道的 ECMAScript 位运算

351 阅读20分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

前排售卖矿泉水 🚰🚰🚰~~

开开心心做需求

前阵子接了一个需求,咋一看是个比较有意思的需求,然鹅实际做下来才发现问题多多,一把辛酸泪😢...

**需求:需要在前端增加一个配置项,生成一个数字给到后端,这个数字用来控制一系列配置项的开启/关闭。 **

需求的实际体现为:这个数字转化为二进制后的每一位都对应着一个开关,为了方便用户操作,需要把开关配置项都列出来供用户勾选,然后前端根据用户的勾选组合来计算出最终的配置数字给后端。

接到需求的时候心里就已经有了腹稿:使用 ECMAScript 中出场率比较低的 二进制位运算符 来直接对指定的二进制位进行操作,具体来说就是使用 << | ~ & 来实现。

开始表演.jpeg

其他的界面相关代码略过不表,这里贴一下对开关进行切换的主要代码,通过 val |= flag;val &= ~flag; 对目标位进行切换操作,代码如下:

/**
 * 根据指定的是否打开和标识位编号对标识位集进行处理,返回处理后的10进制数
 * @param open 标识位是否需要打开
 * @param before 被处理的标识位集
 * @param bitNumber 需要处理的标识位的编号 从 1 开始
 * @returns 返回处理之后的标识位集代表的 10 进制数
 */
export function setFlag(open: boolean, before: number, bitNumber: number) {
  const flag = 1 << (bitNumber - 1); // 标识位

  let val = before;
  if (open) {
    val |= flag; // 将标识位设为 1
  } else {
    val &= ~flag; // 将标识位设为 0
  }

  return val;
}

上面的代码咋一看是没有什么问题的,先根据需要处理的位阶得到标识数,然后使用标识数与原值进行位运算得到新值。

组件放页面上去点点点,一通操作发现完全没问题~

哇,平平无奇的小天才说的就是我了,得瑟~~~

平平无奇.jpeg

但是位运算这东西用的比较少,心里没底,所以我给这个操作方法添加了一些单元测试:

// 测试用例
  it('setFlag', () => {
    expect(setFlag(true, 0b0, 3)).toBe(0b100);
    expect(setFlag(false, 0b0, 3)).toBe(0b0);
    expect(setFlag(true, 0b111, 1)).toBe(0b111);
    expect(setFlag(false, 0b111, 1)).toBe(0b110);
    expect(setFlag(true, 0b1, 3)).toBe(0b101);
    expect(setFlag(false, 0b1, 3)).toBe(0b1);
    expect(setFlag(true, 0b111, 3)).toBe(0b111);
    expect(setFlag(false, 0b111, 3)).toBe(0b11);
    expect(setFlag(false, 0b111, 2)).toBe(0b101);
    // 这一条测试未通过
    expect(setFlag(true, 0, 53)).toBe(4503599627370496);
  });

可是这时出现问题了,最后一条没有通过,而且报错很离谱。

心态崩了.jpeg

裂开.jpeg

最后一条测试未通过:

    //     Expected: 4503599627370496
    //     Received: 1048576

返回值为 1048576 ,这也太小了吧,这难道是溢出了?顿时心里哇凉哇凉...

哭哭啼啼查资料

心凉嘛?凉!但是我不能哭,因为骑电动车的时候擦眼泪不安全。

勇敢的打工人.jpeg

此时我的脑海中仿佛出现了一句话:我们遇到什么困难都不要怕,微笑着面对他,克服恐惧的最好方法就是面对恐惧,加油,奥利给!

经过一整天的资料查询,我终于知道了问题的来龙去脉。我,又站起来了!!!

下面我就将这至少价值 “半年工作经验” 的知识告诉来到这里的有缘人,虽然你们甚至都不肯喊我一声 ”靓仔“ ...

你知道但没有完全知道的背景知识

Number 类型大部分人可能都觉得太过于简单,没有深入的进行了解。通常情况下是不会有什么问题的,毕竟在 ECMAScript 中 1 + 1 === 2 和我们学的代数没多大区别。

而对于 二进制位运算符 ,由于出场率实在低的可怜,更是甚少有人去深入了解。

下面先讲讲个人认为的大部分人对于上面二者的认识,然后介绍一下根据一些参考资料总结出来的重点部分。

先说点大家都知道的

说起 ECMAScript 的数字,可能大部分前端同学就只知道不管是整数还是小数都是 Number 类型。

然后逛掘金比较多的话,还可能还听说过 0.1 + 0.2 !== 0.3 这样的传说,隐约知道在 ECMAScript 的世界中进行小数运算是不可靠的。

要是问起原因可能就不太清楚了。别问,问就是 ECMAScript 辣鸡。

然后要说起位运算,对于大部分前端同学来说可能比较懵。位运算啊,还要去理解二进制,那不是后端的事情嘛,关我前端什么事。

家庭条件比较好,通了网的同学,可能会在网上学了一两招js中位运算的骚操作:使用 ~~ 对数字取整等。

虽然这类文章都会提一嘴这些操作的原理,但是啊,大部分人(包括本人):代码拿来吧,原理自己自己出门回家吧,记得把门带上。毕竟前端很少会去处理超过 2 ^ 31 的整数,所以一般也都不会出现什么问题。

ECMAScript 中的数字

既然现在遇到问题了,那就有必要详细了解一下 ECMAScript 中的数字了。

首先在 Number 文档中有下面一句话:

JavaScript 的 Number 类型为 双精度IEEE 754 64位浮点 类型。

后面的内容有点干,建议去往前排买点农夫山泉~~

满脸坚毅看规范

我们查看规范,可以发现:

Double precision (binary64), usually used to represent the "double" type in the C language family (though this is not guaranteed). This is a binary format that occupies 64 bits (8 bytes) and its significand has a precision of 53 bits (about 16 decimal digits).

双精度(binary64),通常用于表示 C 语言家族中的“double”类型(虽然这不能保证)。这是一种二进制格式,占用 64 位(8 个字节),其有效位精度为 53 位(约 16 位十进制数字)。

然后对于每个比特位的含义的如下表:

TypeSignExponentSignificand fieldTotal bitsExponent biasBits precisionNumber of decimal digits
Half (IEEE 754-2008)1510161511~3.3
Single18233212724~7.2
Double1115264102353~15.9
x86 extended precision11564801638364~19.2
Quad11511212816383113~34.0
类型标志指数有效位字段总位数指数偏差位精度小数位数
HalfIEEE 754-20081510161511~3.3
Single18233212724~7.2
Double1115264102353~15.9
x86 extended precision11564801638364~19.2
Quad11511212816383113~34.0

我们可以看 Double 这一行,1 位作为符号位,11 位作为指数位,52 位作为有效位。

In the IEEE binary interchange formats the leading 1 bit of a normalized significand is not actually stored in the computer datum. It is called the "hidden" or "implicit" bit. Because of this, the single-precision format actually has a significand with 24 bits of precision, the double-precision format has 53, and quad has 113.

在 IEEE 二进制交换格式中,规范化有效数的前导 1 位实际上并未存储在计算机数据中。它被称为“隐藏”或“隐式”位。正因为如此,单精度格式实际上有一个24位精度的有效数,双精度格式有53位,quad有113位。

这就是为什么 Double 只有 52 位作为有效位,但是位精度为 53 位。

下面是 Double 的位排列图:

618px-IEEE_754_Double_Floating_Point_Format

更详细的规范可以看 Double-precision floating-point format

嘻嘻哈哈说人话

上面是规范,下面我再讲点有用的。

结论先放前面:
1、ECMAScript 中的 Number 类型能够安全表示的最大整数为:2 ^ 53 - 1 = 9007199254740991
2、精度有效位为 53 个,所以最多可以准确标识 53 个二进制位的状态

64 位双精度浮点型,能够安全表示的最大整数为:2 ^ 53 - 1 = 9007199254740991 ,有一个常量表示这个值 Number.MAX_SAFE_INTEGER

那为什么要 - 1 呢?

其实 2 ^ 53 = 9007199254740992 这个数也是准确的,但是这个数和 9007199254740993 的二进制表示是一样的。这样 9007199254740992 就不安全了,因为你不知道他是 9007199254740992 还是 9007199254740993。

// 9007199254740992 和 9007199254740993 在 ECMAScript 中的二进制表示是一样的,所以无法做区分
(9007199254740992).toString(2)
// "100000000000000000000000000000000000000000000000000000"
(9007199254740993).toString(2)
// 本来 “应该是” 100000000000000000000000000000000000000000000000000001
// "100000000000000000000000000000000000000000000000000000"
9007199254740992 === 9007199254740993
// true

反向操作也是一样的结论。

// 对于 54 位的二进制数,在转化为 十进制时,最后一位被舍弃,导致 Number 类型 (10 进制数)最多只能准确表示 53 位二进制数
parseInt("100000000000000000000000000000000000000000000000000001", 2)
// 9007199254740992
parseInt("100000000000000000000000000000000000000000000000000000", 2)
// 9007199254740992

还不信邪,再看看这个:

var a = 9007199254740993
// undefined
a
// 再次读取的时候发现读出来的是 9007199254740992 ,因为 9007199254740993 的最后一个二进制位被舍入了
// 9007199254740992 

在文章 JavaScript浮点运算0.2+0.1 !== 0.3 中深入浅出的讲解了 ECMAScript 中的十进制和二进制转化的规则,可以很好的帮助我们理解为什么 64 位的双精度浮点型,却只能最大表示 53 位二进制位 ———— 只有 52 位是数值位,还有一位默认位。

还有疑问可以在 Double (IEEE754 Double precision 64-bit) 更形象的看到数字的实际存储情况

这里再插入一句题外话,超过 9007199254740991 的数在 ECMAScript 中就不能表示了嘛?其实是可以的,只不过这个数将不再准确,精度会降低。按照我的理解,这个数代表的不再是一个确切的数,而更像是一个范围。例如:9007199254740992 不再是准确的 9007199254740993,而是代表 9007199254740992 - 9007199254740993 这样的一个范围。

Precision limitations on integer values

  • Integers from −2 ^ 53 to 2 ^ 53 (−9,007,199,254,740,992 to 9,007,199,254,740,992) can be exactly represented
  • Integers between 2 ^ 53 and 2 ^ 54 = 18,014,398,509,481,984 round to a multiple of 2 (even number)
  • Integers between 2 ^ 54 and 2 ^ 55 = 36,028,797,018,963,968 round to a multiple of 4

整数值的精度限制

  • 可以精确表示从 -2 ^ 53到 2 ^ 53(-9,007,199,254,740,992 到 9,007,199,254,740,992)的整数
  • 2 ^ 53和 2 ^ 54之间的整数= 18,014,398,509,481,984 舍入为 2 的倍数(偶数)
  • 2 ^ 54和 2 ^ 55之间的整数= 36,028,797,018,963,968 舍入为 4 的倍数

ECMAScript 中数字进行位运算最大支持位数

看规范 - 晕头转向

首先是搜索 mdn ,找到了 按位与 (&),这里有提到位运算会对操作数进行转化。

操作数被转换为32位整数,并由一系列位(0和1)表示。 超过32位的数字将丢弃其最高有效位。

32 位?Number 类型不是 64 位嘛?这个 32 是咋来的。稳妥起见,跟随页面下方的【规范说明】链接去到ECMAScript (ECMA-262) Bitwise AND expression 看看情况。

emmm...... 看了个寂寞,跳转过去的部分压根和操作数转换没有联系......

但是,这区区挫折怎么能难倒我呢,上面不是提到了数字类型嘛,那去数字类型的规范部分会不会找到这些呢。果然,在 6.1.6.1.16 NumberBitwiseOp 这里找到了位运算时的执行步骤。

6.1.6.1.16 NumberBitwiseOp ( op, x, y )
The abstract operation NumberBitwiseOp takes arguments op (&, ^, or |), x (a Number), and y (a Number). It performs the following steps when called:

> 1. Let lnum be ! ToInt32(x).
> 2. Let rnum be ! ToInt32(y).
> 3. Let lbits be the 32-bit two's complement bit string representing ℝ(lnum).
> 4. Let rbits be the 32-bit two's complement bit string representing ℝ(rnum).
> 5. If op is &, let result be the result of applying the bitwise AND operation to lbits and rbits.
> 6. Else if op is ^, let result be the result of applying the bitwise exclusive OR (XOR) operation to lbits and rbits.
> 7. Else, op is |. Let result be the result of applying the bitwise inclusive OR operation to lbits and rbits.
> 8. Return the Number value for the integer represented by the 32-bit two's complement bit string result.

6.1.6.1.16 NumberBitwiseOp ( op , x , y )
抽象操作 NumberBitwiseOp 接受参数op ( &, ^, or |), x (数字), 和y (数字)。它在调用时执行以下步骤:

> 1. 定义 `lnum` 成为! ToInt32( x )。  
> 2. 定义 `rnum` 为 ! ToInt32(ÿ)。  
> 3. 定义 `lbits`32 位二进制补码位串,表示ℝ( lnum )。  
> 4. 定义 `rbits`32 位二进制补码位串,表示ℝ( rnum )。  
> 5. 如果 `op``&` ,则定义 `result` 是对 `lbits``rbits` 应用按位与运算的结果。  
> 6. 否则,如果 `op``^`,则定义 `result` 是对 `lbits``rbits` 应用按位异或 (XOR) 运算的结果。  
> 7. 否则, `op``|`。令 `result` 是对 `lbits``rbits` 应用按位或运算的结果。  
> 8. 返回数值为一个由 32 位二进制补码位串 `result` 表示的整数转化而成 `Number` 类型的值。  

也就是说,如果是进行位运算,会先将两个操作数都进行 ToInt32 操作,然后再将结果转化回 Number。

ToInt32 操作又是怎样的操作呢?我们继续看下去

7.1.6 ToInt32 ( argument )
The abstract operation ToInt32 takes argument argument. It converts argument to one of 2 ^ 32 integral Number values in the range 𝔽(-231) through 𝔽(231 - 1), inclusive. It performs the following steps when called:
> > 1. Let number be ? ToNumber(argument).
> 2. If number is NaN, +0𝔽, -0𝔽, +∞𝔽, or -∞𝔽, return +0𝔽.
> 3. Let int be the mathematical value whose sign is the sign of number and whose magnitude is floor(abs(ℝ(number))).
> 4. Let int32bit be int modulo 2 ^ 32.
> 5. If int32bit ≥ 2 ^ 31, return 𝔽(int32bit - 232); otherwise return 𝔽(int32bit).

7.1.6 ToInt32 ( argument )
抽象操作 ToInt32 接受参数argument。它将参数转换为一个在 𝔽(-2 ^ 31) 到 𝔽(2 ^ 31 - 1)范围内的 2 ^ 32 个整数之一。它在调用时执行以下步骤:
>
> 1. 定义 number 为 ToNumber(argument) 的返回值。
> 2. 如果 number 是 NaN, +0𝔽 ,-0𝔽 ,+∞𝔽 , 或-∞𝔽,返回+0𝔽 .
> 3. 定义 int 为数字,它的符号位和 number 的符号位相同,其大小为 floor(abs(ℝ(number))).。
> 4. 定义 int32bit 为 int 对 2 ^ 32 取模。
> 5. 如果 int32bit ≥ 2 ^ 31,则返回𝔽( int32bit - 2 ^ 32 ); 否则返回𝔽( int32bit )。

所谓的 ToInt32 就是将参数转变为一个 32 位有符号整数,但是这个整数还是使用的 Double 类型返回的,所以可能有点反直觉。

我们以 -4294967295 | 0 为例来走一遍步骤:

    1. lunm 为 -4294967295
    • number 为 -4294967295
    • 跳过
    • 跳过
    • int32bit 为 -4294967295
    • 返回 -4294967295
    1. rnum 为 0
    1. lbits 为 0000000000000000000000000001
    1. rbits 为 0000000000000000000000000000
    1. 跳过
    1. 跳过
    1. result 为 0000000000000000000000000001
    1. 返回 1

可以发现在 1、2 和 3、4 都会对操作数进行转化,其中 1、2 的作用主要是将操作数转化为一个整数,然后 3、4 直接截取对应二进制的低 32 位。

根据上面的转化规则,我们可以知道:转化中的精度有效位是 31 位,因为 32 位是作为符号位。

说人话 - 一身轻松

同样,结论放前面:

  1. 位操作符在操作过程中会将 Double 类型转化为 Int 类型进行操作
  2. 由于存在转化行为,导致精度下降,表现为精度有效位由 53 位降为 31

ECMAScript 中的位操作符会先将操作数转化为 32 位二进制整数,然后得到两个操作数的补码,再进行按位操作,最后再返回一个 Double 类型的数据。

这样就导致位操作符最多支持 31 位二进制位的操作,如果多出则会溢出导致结果不和预期。

超出会溢出比较好理解,但是 32 位有符号整型 为什么只能准确表示 31 个二进制位可能会有点疑问。

这是因为如果读取的时候同样使用 32 位有符号整型 来读取,的确可以准确读出 32 个二进制位,但是由于返回时是使用 Double 类型,导致第 32 位丢失。

20210929 经过小伙伴提醒补充:
如果只考虑二进制位,那么第 32 位仍然是准确的,不准确的是 Double 类型对应的数值。也就是说虽然对应的数值不再准确,但是对应的低 32 位仍然是准确的。
当然,此处的二进制位准确也只限于低 32 位,如果发生溢出,那么高位将会完全不同。也就是说如果相信了第 32 位,那么高位将完全不可信,这点也需要注意。
所以一般来说,认为只有 31 位是准确的更加合理一点。

下面看几个例子辅助理解:

Math.pow(2, 31) // 2147483648

2147483648 | 0
// -2147483648 // 第 32 位丢失了

2147483647 | 0
// 2147483647 // 没有问题

Math.pow(2, 52) // 4503599627370496
1<< 52 // 1048576 - 溢出


// 20210929 补充示例
-2147483648 & 2147483648
// -2147483648 // 在低 32 位上,-2147483648 和 2147483648 的二进制码是一样的

// 2147483649 和 -2147483647 的低 32 位是相同的,都是第 1 位和第 32 位为 1,其他全部为 0
(2147483649).toString(2)
// "10000000000000000000000000000001"
2147483649 | 0
// -2147483647
-2147483647 & 1
// 1
-2147483647 & -2147483648
// 2147483648

我又回来了

根据上面得到的力量,我们得出下面的结论:

使用数字来表示二进制开关,前端最多支持到 53 位。而如果还需要对数字进行位运算,则最多支持 31 位,超出则会发生精度丢失。

所以下次再遇到这种二进制位的需求,心里默念 5331 三遍,不要等到开发了才发现出了幺蛾子...

你问我怎么解决开始遇到的问题?

既然知道了问题出在了哪,那解决起来就比较容易了。

BigInt

BigInt 是一种内置对象,它提供了一种方法来表示大于 2 ^ 53 - 1 的整数。这原本是 Javascript中可以用 Number 表示的最大数字。BigInt 可以表示任意大的整数。

同时除 >>> (无符号右移)之外的 位操作 也可以支持。也就是说可以进行任意位阶的二进制位操作。

当然,不要以为 BigInt 支持了任意位阶的二进制位操作,就可以为所欲为了。别忘了 HTTP 传输的时候还是得转换成 Number 才能够传给后端🐶

9007199254740992 | 0 // 0

9007199254740992n | 0n // 9007199254740992n

优点:

  • 可以将位运算支持的位数限制取消,也就是不再要求最大 31

缺点:

  • 兼容性有影响
  • 只能自 high ,一旦需要进行数据传输,还得转化为 Number 类型,超过 53 位部分会被舍去

数组 yyds

既然数字进行位操作有限制,那么我们可以把数字转化成二进制位,然后数组存起来。这样需要操作哪位就操作哪位,完全不用管数据被转化的问题。

首先将数字转换为数组,数组的每一项存储一个二进制位,操作完指定位的值后再转换为十进制值。受限于 Number 的实现,最大支持二进制位数是 53 位。

/**
 * 将传入的10进制数转换为二进制位的数组
 * @param val 开关集的10进制值
 * @returns 开关每一位的开关状态数组
 */
export function getBytesFromNumber(val?: number) {
  if (val === undefined || val === null) return [];

  const bytes = (val || 0)
    .toString(2)
    .split('')
    .map((s: string) => +s)
    .reverse();

  return bytes;
}

/**
 * 根据指定的是否打开和标识位编号对标识位集进行处理,返回处理后的10进制数,最大支持 53 位二进制
 * 中间计算使用数组实现,而不是位运算,因为位运算最大支持到 31 位
 * @param open 标识位是否需要打开
 * @param before 被处理的标识位集
 * @param bitNumber 需要处理的标识位的编号 从 1 开始
 * @returns 返回处理之后的标识位集代表的 10 进制数
 */
export function setFlag(open: boolean, before: number, bitNumber: number) {
  const preArr = getBytesFromNumber(before);

  preArr[bitNumber - 1] = open ? 1 : 0;

  // 根据二进制转十进制的方法计算出对应的10进制值
  // 如果使用 parseInt 还需要将未赋值的位填 0
  const res = preArr.reduce((total, bit, rank) => total + bit * Math.pow(2, rank), 0);

  return res;
}

优点:

  • 理解容易,不像位运算有理解成本
  • 不用考虑兼容性

缺点:

  • 需要将数组和数字进行转化
  • 最大支持位数仍然受限于 Number 类型,最大只能支持 53 个二进制位

什么?你还问超过了 53 位怎么办?

同学你怕不是来买瓜的吧?

买瓜.jpg

哎哎哎,有话好好说,我告诉你就是了。

你啊,出门右拐,看看 Bitmap简介 。待你学成之时,任意位的数据处理都能搞定

对了,忘了和你说了,此秘籍须得和后端同学同修,一个人是练不成的,加油~~

参考资料

Number 类型部分

位运算部分