小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
前排售卖矿泉水 🚰🚰🚰~~
开开心心做需求
前阵子接了一个需求,咋一看是个比较有意思的需求,然鹅实际做下来才发现问题多多,一把辛酸泪😢...
**需求:需要在前端增加一个配置项,生成一个数字给到后端,这个数字用来控制一系列配置项的开启/关闭。 **
需求的实际体现为:这个数字转化为二进制后的每一位都对应着一个开关,为了方便用户操作,需要把开关配置项都列出来供用户勾选,然后前端根据用户的勾选组合来计算出最终的配置数字给后端。
接到需求的时候心里就已经有了腹稿:使用 ECMAScript 中出场率比较低的 二进制位运算符 来直接对指定的二进制位进行操作,具体来说就是使用 << | ~ & 来实现。
其他的界面相关代码略过不表,这里贴一下对开关进行切换的主要代码,通过 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;
}
上面的代码咋一看是没有什么问题的,先根据需要处理的位阶得到标识数,然后使用标识数与原值进行位运算得到新值。
组件放页面上去点点点,一通操作发现完全没问题~
哇,平平无奇的小天才说的就是我了,得瑟~~~
但是位运算这东西用的比较少,心里没底,所以我给这个操作方法添加了一些单元测试:
// 测试用例
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);
});
可是这时出现问题了,最后一条没有通过,而且报错很离谱。
最后一条测试未通过:
// Expected: 4503599627370496
// Received: 1048576
返回值为 1048576 ,这也太小了吧,这难道是溢出了?顿时心里哇凉哇凉...
哭哭啼啼查资料
心凉嘛?凉!但是我不能哭,因为骑电动车的时候擦眼泪不安全。
此时我的脑海中仿佛出现了一句话:我们遇到什么困难都不要怕,微笑着面对他,克服恐惧的最好方法就是面对恐惧,加油,奥利给!
经过一整天的资料查询,我终于知道了问题的来龙去脉。我,又站起来了!!!
下面我就将这至少价值 “半年工作经验” 的知识告诉来到这里的有缘人,虽然你们甚至都不肯喊我一声 ”靓仔“ ...
你知道但没有完全知道的背景知识
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 位十进制数字)。
然后对于每个比特位的含义的如下表:
| Type | Sign | Exponent | Significand field | Total bits | Exponent bias | Bits precision | Number of decimal digits | |
|---|---|---|---|---|---|---|---|---|
| Half (IEEE 754-2008) | 1 | 5 | 10 | 16 | 15 | 11 | ~3.3 | |
| Single | 1 | 8 | 23 | 32 | 127 | 24 | ~7.2 | |
| Double | 1 | 11 | 52 | 64 | 1023 | 53 | ~15.9 | |
| x86 extended precision | 1 | 15 | 64 | 80 | 16383 | 64 | ~19.2 | |
| Quad | 1 | 15 | 112 | 128 | 16383 | 113 | ~34.0 |
| 类型 | 标志 | 指数 | 有效位字段 | 总位数 | 指数偏差 | 位精度 | 小数位数 | |
|---|---|---|---|---|---|---|---|---|
| Half(IEEE 754-2008) | 1 | 5 | 10 | 16 | 15 | 11 | ~3.3 | |
| Single | 1 | 8 | 23 | 32 | 127 | 24 | ~7.2 | |
| Double | 1 | 11 | 52 | 64 | 1023 | 53 | ~15.9 | |
| x86 extended precision | 1 | 15 | 64 | 80 | 16383 | 64 | ~19.2 | |
| Quad | 1 | 15 | 112 | 128 | 16383 | 113 | ~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 的位排列图:
更详细的规范可以看 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 为例来走一遍步骤:
-
- lunm 为 -4294967295
- number 为 -4294967295
- 跳过
- 跳过
- int32bit 为 -4294967295
- 返回 -4294967295
-
- rnum 为 0
-
- lbits 为 0000000000000000000000000001
-
- rbits 为 0000000000000000000000000000
-
- 跳过
-
- 跳过
-
- result 为 0000000000000000000000000001
-
- 返回 1
可以发现在 1、2 和 3、4 都会对操作数进行转化,其中 1、2 的作用主要是将操作数转化为一个整数,然后 3、4 直接截取对应二进制的低 32 位。
根据上面的转化规则,我们可以知道:转化中的精度有效位是 31 位,因为 32 位是作为符号位。
说人话 - 一身轻松
同样,结论放前面:
- 位操作符在操作过程中会将
Double类型转化为Int类型进行操作- 由于存在转化行为,导致精度下降,表现为精度有效位由 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 位,超出则会发生精度丢失。
所以下次再遇到这种二进制位的需求,心里默念 53 和 31 三遍,不要等到开发了才发现出了幺蛾子...
你问我怎么解决开始遇到的问题?
既然知道了问题出在了哪,那解决起来就比较容易了。
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 位怎么办?
同学你怕不是来买瓜的吧?
哎哎哎,有话好好说,我告诉你就是了。
你啊,出门右拐,看看 Bitmap简介 。待你学成之时,任意位的数据处理都能搞定
对了,忘了和你说了,此秘籍须得和后端同学同修,一个人是练不成的,加油~~
参考资料
Number 类型部分
- 二进制位运算符
- JS48 JS中的二进制运算和按位操作符
- 二进制的科学计数法?白话谈谈计算机如何存储与理解小数:IEEE 754
- js >>> 0 谈谈 js 中的位运算
- JavaScript浮点运算0.2+0.1 !== 0.3
- Number
- BigInt
- Floating-point arithmetic
- Double-precision floating-point format
- Double (IEEE754 Double precision 64-bit)
- zh.wikipedia.org/wiki/%E8%BF…
- js中位运算的骚操作
位运算部分