JavaScript 位运算

1,357 阅读14分钟

开篇

位运算 是一种对 Number 数字 进行二进制位数值运算的方式,通常用于权限系统中。

在 JS 中 Number 数字表示形式有(进制):

1. 十进制(满101,我们日常生活使用的都是十进制数字表示)
123456789
20

2. 二进制,以 0b、0B 作为前缀(计算机中常使用二进制来表示)
0b111010110111100110100010101 --> 123456789
0b10100 --> 20
解释:1 * 2^4 + 0 * 2^3 + 1 * 2^2 + 0 * 2^1 + 0 * 2^0 = (16 + 0 + 4 + 0 + 0 = 20)

> 十进制转二进制:num.toString(2);
> 二进制转十进制:parseInt(二进制值, 2).

3. 八进制,以 0o、0O 作为前缀
0o726746425 --> 123456789
0o24 --> 20
解释:2 * 8^1 + 4 * 8^0 = (4 + 16 = 20)

> 十进制转八进制:num.toString(8);
> 八进制转十进制:parseInt(八进制值, 8).

4. 十六进制,以 0x、0X 作为前缀
0x75bcd15 --> 123456789
0x14 --> 20
解释:1 * 16^1 + 4 * 16^0 = (16 + 4 = 20)

> 十进制转十六进制:num.toString(16);
> 十六进制转十进制:parseInt(十六进制值, 16).

一、位运算操作符

JavaScript 中的常用的按位操作符如下,

1. 按位与(AND)

  • 用法:a & b
  • 描述:对于每一个 bit 位,只有两个操作数相应的 bit 位都是 1 时,结果才为 1,否则为 0。
  • 代码示例:
// 计算 3 & 2,首先将操作数转化为 Int32:

// 3对应的 Int32
0b000 0000 0000 0000 0000 0000 0000 0011 
// 2对应的 Int32
0b000 0000 0000 0000 0000 0000 0000 0010 

// 为了直观理解,我们排除前面的 0,只保留最后8位(实际参与计算的是全部的 32 位):
  0000 0011
& 0000 0010
-----------
  0000 0010

// 最终,3 & 2计 算结果转化为浮点数后为 2。

console.log(0b0011 & 0b0010); // 得到 2,0b0010 -->(2^0 + 2^1 = 2)

2. 按位或(OR)

  • 用法:a | b
  • 描述:对于每一个 bit 位,当两个操作数相应的 bit 位至少有一个 1 时,结果为 1,否则为 0。
  • 代码示例:
console.log(0b0010 | 0b0010); // 得到 0b0010 --> 2
console.log(0b0110 | 0b0010); // 得到 0b0110 6(2^0 + 2^1 + 2^2 = 6)

PS: 扩展
位运算操作的特点是将一个数字转换为 32 位有符号整数,即 可将小数转为整数它会对数字进行按位或操作,结果是将小数部分截断(丢弃),从而实现向零取整的效果

因此,我们可以使用 | 0 代替 Math.floor() 实现向下取整,以实现我们期望的结果。

12.3 | 0 --> 输出 12
-12.3 | 0 --> 输出 -12(如果使用 Math.floor(-12.3),输出 -13

3. 按位异或(XOR)

  • 用法:a ^ b
  • 描述:对于每一个 bit 位,当两个操作数相应的 bit 位有且只有一个 1 时,结果为 1,否则为 0。
  • 代码示例:
console.log(0b0010 ^ 0b0010); // 得到 0b0010 --> 0
console.log(0b0110 ^ 0b0010); // 得到 0b0100 --> 4(2^0 + 2^0 + 2^2 = 4)

4. 按位非(NOT)

  • 用法:~a
  • 描述:将操作数的二进制表示的每个位取反,即 0 变为 1,1 变为 0,然后返回「整数」结果。
  • 代码示例:
console.log(~0b0010); // ~2 得到 -3

对于一个正数,~正数 经过 按位非 操作后得到的结果是: -(x + 1)。这是因为 ~ 会将 32 位二进制的 符号位(第一位,即最高位)0 变为 1 变成负数,负数需要进行「补码 + 1」所以 ~2 最终得到 -3。

这里提到的负数转换规则,可以查看下文 「扩展知识」中的「负数的二进制表示」。按位非 通常结合 按位与 一起使用,如下文的 删除权限。

5. 左移(Left shift)

  • 用法:a << b
  • 描述:将 a 的二进制形式向左移动 b (< 32) 个比特位,并且使用 1 填充,相应的右边会增加 b(<32)个比特位,用 0 填充。
  • 代码示例:
let A = 0b1111; // 15
console.log(A << 1); // 30(0b1111 --> 0b11110)
console.log(A << 2); // 60(0b1111 --> 0b111100)
console.log(A << 3); // 120(0b1111 --> 0b1111000)

从上面示例可以看出:左移一位相当于 * 2。

6. 有符号右移

  • 用法:a >> b
  • 描述:将 a 的二进制表示向右移动 b (< 32) 个比特位,丢弃被移出的位。
  • 代码示例:
let A = 0b1111; // 15
console.log(A >> 1); // 7(0b1111 --> 0b111)
console.log(A >> 2); // 3(0b1111 --> 0b11)

从上面示例可以看出:有符号右移 也常被用作向下取整。"有符号" 右移一位 相当于除以 2(向下取整),"有符号" 右移两位 相当于除以 4(向下取整),以此类推。

在二分查找中查找 middle 中间值时可以使用:

let left = 0;
let right = len - 1;

while (left <= right) {
  const middle = ((right - left) >> 1) + left;
  ...
}

7. 无符号右移

  • 用法:a >>> b
  • 描述:将 a 的二进制表示向右移动 b (< 32) 个比特位,丢弃被移出的位,并使用 0 在左侧填充。
  • 代码示例:
let A = 0b1111; // 15
console.log(A >>> 1); // 7(0b1111 --> 0b0111)
console.log(A >>> 2); // 3(0b1111 --> 0b0011)

有符号右移 与 无符号右移 主要区别在于用于填充左侧空缺位的值不同。在对负数的处理上表现明显:

let A = 1;
let B = -1;
console.log(A >> 0, B >> 0); // 1 -1
console.log(A >>> 0, B >>> 0); // 1 4294967295

无符号右移应用场景:

  1. 在 JS 中常被用作处理数组索引下标为 -1 的时候,运算出一个无限大的值,省去了判断 -1 的逻辑。如 events 发布订阅:
-1 >>> 0   -->   4294967295
// event.off
this._eventMap[type].splice(this._eventMap[type].indexOf(handler) >>> 0, 1);
  1. 用作求中间值并向下取整(和 有符号右移 作用相同),如:5 >>> 1 代替了 Math.floor(5 / 2)
5 >>> 1
输出:2
7 >>> 1
输出:2
9 >>> 1
输出:2

二、扩展知识 - 负数的二进制表示

在计算机内存中二进制数中有两种表示方式:原码 和 补码。常见的正数采用原码形式,如正数 1 的二进制表示为 0001;负数在计算机内存中则采用补码表示。

如现在有一个负数 -1,

在计算机中,二进制数的长度为 32 位,其中左侧第一位(最高位)是一个符号位也是 0 和 1 组成,其中 0 代表是正数,1 代表是负数。

对于正数 1 在内存中采用原码表示为:0000...0001;对于负数 -1,在内存中需要经过 原码 --> 反码 --> 补码,最终使用 补码 来表示。

1)-1 的原码表示为:1000...0001,第一位(符号位)是 1;

2)接着转换为反码,符号位 1 不动,其余位取反(0 变 1,1 变 0)变成:1111...1110;

3)反码加上 1 后得到的就是 -1 的补码,最终得到 32 位的 1111...1111。

根据这个规则就能推算出 -2、-3、-4 在计算机内存中的二进制表示值:

-2,
原码: 1000...0010
反码 + 11111...1101 + 1 --> 1111...1110

-3,
原码: 1000...0011
反码 + 11111...1100 + 1 --> 1111...1101

-4,
原码: 1000...0100
反码 + 11111...1011 + 1 --> 1111...1100

试想一下,操作符 按位非 ~ 对 -1 做处理会怎样呢?按位非会将 -1 的 32 位二进制 1111...1111 变为 0000...0000,最终得到一个 0。利用这一特点,可以用于简化对数组匹配得到 -1 的处理方式。

如:判断 url 上是否存在 ?

url += ~url.indexOf("?") ? "&" : "?";

三、位运算在权限系统中的应用

在权限系统中,一个角色可能具备一个或多个权限,位运算可以很好地处理这种关系。

位运算运用在权限系统中,需要遵循两个规则:

  1. 每一种权限码保持唯一;
  2. 权限码均以二进制数形式,有且只有一位值为 1,其余全部为 0(即 权限值为:2^n)

位运算在权限系统中使用的运算操作:

  1. | 按位或,用来赋予权限(添加权限,使得角色存在多种类型权限)
  2. & 按位与,用来校验权限(校验权限,判断角色是否具备某个权限)
  3. ^ 按位异或,用来校验权限(删除权限,移除角色上的某个权限)

比如 linux 的文件权限分为 读、写 和 执行,分别使用不同二进制值表示:4、2、1(2^n 表示)

权限字母表示数字表示二进制
r40b100
w20b010
执行x10b001

下面我们借助位运算操作符,模拟实现 linux 文件权限的 添加、校验 和 删除。

1. 添加权限

let r = 0b100
let w = 0b010
let x = 0b001

let user = r | w | x; // 使用 | 操作符 赋予权限

console.log(user); // 7
console.log(user.toString(2)); // 0b111

r | w | x 会让 user 的三个二进制位都为 1,表示同时拥有三个权限。

2. 校验权限

let r = 0b100;
let w = 0b010;
let x = 0b001;

let user = r | w; // 赋予 r w 两个权限,得到 6(二进制 0b110)

// 是否存在单个权限
console.log((user & r) === r); // true, 存在 读 权限
console.log((user & w) === w); // true, 存在 写 权限
console.log((user & x) === x); // false, 不存在 执行 权限

// 是否满足一组权限中的其中一个
console.log((user & r) === r || (urse & w) === w); // true, 存在 读 或 写 权限

// 必须同时满足多个组合权限
console.log((user & (r | w)) === (r | w)); // true, 存在 读与写 权限
console.log((user & (w | x)) === (w | x)); // false, 不存在 写与执行 权限

用户权限 & 权限 code === 权限 code 可以判断出用户是否拥有该权限。

3. 删除权限

如果想删除某个权限,只需将该权限对应二进制位上的 1 变为 0,按位异或操作符 ^ 可以实现:

let r = 0b100
let w = 0b010
let x = 0b001

let user = r | w; // 赋予 r w 两个权限,得到 6(二进制 0b110)
user = user ^ w; // 移除 w 写的权限

console.log((user & r) === r); // true, 存在 读 权限
console.log((user & w) === w); // false, 不存在 写 权限
console.log((user & x) === x); // false, 不存在 执行 权限

另一种办法也可以实现删除权限:先 按位非 取反,再执行 按位与 操作:&(~code)

let user = r | w; // 赋予 r w 两个权限,得到 6(二进制 0b110)
user = user & (~w); // 0b110 & 0b101 --> 0b100 --> r

console.log((user & r) === r); // true, 存在 读 权限
console.log((user & w) === w); // false, 不存在 写 权限
console.log((user & x) === x); // false, 不存在 执行 权限

4. 到底能支持多少种权限呢?

文件权限分为 读、写 和 执行 三种,如果结合到业务,我们假设存在上千种权限,二进制能表示的过来吗?相信你也有这样的疑问。

其实,二进制位能支持多少种权限种类,取决于用于表示权限的二进制位数。如果使用 n 位二进制数来表示权限,则可以表示出 n 种不同的权限。我们看下面的例子:

当只有一个权限时,我们使用 2^0 表示,得到 0b1:

image.png

新增一个权限,我们使用 2^1 表示,得到 0b10:

image.png

再新增一个权限,我们使用 2^2 表示,得到 0b100:

image.png

以此类推,

  • 当第 32 个权限时,我们使用 2^32 表示,得到 0b1+32个0。
  • 当第 64 个权限时,我们使用 2^64 表示,得到 0b1+64个0。
  • 当第 n 个权限时,我们使用 2^n 表示,得到 0b1+n个0。

所以,使用二进制位数表示权限,没有上限!

四、位运算在 React 源码中的应用 - 标记上下文

React 源码中定义了多个执行上下文,比如 渲染阶段 RenderContext 或者 提交阶段 CommitContext,在执行函数中会判断当前处于哪个执行环境。

标记上下文场景类似于上面的权限系统,涉及到位运算符操作的场景是:进入某个执行上下文、判断是否处于某个执行上下文、离开某个执行上下文。

假设上下文的种类以下几种,默认的执行上下文为 0:

// A 上下文
const AContext = 0b0001;
// B 上下文
const BContext = 0b0010;
// 初始无上下文
const NoContext = 0b0000;

// 变量记录当前上下文
let context = NoContext;
  1. 使用 按位或 位运算符 进入 某个上下文
context |= AContext; // 进入 A 执行上下文,得到 0b0001
context |= BContext; // 进入 B 执行上下文,得到 0b0011
console.log(context); // 3
  1. 使用 按位与 位运算符 判断是否处于 某个上下文
(context & AContext) === AContext; // true 处于 A 执行上下文
(context & BContext) === BContext; // true 处于 B 执行上下文 
  1. 使用 按位非 结合 按位与( &(~code) ) 离开 某个上下文
context &= (~AContext); // 离开 A 执行上下文,得到 0b0010
console.log(context); // 2

五、业务场景 - 记录 change action 修改动作

通常对一个数据列表进行操作时,会涉及 添加、修改、删除 等动作。

现在业务上需要知道每一个动作,在提交更新时多不同处理,这时候你可能会想到使用三个变量来记录每一个 action 动作。

但如果使用位运算符,只需要有一个变量就能够实现记录这些动作的能力,下面分别为 三个 action 定义二进制值,

// 默认状态
export const NotChanged = 0b0000;
// 记录 数据添加
export const AppendChanged = 0b0001;
// 记录 数据修改
export const UpdateChanged = 0b0010;
// 记录 数据删除
export const DeleteChanged = 0b0100;

在标记动作时,可以这样使用位运算符:

if (changedType === 'not') {
  changed &= NotChanged; // 初始化动作
} else if (changedType === 'append') {
  changed |= AppendChanged; // 标记进行了数据添加操作
} else if (changedType === 'update') {
  changed |= UpdateChanged; // 标记进行了数据更新操作
} else if (changedType === 'delete') {
  changed |= DeleteChanged; // 标记进行了数据删除操作
} 

提交数据时判断是否进行了某个操作,可以这样使用位运算符,比如判断是否更新了数据:

if ((changed & UpdateChanged) === UpdateChanged) { ... }

六、位运算算法题 - 只出现一次的数字

  1. 题目: 给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

比如输入 nums = [4, 1, 2, 1, 2],输出结果为 4。

  1. 解题思路: 任意进制的数字在计算机都是将其转化为二进制计算,借助位运算可以来实现。

位运算符 ^ 异或,两个值 bit 位相同为 0,不同 为 1,将数组中所有数据进行异或,最后得到的值就是数组中只出现一次的数字。

如示例:[4, 1, 2, 1, 2],初始值为 0,异或位运算可以理解为:(值第一次出现,异或代表相加,值第二次出现,异或代表相减)

0 ^ 4 ^ 1 ^ 2 ^ 1 ^ 2  --->  4
    ||
0 + 4 + 1 + 2 - 1 - 2  --->  4
  1. 代码实现:
function singleNumber(nums) {
  let res = 0;
  nums.forEach(num => {
    res ^= num;
  });
  return res;
}
console.log(singleNumber([4, 1, 2, 1, 2])); // 4

参考:

1)JavaScript 中的位运算和权限设计
2)React源码中的位运算技巧