【JS红宝书¹⁰】位操作符

228 阅读9分钟

引子

本文讲述为 ECMA-262 中描述可用于操作数据值的操作符之 位操作符;ECMAScript 中的操作符是独特的,因为它们可用于各种值,包括字符串、数值、布尔值,甚至还有对象;在应用给对象时,操作符通常会调用 valueOf()toString() 方法来取得可以计算的值。

位操作符

有符号整数使用 32 位的前 31 位表示整数值;第 32 位表示数值的符号,如 0 表示正,1 表示负,这一位称为符号位(sign bit),它的值决定了数值其余部分的格式。

正值

正值以真正的二进制格式存储,即 31位中的每一位都代表 2 的幂;第一位(称为第 0 位)表示 20 ,第二位表示 21 ,依此类推。如果一个位是空的,则以0填充,相当于忽略不计。

比如,数值18的二进制格式为00000000000000000000000000010010,或更精简的 10010,后者是用到的 5 个有效位,决定了实际的值。

负值

负值以一种称为二补数(或补码)的二进制编码存储;

一个数值的二补数通过如下 3 个步骤计算得到:

  1. 确定绝对值的二进制表示(如,对于-18,先确定 18 的二进制表示);
  2. 找到数值的一补数(或反码),换句话说,就是每个 0 都变成 1,每个 1 都变成 0;
  3. 给结果加 1。

基于上述步骤负值转换的过程,如下图所示:

那么,-18 的二进制表示就是 11111111111111111111111111101110。

要注意的是,在处理有符号整数时,我们无法访问第 31 位。

ECMAScript 会帮我们记录这些信息;在把负值输出为一个二进制字符串时,我们会得到一个前面加了减号的绝对值,如下所示:

 let num = -18; 
 console.log(num.toString(2)); // "-10010" 

在将负值转换为二进制字符串时,转换过程会求得二补数,然后再以更符合逻辑的形式表示出来。

注意: 默认情况下,ECMAScript 中的所有整数都表示为有符号数。不过,确实存在无符号整数;对无符号整数来说,第 32 位不表示符号,因为只有正值;无符号整数比有符号整数的范围更大,因为符号位被用来表示数值了。

小知识

在对 ECMAScript 中的数值应用位操作符时,后台会发生转换:64 位数值会转换为 32 位数值,然后执行位操作,最后再把结果从 32 位转换为 64 位存储起来;整个过程就像处理 32 位数值一样,这让二进制操作变得与其他语言中类似;但这个转换也导致了一个奇特的副作用,即特殊值NaN 和Infinity在位操作中都会被当成 0 处理

如果将位操作符应用到非数值,那么首先会使用 Number() 函数将该值转换为数值(这个过程是自动的),然后再应用位操作,最终结果是数值。

按位非

按位非是 ECMAScript 中为数不多的几个二进制数学操作符之一,用波浪符(~)表示,它的作用是返回数值的一补数;如下所示:

 let num1 = 25; // 二进制 00000000000000000000000000011001 
 let num2 = ~num1; // 二进制 11111111111111111111111111100110 
 console.log(num2); // -26 

这里,按位非操作符作用到了数值 25,得到的结果是 -26;由此可以看出,按位非的最终效果是对数值取反并减 1,就像执行如下操作的结果一样:

 let num1 = 25; 
 let num2 = -num1 - 1; 
 console.log(num2); // "-26" 

尽管两者返回的结果一样,但位操作的速度快得多;这是因为位操作是在数值的底层表示上完成的。

按位与

按位与操作符用和号(&)表示,有两个操作数;

规则: 按位与操作在两个位都是 1 时返回 1,在任何一位是 0 时返回 0

本质上,按位与就是将两个数的每一个位对齐,然后基于真值表中的规则,对每一位执行相应的与操作,如下表所示:

第一个数值的位第二个数值的位结果
111
100
010
000

再来看下面这个例子:

 let result = 25 & 3;
 console.log(result);  // 1

有点神奇(⊙o⊙) ,25和3的按位与操作的结果居然是1 ?!

看看下图二进制计算结果就清楚了:

如上所示,25 和 3 的二进制表示中,只有第 0 位上的两个数都是 1;于是结果数值的所有其他位都会以 0 填充,因此结果就是 1

按位或

按位或操作符用管道符(|)表示,同样有两个操作数;

规则: 按位或操作在至少一位是 1 时返回 1,两位都是 0 时返回 0

按位或遵循如下真值表:

第一个数值的位第二个数值的位结果
111
101
011
000

仍然用按位与的示例,如果对 25 和 3 执行按位或,代码如下所示:

 let result = 25 | 3;
 console.log(result); // 27

可见 25 和 3 的按位或操作的结果是 27,如下图所示:

在参与计算的两个数中,有 4 位都是 1,因此它们直接对应到结果上;二进制码 11011 等于 27

按位异或

按位或操作符用管道符(^)表示,同样有两个操作数;

规则: 按位异或与按位或的区别是,它只在一位上是 1 的时候返回 1(两位都是 1 或 0,则返回 0)

按位异或遵循如下真值表:

第一个数值的位第二个数值的位结果
110
101
011
000

对数值 25 和 3 执行按位异或操作:

 let result = 25 ^ 3; 
 console.log(result); // 26 

可见,25 和 3 的按位异或操作结果为 26,如下图所示:

左移

左移操作符用两个小于号(<<)表示,会按照指定的位数将数值的所有位向左移动

比如,如果数值 2(二进制 10)向左移 5 位,就会得到 64(二进制 1000000),如下所示:

 let oldVal = 2;  // 2 等于二进制 10 
 let newVal = oldVal << 5;  // 等于二进制 1000000, 即十进制 64

注意在移位后,数值右端会空出 5 位;左移会以 0 填充这些空位,让结果是完整的 32 位数值;如下图:

注意:左移会保留它所操作数值的符号;比如,如果 -2 左移 5 位,将得到 -64,而不是正 64

有符号右移

有符号右移由两个大于号(>>)表示,会将数值的所有 32 位都向右移,同时保留符号(正或负);有符号右移实际上是左移的逆运算。

比如,如果将 64 右移 5 位,那就是 2:

 let oldVal = 64;  // 64  等于二进制 1000000
 let newVal = oldVal >> 5;  // 等于二进制 10, 即十进制 2

同样,移位后就会出现空位,不过右移后空位会出现在左侧,且在符号位之后;如下图:

ECMAScript 会用符号位的值来填充这些空位,以得到完整的数值

无符号右移

无符号右移用 3 个大于号表示(>>>),会将数值的所有 32 位都向右移;对于正数,无符号右移与有符号右移结果相同;

仍然以前面有符号右移的例子为例,64 向右移动 5 位,会变成 2;如下

 let oldValue = 64; // 等于二进制 1000000 
 let newValue = oldValue >>> 5; // 等于二进制 10,即十进制 2 

与有符号右移不同,无符号右移会给空位补 0,而不管符号位是什么;对正数来说,这跟有符号右移效果相同;但对负数来说,结果就差异会非常大;

负值

无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理。因为负数是其绝对值的二补数,所以右移之后结果变得非常之大,如下面的例子所示:

 let oldValue = -64; // 等于二进制 11111111111111111111111111000000 
 let newValue = oldValue >>> 5; // 等于十进制 134217726 

在对 -64 无符号右移 5 位后,结果是 134217726;这是因为 -64 的二进制表示是 1111111111111111111 1111111000000 无符号右移却将它当成正值,也就是 4294967232;把这个值右移 5 位后,结果是00000111111111111111111111111110,即 134217726。

总结

JavaScript 将数字存储为 64 位浮点数,但所有按位运算都以 32 位二进制数执行;

在执行位运算之前,会先将数字转换为 32 位有符号整数,执行按位操作后,结果将转换回 64 位数

  • 正值以二进制编码存储,负值以补码的二进制编码存储
  • 负值编码流程:先用正二进制表示,再找到数值的反码(0变1,1变0),最后结果加1
  • ~NOT 反转所有位
  • &AND 如果两位都是 1,则设置每位为 1
  • |OR 如果两位之一是1,则设置每位为 1
  • ^XOR 如果两位只有一位是 1, 则设置每位为 1
  • <<左移 按照指定的位数将数值的所有位向左移动
  • >>有符号右移 会将数值的所有 32 位都向右移,同时保留符号(正或负)
  • <<<无符号右移 会将数值的所有 32 位都向右移;对于正数,无符号右移与有符号右移结果相同