我最近在复盘代码的时候就发现个很有意思的现象:很多写了三五年业务代码的人,遇到二进制做掩码、标志位拼接、数据校验这类需求时,第一反应永远是% 2、拆数组、写循环,很少有人能顺手丢出一句a ^ b或者1 << n。一问为什么,大家都说“移位和异或我看过,但不知道什么时候用,也不敢用”。
这篇就把这两件事彻底讲透。会覆盖二进制视角下的移位左移右移、算术移位和逻辑移位的区别、异或的四条数学性质,以及把它们组合起来能干的几件正经事。最后再附上我在不同语言里实测踩过的坑。适合刚接触位运算的人,也适合想把手上的代码再抠快一点的老开发,看完你能知道一段逻辑用<<还是* 2、用^还是==,各自付出的代价是什么。
1. 先换一个视角:整数在内存里就是一排开关
很多人学移位和异或觉得抽象,根本原因是习惯了十进制视角。你在纸上写27 + 45,脑子里是竖式加法;但写27 ^ 45,再用十进制的思路去套,当然怎么都想不明白。所以第一步不是记公式,而是把“数字”这个概念替换成“位模式”。
一个int在内存里不是给你看的一个数,而是若干个二进制位排列成一排。比如 8 位的场景下,25是00011001。你可以把这 8 个位想象成 8 个独立开关:某个位置上是 0,代表这个开关关闭;是 1,代表这个开关打开。整数的数值大小,只是这排开关按权重累加出来的结果。
权重怎么算呢?从左往右,每一位的权重是2^(位数-1)到2^0。最右边那位叫“最低有效位”,权重是 1;最左边那位在无符号数里叫“最高有效位”,权重最大。所以00011001从右往左读:第 0 位是 1,第 3 位是 1,第 4 位是 1,加起来就是2^0 + 2^3 + 2^4 = 1 + 8 + 16 = 25。
一旦接受这个设定,移位就很好懂:所谓左移<<,就是把整排开关整体往高位方向推一格;所谓右移>>,就是整体往低位方向推一格。而异或^就更直接,它做的事是:把两个数对应的每一位拿出来,相同出 0,不同出 1,按位独立处理。5 ^ 3为什么等于 6?因为101 ^ 011 = 110。逐位算,一位都不需要借位和进位,这就是它和加法的本质区别。
2. 移位运算:左移、右移,以及“丢掉什么、补进什么”
2.1 左移的本质:低位补 0,高位溢出即丢失
左移操作a << n,意思是把a的二进制位往高位移动 n 位,空出来的低 n 位全部补 0,从高位移出去的位直接丢弃。用生活化的比喻,就像一条传送带往左推,左边掉下桌的零件不要了,右边空出来的位置放上默认的空箱子。
举个例子。在 8 位环境里:
十进制 3 = 00000011 3 << 1 = 00000110 = 6 3 << 2 = 00001100 = 12 3 << 3 = 00011000 = 24肉眼观察一下规律:3 << 1是 6,3 << 2是 12,3 << 3是 24,全都是乘 2 的对应次方。所以有句口诀叫“左移一位等于乘 2,左移 n 位等于乘 2 的 n 次方”。
但这句口诀只在一定范围内成立。因为位数有限,当一个数已经很大,高位里原本有 1,左移后这个 1 被推出去,就会发生溢出。比如 8 位无符号数里,128 << 1的结果不是 256,十六进制的0x80左移一位变成0x00,直接归零。这就是为什么处理标志位、状态掩码时,要特别小心不要把有效的 1 推出边界。
有符号数更微妙。先记住一件事:在二进制里,负数通常用补码表示。-1在 8 位里头是11111111,-1 << 1得到11111110,换算过来是-2。你说它是不是也等于乘 2?是。-1 * 2 = -2,没毛病。但如果左移一位的数是64,也就是01000000,左移后变成10000000;在无符号语义下这是 128,在有符号语义下这是 -128。同一个位模式,十进制解释完全不同。这个现象必须根植脑海:左移只负责改变位的位置,不负责保证数值结果按照十进制直觉成立。
2.2 右移分两派:算术右移和逻辑右移
右移就没左移那么单纯了,因为涉及“最左边补什么”。
- 逻辑右移:最高位无论原来是什么,空出来的高位一律补 0。这适合无符号数。
- 算术右移:空出来的高位补符号位。原数最高位是 0 就补 0,是 1 就补 1。这适合有符号数,因为要保留负号。
一个具体的例子最能说明问题。8 位场景下,-8的二进制是11111000:
算术右移 1 位:11111100 = -4 逻辑右移 1 位:01111100 = 124同一个-8,同样右移一位,两种规则天差地别。所以在绝大多数编程语言里,int >> n对有符号类型默认走算术右移,uint32_t >> n走逻辑右移。而在 Java 里额外提供了一个>>>,强制做逻辑右移,不管类型有没有符号。C 和 C++ 则没有独立的符号,全靠类型本身决定。这个区别不搞清楚,线上排查问题会非常痛苦,因为看起来一模一样的表达式,在不同平台上结果可能完全不同。
右移和除法的关系也要拎清。数学上,右移 n 位等于整除2^n,但更准确地说,是向下取整的除法,或者说带符号的除法。-3 >> 1,按算术右移,结果是-2,但-3 / 2在普通整数除法里通常是-1(向零取整)或-2(向负无穷取整),取决于语言。所以“右移就是除以 2”只在无符号数和正数里完全成立,负数场景可能少一截。
2.3 移位与乘除法的性能差,到底还重要吗
老早以前,编译器很笨,写x * 8真的可能生成一条乘法指令,时钟周期比移位长得多。于是很多老一辈程序员的习惯是:见* 2就改<< 1,见/ 2就改>> 1。
现在这个习惯还重要吗?现代编译器的优化器非常成熟,遇到x * 8、x / 8,只要x是无符号整数或者编译时能确定无副作用,编译器会自动生成移位指令。所以从纯 CPU 指令数看,写* 8和<< 3没有差别。
真正需要你手动写移位的场景,不是因为乘法指令慢,而是因为你要用移位去拼位模式、设置标志、分配内存对齐,这些场景你需要的不是“数学上的乘”,而是“结构上的移动”。比如在文件格式里写一个 BNF 字段,需要把两个 4 位数字拼成一个字节,那么(high << 4) | low这种写法,语义就是移位和拼接,跟乘法一毛钱关系都没有。
3. 异或:二进制世界里非常关键的一把可逆钥匙
3.1 异或的四条数学性质,以及它们能推出的结论
异或的运算规则,按位看只有四种情况:0 ^ 0 = 0、0 ^ 1 = 1、1 ^ 0 = 1、1 ^ 1 = 0。翻译成大白话就是:两个位一样,结果是 0;两个位不一样,结果是 1。没错,异或可以当“二进制比较器”用,但它最有价值的特性来自下面几条整体性质:
a ^ 0 = a:任何数和 0 异或,等于自己,因为每个位和 0 异或之后不变。a ^ a = 0:任何数和自身异或,每一位都相同,全部归 0。- 交换律:
a ^ b = b ^ a。 - 结合律:
(a ^ b) ^ c = a ^ (b ^ c)。
这三条加一条 0 的特性,能推出两个特别重要的结论。第一,异或运算可逆。所谓“可逆”是指如果c = a ^ b,那么a = c ^ b、b = c ^ a。这就像做了一次二进制层面的“加减密”:用同一个密钥对数据异或一次,再用同一个密钥异或一次,数据就原样恢复。第二,偶数次异或同一个值会互相抵消。这个性质用来做偶数元素成对消除,在数组找单独元素时堪称降维打击。
3.2 零开销交换两个数,真的能这么用吗
网上流传最广的异或应用是这个三段式:
// 交换 a 和 b,不引入临时变量 a = a ^ b; b = a ^ b; a = a ^ b;推导也不难。第一步之后a存着a_old ^ b_old。第二步,b = (a_old ^ b_old) ^ b_old = a_old。第三步,a = (a_old ^ b_old) ^ a_old = b_old。整个过程没有动用额外存储,所以有些人吹它是“零开销交换”。
但我必须说句泼冷水的话:在现代 CPU 和编译器面前,这个技巧几乎没有任何性能优势,寄存器充足的时候三条mov指令也能完成交换,而且更直观。这个三连异或更大的价值是帮助你理解异或性质,而不是用于核心业务代码。因为当a和b指向同一块内存时,比如同一个数组元素和自身交换,第一步a ^ b会把那个内存位置直接清零,后面第二步第三步拿到的都是 0,数据直接丢失。这种行为在指针编程场景下是经典陷阱,不要为了炫技把线上数据搭进去。
3.3 状态翻转:用异或切换开关,比条件判断干净
异或在实际代码里一个非常实用的用途是“切换位”。假设你有一个状态位,想把它从 0 变 1、从 1 变 0,常规写法是:
if (flag == 0) { flag = 1; } else { flag = 0; }有了异或之后,一句flag ^= 1搞定。因为 0 和 1 异或得到 1,1 和 1 异或得到 0。这在高频循环、嵌入式 GPIO 翻转、动画帧标志位切换里尤其好用,少两次分支预测,代码也更紧凑。
更进一步,如果要对多个位同时翻转,就构造一个“翻转掩码”。比如我想把一个字节的低 4 位全部取反,而高 4 位保持不变,表达式是data ^ 0x0F。为什么能用异或?因为异或每一位独立工作:掩码对应位是 1,就翻转原始位;掩码对应位是 0,就保持原始位。你不需要先读出来,再判断,再写回去,一次异或全部完成。
4. 移位和异或组合起来,能干几件正经事
4.1 构造掩码:1 << n、(1 << n) - 1、~((1 << n) - 1)
掩码就是一段特殊的二进制模板,用来和原始数据做按位与、按位或、按位异或,从而提取或修改特定位。位移在这里是构造掩码的主力工具。
1 << n:得到一个只有第 n 位为 1 的数。比如1 << 3是00001000。用于判断某一位是否为 1:(value & (1 << 3)) != 0就是“value 的第 3 位是不是 1”。(1 << n) - 1:得到低 n 位全为 1 的数。比如(1 << 4) - 1 = 00001111。用于提取低 n 位:value & ((1 << 4) - 1)。~((1 << n) - 1):得到除低 n 位外全为 1 的数。用于把低 n 位清零:value & ~((1 << 4) - 1)。
这三件事是做网络协议、嵌入式寄存器配置、权限系统位图时最基础的操作。比如一个 8 位配置寄存器,bit0 到 bit2 表示模式,bit3 表示开关,bit4 到 bit7 保留,那你读写模式就只需要(register >> 0) & 0x07提取,然后register = (register & ~0x07) | newMode写回。
4.2 用异或找“出现奇数次的数”,逻辑很干净
一个经典面试题:一个数组里只有一个数出现奇数次,其余数都出现偶数次,找出这个数。常规做法是哈希表统计,但用异或只要一行:
let result = 0; for (let num of nums) { result ^= num; } // 最终 result 就是那个出现奇数次的数原理就是前面说的偶数次同值抵消。所有出现偶数次的数,两两异或全部归零;唯一剩下那个只出现一次的数,与 0 异或得到它本身。这个过程不需要额外数组,不需要比较,时间 O(n)、空间 O(1)。你要是没理解异或的性质,第一次看到这种解法会觉得像魔法,理解之后就会发现它其实是把“两两抵消”的思想用数学方式编码了。
4.3 简单加密与 CRC 校验:异或为什么总出现在底层
异或可逆这个特性,让它天然适合做对称加密的底层算子。举个最简单的例子:cipher = plain ^ key,解密时plain = cipher ^ key。同一个 key 两次异或回原值,密钥不需要保存两份。虽然这种简单异或在现代密码学里只能算玩具级别,但它在很多轻量场景仍然真实存在,比如某些传输协议的混淆、非安全场景下的数据掩藏、单片机和上位机之间的私有协议校验。
再往外延伸一点,CRC 校验算法里那些看起来天书一样的多项式运算,底层核心也是移位和异或的组合。CRC 把数据当作一个长位串,按多项式做无进位的二进制除法,那里面的“无进位减法”本质就是异或。你去看任何 CRC32 的软件实现,循环体里全是^和>>。理解了移位和异或,你才能真正读懂这些库函数在干什么,而不是只会调用。
4.4 用一个实际例子串起来:位标志权限系统
光讲原理不够,我拿一个权限系统的小例子把移位和异或串起来。假设有四种权限:读、写、执行、删除,分别用 bit0、bit1、bit2、bit3 表示。
- 定义常量:
const READ = 1 << 0; const WRITE = 1 << 1; const EXEC = 1 << 2; const DEL = 1 << 3; - 某个用户拥有读和写权限:
perm = READ | WRITE;结果是00000011。 - 判断是否有执行权限:
(perm & EXEC) != 0。 - 给用户追加执行权限:
perm |= EXEC; - 撤销用户的写权限:
perm &= ~WRITE; - 让用户对某项权限做翻转:
perm ^= DEL;
这段代码用最少的存储表达了多个开关状态,而且所有判断都是几条指令级别。换一组场景,网络封包头、数据库权位标记、嵌入式寄存器状态、甚至游戏里的 buff 列表,只要开关数量不超过整数位数,都可以用同一套写法。等以后真遇到“一个字段表示 32 个开关”的需求,你就知道位运算为什么比数组省钱了。
5. 最容易踩的坑,我一个个试给你看
5.1 移位数超过类型位宽,是未定义行为
在 C 语言里,a << 32当a是 32 位整数时,行为是未定义,编译器可能给出你完全想不到的结果。我实测过,有的机器上1 << 32等于 1(相当于移位次数被截断成 0),有的机器上直接是乱七八糟的值。原因是底层 CPU 指令只取移位次数的低 5 位或低 6 位,超过部分被悄悄丢弃,这个行为在不同平台可能不同。真正的工程代码里,务必先对移位数做边界判断,n % 位宽或者提前if排除。
Java 的选择更保守一点:Java 对移位距离做了取模处理。int是 32 位,移位距离取低 5 位,也就是1 << 32等于1 << 0,结果等于 1。这虽然规避了未定义行为,但也很容易让人误以为左移 32 位会得到 0。写业务代码的时候,别指望这种“安全”兜底,自己把范围判断写清楚。
5.2 有符号数右移,符号位可能让数值变负数
这是线上排查里最经典的坑。在 C/C++ 和 Java 等语言里,对负号整数做>>,默认算术右移,符号位不断扩展。你以为是在“除以 2 向 0 取整”,实际上它朝向负无穷取整。-5 >> 1的结果是-3而不是-2,这个误差在循环边界和数组索引计算时很容易引出隐蔽 bug。
如果你确实需要逻辑右移,在 Java 里要用>>>;在 C 里除了把变量类型改成无符号数,几乎没有直接的运算符。所以,只要一个二进制位串将来要被“解释为数值”,就不要随手右移,最好明确类型和移位规则。
5.3 异或运算符优先级很低,括号必须加
C、Java、JavaScript 里^的优先级都低于==和!=。举个例子:
int a = 5; int b = 3; if (a ^ b == 6) { ... }你以为是先算a ^ b,得到 6,再和 6 比较,成立。实际上,==优先级高于^,表达式被解析成a ^ (b == 6),也就是5 ^ 0 = 5。这个 bug 非常隐蔽,因为表达式的值不会触发编译器警告。我的经验是:只要表达式里除了位运算还有比较、逻辑运算,一律给位运算部分加括号,不要省那两下键盘。
5.4 Python 里的无限位宽是个双刃剑
Python 的整数是任意精度,移位和异或同样适用,但表现和 C 不一样。一个重要区别是:Python 的右移对有符号数一律是算术右移,并且因为无限精度,负数的二进制表示永远带无限个前缀 1。比如-1 >> 10结果还是-1,因为不管移多少位,无限延伸的符号位 1 都在那里。另一个容易惊讶的点是~5,结果是-6,不是像有些人想的那样“5 取反得到某位串”。因为 Python 的~是从无限位模式取反,-5 减 1。处理协议、位图时,如果从 Python 转 C 语言,这两处最容易对不上。
6. 常见问题速查表,直接在项目里对号入座
| 场景 | 推荐写法 | 需要注意的坑 |
|---|---|---|
| 让整数乘 2 的 n 次方 | x << n | 高位可能溢出,检查结果范围 |
| 让整数除 2 的 n 次方 | x >> n | 有符号负数右移是算术移位,结果可能向负无穷取整 |
| 判断 value 第 n 位是否 1 | (value >> n) & 1 | n 不要大于等于位宽 |
| 提取低 n 位 | value & ((1 << n) - 1) | 注意(1 << n)的类型可能溢出 |
| 把低 n 位清零 | value & ~((1 << n) - 1) | 确认~在目标语言里不会因为符号扩展出问题 |
| 设第 n 位为 1 | value |= (1 << n) | 多个位同时设置用或运算 |
| 设第 n 位为 0 | value &= ~(1 << n) | 掩码取反后再与 |
| 翻转第 n 位 | value ^= (1 << n) | 不影响其他位,天然可逆 |
| 两个相同的数异或 | 后续结果互相抵消 | 适合找唯一奇数次数 |
| 交换两个数(不临时变量) | 异或三连 | 两个变量指向同一内存时会清 0,谨慎 |
| 简单对称加密 | plain ^ key | 只能用于非高安全场景,注意密钥管理 |
7. 按我自己的使用习惯,最后分享三点经验
第一,新手练移位和异或,最适合的场地是 LeetCode 里所有带“位”“幂次”“出现次数”这几个关键词的题,每道题做完都把位运算版解法重写一遍。不要背题解,自己拿笔把每个位的变化画出来,画上十几道,感觉自然就来了。
第二,在实际项目里,我有个检查习惯:凡是看到* 2、/ 2、% 2,都会下意识想一想能不能用位运算替代;但只在做标志位、协议解析、性能敏感循环时才真正替换。普通业务逻辑里,保持可读性比节省两个时钟周期重要得多。位运算写出来确实很帅,但三个月后维护代码的人可能是你自己,别让帅变成负担。
第三,一次线下调试经历给我留下的印象非常深:某跨平台系统的内存缓冲池莫名出现首字节被清零的问题,排查了很久,最后定位到就是有同事写了a = a ^ b; b = a ^ b; a = a ^ b;来交换指针,结果两个指针恰好指向同一块内存的首地址。从那以后我对异或三连交换就只剩一个态度:欣赏它的数学美感,但绝不在指针和引用场景用它。类似地,所有涉及移位和异或的写法,我都会在关键行加一行注释,写清楚“这里在做什么位操作、期望什么结果”,避免后来者看到一坨二进制魔法抓狂。
工具是拿来解决问题的,不是拿来炫技的。把移位和异或真正用到手,你不但能写出更快的代码,更重要的是能看懂底层系统里那些“看起来不像人话”的位运算逻辑。多了这层视角,再回头看网络协议头、文件格式、驱动寄存器,很多天书会一下子豁然开朗。