奇特的~运算符

458 阅读7分钟

「这是我参与2022首次更文挑战的第10天,活动详情查看:2022首次更文挑战

最近实在无聊透顶,我拿出了我封存已久的新书《你不知道的JavaScript》来看,里面有几个关于 ~ 运算符的一个用法让我记忆比较深刻。

与(&)、非(~)、或(|)、异或(^)

~ 运算符

非(~) 运算很简单,就是把一个值的二进制中0变成1,1变成0

例如 ~5 就是将 5 的二进制所有位全部取反,首先我们知道在 JavaScript 中所有的数组都是使用 IEEE754 中 双精度浮点型的规范来存储的,详见 0.1+0.2!=0.3?揭秘其中的奥妙。 中的描述。

5
// 转化为双精度浮点数后
// 0 10000000000 1010000000000000000000000000000000
// 实际存储(101 = 1.01*2^2),舍去首位,存储的是补码,但是因为5是正数,所以其补码是其本身
// 0 10000000010 0100000000000000000000000000000000
// 1.01 * 2^1 

注意:阶码是目标数字转化为二进制后再进行科学计数法的阶码,例如:
5(十进制) -> 101(二进制) -> 1.01 * 2^2(二进制的科学计数法)
所以 5 的阶码为二进制的10(也就是2),尾数为01(舍去1.是因为所有的二进制科学计数法第一位都是1,没不要存储,运算的时候再补上去就行了)

好了,现在我们知道了 JavaScript 中 5 在计算机中存储的是什么样的。

当我们对数字进行 ~ 运算时,是无法直接对浮点数的存储结果运算的,不信你看下面的尝试。

5   //0 10000000000 1010000000000000000000000000000000
~5  //1 01111111111 0101111111111111111111111111111111(猜测)

// 1 01111111111 0101111111111111111111111111111111 转化为10进制数字
// - 1.0101111111111111111111111111111111 * 2^-1  是 -0.68749999997089616954
console.log(~5) // -6
// 很明显这不是一个整数,但是 ~5 的实际执行结果却是 -6,所以很明显,并不是这么运算的

查阅 MDN 和 ECMAScript 规范后了解到,对数字的位运算是先需要执行 ToInt32 抽象操作 将其转化位 32位整数 再对其运算。 所以再运算之前还有一个 64位双精度浮点数 转化位 32位整数 的过程

注意:字位运算只适用于32位整数

es5.github.io/#x11.4.8

规范中对 ToInt32 的描述很简单,先执行 ToNumber 将操作值转化为数字,然后执行 floor(abs(number)) 将数组转化为正整数,然后将其符号位设置为于 number 相同。如果结果大于 32位 整数的表示,则只取其值的后32位。

abs 的取绝对值,作用是将数字变为正数
floor 是向下取整,作用是舍去小数部分将数字转化为整数

注意:当值大于 2*31 -1 时,会取其二进制的最后32位,这时符号位也可能发生变化

对 ~ 的结果再进行 ~ 会得到转化为32位整数的结果

~~1 //1
~~1.123 //1
~~100 //100
~~100.5 //100
~~2147483647 //2147483647  (2 ** 31 - 1)(除了首位是0其他位都是1)
~~2147483648 //-2147483648  (2 ** 32)(除了首位是1其他位都是0),(意想不到的结果)
// 其值超过32位整数表示范围,所以取值的最后32位为结果,也就是10000000...(31个0)
10000000...(310)(补码)转化为源码就是 321,也就是-2147483648的二进制表示

~~429496729542324 // -57676(意想不到的结果)

这里有几个注意点:

  1. 字位运算是直接操作存储的补码而不是源码
  2. 字位运算只对32位整数有效,大于这个范围的数字会只取最后32位进行运算
  3. 会舍去操作数的小数部分

但是对于被操作数是32位整数范围中的值时, ~ 运算的结果有一个规律,~x = -(x+1)

但实际上这是一个字位运算而非数学运算

50 0000000000000000000000000000010132位整 补码)

~51 11111111111111111111111111111010(补码)
     1 00000000000000000000000000000110(源码:负数的源码=补码取反后加1// -6
// ~5 = -(5+1) = -6

~ 代替 === -1

判断一个字符串中是否存在某个字符

function foo(str, target){
    return str.indexOf(target) !== -1
}

想必你也深受 str.indexOf(target) !== -1 的折磨吧,当然这不止 indexOf 还有 findIndex 等一切将 -1 作为失败值返回的一系列方法都是这样。

因为 -1 是一个真值(true),这导致我们不得不使用丑陋的 x === -1 或者 x<0 的方式来判断函数是否执行成功。当然 x === -1 更加安全。

今天我们带来一种新的用法,借助 ~x = -(x+1) ,我们可以知道 ~-1 得到结果 0(假值、false)。

function bar(str, target){
    return !!~str.indexOf(target)
}

这里使用 !! 来将结果转化为 Boolean,可能你会觉得 !!~!==-1 更加丑陋不可读。 反正我是这样认为的。

但是当我们在某些时候并不需要显式的去将结果转化为 Boolean,例如下面这样

if(str.indexOf(target) !== -1){
    // foo
}

if(~str.indexOf(target)){
    // bar
}

对于 ~str.indexOf(target) 是显式的还是隐式的这件事,作者认为这取决于你自己,如果你比较熟悉它,那么它就是显式的,如果你不熟悉它,那它就是隐式

但是需要注意的是,因为字位运算只支持32位,所有当遇到一个最后32位全是1的数字时,会得到与 -1 一样的结果(-1的补码就是32个1)

(2**n - 1)的值每一位都是1(省略前面的0的话),位数取决于n,当时n>=32时,可以得到最后32位都是1的数字
2**3-1 = 5(10进制) = 111(二进制)、2**6-1 = 63(10进制) = 111111(二进制)
** 是求幂运算符, a**b 相当于 a^b

2 ** 35 -1 //34359738367(二进制是35个1)
~(2 ** 35 -1) //0
~34359738367 //0
~-1 //0

~~34359738367 === -1 // true

所以 ~ 的结果在操作数比较大时是不可信的。这时还是 === -1 最实在。

取整

有许多人使用 ~~ 来截除数字的小数部分,以为这和 Math.floor 一样,但是并非如此

~~ 中第一个 ~ 执行 ToInt32 并反转字位,然后第二个 ~ 再进行一次字位反转,即将所有字位反转回原值,最后得到的任然是 ToInt32 的结果。

~~1.5 //1
~~3.14 //3

需要注意的是这种用法只适用于32位整数 表示范围内的数字,更重要的是他对负数的处理和 Math.foolr 是不一样的,因为它是 floor(abs(number)) 的结果在加上原来的符号。

~~429496729542324  //-57676
~~-49.6  // -49
-Math.floor(Math.abs(-49.6)) // 49

总结

  1. 字位运算是直接操作补码而不是源码,被操作数需要先转化为 32位整数 再来运算,这个过程对于大于32位表示范围的数会只取最后32位,会得到意想不到的结果。
  2. 对于 ~ 运算符的上面两种用法对于超出 -2^322^32-1 以外的数都有隐患,不仅仅是 ~&|^ 都存在一样的问题。所以在使用他们前尽量避免这些可能的情况。
  3. ~x 的运算结果是 -(x+1),借此可以用 ~x 来优化 x === -1 的判断,但是也需要考虑x的长度问题
  4. ~~x 可以用来实现舍去 x 的小数部分,但是同样也要注意长度问题