前言
最近在阅读Koa2源码,在阅读过程中get到了一些特别的技巧:就是关于~这个家伙的。为此做了一些调研,把学到的东西分享给大家~~
~ 是啥
非科班出身的同学可能会对这个~符号比较懵,这里简单介绍一下(科班同学就可以跳过啦)。~是位运算符的一种,它的作用是按位取反。
我们都知道,计算机其实只认两个符号:0、1,但其实在计算机中,数值一律是用补码来表示和存储,因此位运算也是基于补码运算的。
这里又涉及到了补码的概念,所以简单介绍一下原码、补码:
- 正数的原码、补码是它本身的二进制
- 负数的原码是它的二进制,从左往右数第一位作为符号位,1代表负数。如:-1的原码是 1 000 0001
- 负数的补码是它的原码的非符号位的按位取反加1。举个🌰:-1的原码是1000 0001 (左边第一个1就是符号位,1代表负数),除非符号位按位取反加1后是 1 111 1111。
~ 是如何实现的
JS中的~与其他语言中的~略有不同,这里先简单介绍一下比较常规的~。
在对正数进行~操作时:
- 将正数的原码转换为补码(都是它的二进制啦)
- 对补码按位取反 (注意二进制第一个符号位,原本是0,代表正数)
- 正数的补码按位取反后,计算机:你这小子第一位现在是1啊,是个负数的补码
- 计算机将补码转换为原码(转换规则也是非符号位的按位取反加1)
- 将原码转换对应进制输出(10进制)
举个实例:
- 3的原码补码都是 0000 0011
- 按位取反后 1111 1100
- 补码转换为原码 1 000 0100
- 输出10进制:-4
对负数进行~操作时:
- 将负数的原码转换为补码(强调下:负数的补码是它的原码的非符号位的按位取反加1)
- 补码按位取反
- 计算机:你小子原来是个正数啊,原码、补码都一样了,真好办
- 补码直接输出对应进制
举个实例:
- -3的原码是 1000 0011
- -3的补码是 1111 1101
- 按位取反后 0000 0010
- 输出10进制:2
总结:~x = -x - 1,如 ~3 = -3-1 = -4 ;~(-3) = 3 - 1 = 2
而在JS里,~会首先对运算对象进行转换为整数的操作,转换规则参考ECMA-262规范的ToInt32。这里简单翻译一下,假设我们输入的参数为input;
- let number = ToNumber(input) (ToNumber函数定义)
- 关于ToNumber简单提一下,如果参数类型是Undefined 返回 NaN
- 如果是Null,返回 +0
- 如果是布尔值,值为true,返回1,值为false,返回0
- 如果是数字,返回它本身
- 如果是字符串:参照规则9.3.1,规则比较多,不一一叙述了
- 如果是对象:
- let primValue = ToPrimitive(input argument, hint Number) (ToPrimitive定义;第一个参数为输入的变量,第二个为控制变量,如果输入的变量可以转换为多个基本类型,可以用第二个变量控制)
- 返回 ToNumber(primValue)
- 如果number值是NaN, +0, −0, +∞, 或者 −∞,则返回 +0
- let posInt = sign(number) * floor(abs(number))
- let int32bit = posInt modulo 232
- modulo 为取模运算 (modulo定义)
- 如果 int32bit >= 231,返回 int32bit - 232,否则返回 int32bit。
举个例子:~undefined,在转换过程中,ToNumber(undefined)结果为NaN,ToInt32(NaN)结果为+0,因此~undefined结果与~+0一致,为-1。另外对于对象的转换,核心规则主要根据[[DefaultValue]] (hint)。下面各举一些对象转换的例子:
> ~{}
-1
> ~[]
-1
> ~NaN
-1
> ~[1]
-2
> ~[2,3]
-1
> ~{toString: () => '45'}
-46
> ~{toString: () => '45',valueOf: () => 123}
-124
~ 妙用
- 对于-1,在JS中有个很出名的函数是indexOf(),如果indexOf返回-1 即表示要找的内容在目标数组、字符串中不存在。
const target = [1,4,56,7]
console.log(!!~target.indexOf(8)) //false
// 通过~转换为0后,很多处理可以简化,
// 可以免去写 !== -1 或者 === -1等烦恼
- 注意下 ~x = -x -1,那么 ~~x = ~(~x) = - (-x - 1) - 1 = x, 即 ~~x == x本身。刚才也提到,在进行~x时,会先经过整数处理,因此~还有转换为整数的巧妙作用
const target = '321'
console.log(typeof ~~target) // number
- 刚才也提到JS是进行转换整数的处理,对于正浮点数,采取的是类似Math.floor的处理,即可以用~~去代替Math.floor,而负浮点数是类似Math.ceil的处理。总而言之只取整数部分。
const target1 = '321.235'
console.log(~~target1) // 321
const target2 = '-321.77'
console.log(~~target2) // -321
- ~ 与 ! ~与!虽然逻辑不同,但是在某些场合下能起到相同的作用
> ~~undefined == !!undefined
true
> ~~null == !!null
true
> ~~0 == !!0
true
> ~~1 == !!1
true
// 注意,并不是所有一样的输入,~、!计算结果都相等
> ~~'a' == !!'a'
false
> ~~[0] == !![0]
false
> ~~-1 == !!-1
false
> ~~2 == !!2
false
~ 速度比较
上面提到了~的几种用处,归根结底都是利用了~能快速转换整数的能力,下面分别对~~与其它常用的转换操作进行简单的速度比较,测试环境:macOS Mojave 10.14.2 Node v8.12.0
let {numArr,stringArr} = require('./data');
let func1 = number => {
let start = performance.now();
let b = ~~number; // 测试符号:包括 ~ 、Math.floor、parseInt、+ 四种
return {
value: b,
time: performance.now() - start
};
};
let totalTime = 0;
numArr.map(item => {
totalTime += func1(item).time
})
console.log({
type: 'fun1',
totalTime,
len: numArr.length,
average: totalTime / numArr.length
})
由于篇幅问题,这里不放全所有代码了,主要修改内容为有测试的运算符号以及数据来源。数据为MockJS生成随机浮点数数组,包含正负浮点数、正负浮点字符串、随机Null、undefined、NaN数组;
- 测试转换内容为±浮点数:
| 操作符号 | 测试数量 | 总耗时 | 平均时间 |
|---|---|---|---|
| ~~ | 609 | 0.24905507266521454 | 0.00040895742637966264 |
| Math.floor | 609 | 0.34583795070648193 | 0.00056787840838502780 |
| parseInt | 609 | 0.28167189657688140 | 0.00046251542951868867 |
| + | 609 | 0.31073787808418274 | 0.00051024282115629350 |
- 测试转换内容为±字符串浮点数:
| 操作符号 | 测试数量 | 总耗时 | 平均时间 |
|---|---|---|---|
| ~~ | 701 | 0.4971509501338005 | 0.0007092024966245371 |
| Math.floor | 701 | 0.5587870776653290 | 0.0007971284988093138 |
| parseInt | 701 | 0.5465480908751488 | 0.0007796691738589855 |
| + | 701 | 0.4985470473766327 | 0.00071119407614355590 |
- 测试内容为undefined、null、NaN:
| 操作符号 | 测试数量 | 总耗时 | 平均时间 |
|---|---|---|---|
| ~~ | 2187 | 2.5649426132440567 | 0.0011728132662295642 |
| !! | 2187 | 2.2864151149988170 | 0.0010454572999537344 |
在JS中,由于有参数的转换,~~必然会存在性能的损耗,但是这个损耗在我们可以接受的范围内,对比上面几组测试结果可得知,同是数字的情况下,~速度最快,而其他两种情况由于转换的原因,略有降低。
题外话
在测试过程中,还发现了一个有意思的事,发现用console、performance测量时,第一个耗时较长,有图有真相:
第1、2张图是console.time、performance.now计算的结果,在第二张图时还互换了下两个函数调用顺序。猜测是第一次调用console.time、performance需要进行某些初始化。