位运算在前端里有哪些用法?
JS 的位运算会先把数字转成 32 位有符号整数再计算,结果也是 32 位整数。常见用法是权限位掩码:每个权限占一位,用 | 组合、& 检查、& ~ 移除。要小心 | 0 取整:超出 -2147483648 到 2147483647 的数会被截断成完全不同的值,取整应该用 Math.trunc。>>> 是无符号右移,结果总是非负数,常用来把负数看成 32 位无符号整数。
简答
一个用户可能同时有“读、写、删除、管理”几种权限,与其存四个布尔值,不如用一个数字的四个二进制位表示:第 0 位是读,第 1 位是写,依此类推。
READ | WRITE组合出0b011,perms & WRITE不为 0 就说明有写权限。这就是位掩码。DOM 的Node.compareDocumentPosition()返回值也是位掩码,MDN 要求用&检查其中的位。前提是要知道 JS 位运算的规则:Number 平时是 64 位浮点数,但
|、&、^、~、<<、>>会先把操作数转成 32 位有符号整数,小数部分直接丢掉,超出 32 位的高位也丢掉,结果是一个 32 位有符号整数。所以位掩码最多只能放 32 个标志,而且用到第 31 位时结果会变成负数。最常见的陷阱是用
x | 0或~~x取整。对小数字它确实等于向零取整,可超出 -2147483648 到 2147483647 的数会被截断:3e9 | 0得到-1294967296。时间戳、文件字节数这类大数很容易越界,取整应该用Math.trunc,MDN 也是这样建议的。
>>>是无符号右移,左边补 0,结果被当成无符号 32 位整数,所以总是非负,-1 >>> 0是4294967295。它常用来把一个数转成无符号 32 位整数,比如哈希函数里避免出现负数。注意>>>不支持 BigInt,其他位运算符两边都是 BigInt 时可以按任意位宽计算。
用位掩码表示权限
看三种操作分别用哪个运算符:组合用 |,检查用 &,移除用 & ~。
const READ = 1 << 0; // 0b0001
const WRITE = 1 << 1; // 0b0010
const DELETE = 1 << 2; // 0b0100
const ADMIN = 1 << 3; // 0b1000
const has = (perms, flag) => (perms & flag) !== 0;
const add = (perms, flag) => perms | flag;
const remove = (perms, flag) => perms & ~flag;
let perms = READ | WRITE;
console.log(perms, perms.toString(2)); // 3 11
console.log(has(perms, WRITE), has(perms, DELETE)); // true false
perms = add(perms, DELETE);
console.log(perms.toString(2)); // 111
perms = remove(perms, WRITE);
console.log(perms.toString(2)); // 101
perms = remove(perms, WRITE); // 移除一个本来就没有的权限
console.log(perms.toString(2)); // 101
console.log(has(0, READ), has(READ | WRITE | DELETE | ADMIN, ADMIN)); // false true
为什么移除要写 & ~flag,而不是减法?~WRITE 把 0b0010 的每一位取反,只有第 1 位是 0,其他位都是 1,和它做 & 就只清掉第 1 位,其他权限不变。减法在权限本来就没有时会出错:0b101 - WRITE 得到 0b011,平白多出写权限、丢了删除权限;而 & ~ 对没有的权限什么也不做,例子里重复移除仍是 101。
检查为什么写 (perms & flag) !== 0?& 的结果是数字,有这个位时等于 flag 本身,没有时是 0。加括号是因为 !== 的优先级比 & 高,不加括号会先算 flag !== 0。
| 0 取整为什么会出错
下面把同样的数分别用 | 0 和 Math.trunc 处理,看从哪里开始结果不一样。
console.log(3.7 | 0, Math.trunc(3.7)); // 3 3
console.log(-3.7 | 0, Math.trunc(-3.7)); // -3 -3
console.log(2147483647 | 0); // 2147483647
console.log(2147483648 | 0); // -2147483648
console.log(3e9 | 0, Math.trunc(3e9)); // -1294967296 3000000000
console.log(2 ** 32 + 5 | 0); // 5
console.log(~~3.7); // 3
console.log(NaN | 0, Infinity | 0); // 0 0
2147483648 | 0 为什么变成负数?32 位有符号整数能表示的最大值是 2³¹ - 1,也就是 2147483647。2³¹ 的二进制在第 31 位是 1,而第 31 位在有符号整数里是符号位,于是被解读为 -2³¹。3e9 也一样,它在 2³¹ 和 2³² 之间,结果是 3e9 - 2³²,即 -1294967296。
2 ** 32 + 5 | 0 为什么是 5?超出 32 位的高位直接被丢掉,2³² 正好是第 32 位,丢掉以后只剩 5。这类错误不会报错,只会悄悄得到一个错的数,所以很难发现。
NaN 和 Infinity 转成 32 位整数时都变成 0,Math.trunc 则会保留 NaN 和 Infinity。所以 | 0 和 Math.trunc 只在 32 位范围内的有限数上结果一致。
>>> 和 >> 有什么区别
两个都是右移,区别在左边补什么:>> 补符号位,负数右移仍是负数,相当于除以 2 向下取整;>>> 总是补 0,结果按无符号整数解读。
console.log(5 >> 1, -5 >> 1); // 2 -3
console.log(-5 >>> 1); // 2147483645
console.log(-1 >>> 0); // 4294967295
console.log((-1 >>> 0).toString(2).length); // 32
console.log(1 << 31); // -2147483648
console.log(100 >>> 32); // 100
-5 >> 1 为什么是 -3 而不是 -2?>> 相当于除以 2 后向负无穷取整,-2.5 向下取整是 -3。-5 >>> 1 左边补 0,原来的负数被当成很大的无符号数,所以结果是 2147483645。
100 >>> 32 为什么还是 100?右操作数会先转成无符号 32 位整数,再对 32 取模,所以移位位数只能是 0 到 31。移 32 位等于移 0 位,不是把数移成 0。
1 << 31 是负数,所以位掩码只用第 0 到 30 位时结果始终是正数;第 31 位也能用,但打印出来是负数,要用 >>> 0 才能按无符号显示。
常见追问
超过 32 个标志怎么办?
可以拆成几个数字各管 32 位,或者用 BigInt:
1n << 40n这样的 BigInt 位运算没有 32 位限制,|、&、~都支持 BigInt。但两边必须都是 BigInt,混用 Number 会抛 TypeError,而且>>>不能用于 BigInt。数字精度的其他边界见《浮点精度、安全整数和 BigInt 有哪些边界?》。
怎样判断一个正整数是不是 2 的幂?
2 的幂的二进制只有一个 1,比如 8 是
1000;减 1 以后是0111,和原数没有共同的位。所以n > 0 && (n & (n - 1)) === 0成立就是 2 的幂。这个写法同样受 32 位限制,只适用于 2³¹ 以内的正整数。
用异或交换两个变量还值得用吗?
a ^= b; b ^= a; a ^= b能不用临时变量交换整数,但只适用于 32 位整数,可读性也差。JS 里直接写解构赋值[a, b] = [b, a]更清楚,对任何类型的值都有效。