逻辑运算入门与避坑:与或非异或同或、真值表和位运算
逻辑运算这四个字,写起来简单,用起来能让人栽跟头。我见过工作好几年的工程师,真值表背得一字不差,写出来的条件表达式却在边界值上反复翻车;也见过有人把异或当成“高级技巧”到处炫,结果在变量交换那一步踩了自反的坑,排查了大半天。与、或、非、异或、同或这五个运算,是布尔代数的全部家当,也是数字电路、编程语言、数据库查询、权限系统里共用的一套底层语言。它不需要什么框架,也不需要什么环境配置,但恰恰因为太基础,很多人只停留在“知道”层面,没走到“会用”层面。这篇内容就聊这件事:五种逻辑运算到底是什么、为什么这样定义、在布尔逻辑和位运算两个战场里有什么区别、异或和同或各自有哪些真正值得记住的用法,以及我在实际项目里踩过的几个坑。不管你是刚接触条件判断的新手,还是写了很多年业务代码、想把底层逻辑重新捋一遍的老手,都能从中找到能直接拿走用的东西。
1. 真值表背得滚瓜烂熟,写出来的条件却还是错的
1.1 一次权限判断引发的复盘
前阵子帮人看一段权限判断的代码,需求很朴素:某个操作只允许“管理员”或者“创建者”执行,其他角色一律拒绝。代码写出来是这样的:
if (role === 'admin' || role === 'owner') { allow(); } else { deny(); }看起来没问题,跑起来也没问题,直到有人提了一个新角色:只读管理员(readonly-admin)。这个角色既能看管理面板,又不该拥有执行权限。于是判断被改成role === 'admin' || role === 'owner'加上一个!isReadonly的补丁,然后又出现“临时创建者”,再打一个补丁。补丁叠到第四层的时候,没人能一眼说出这段条件到底在表达什么了。
复盘的时候我问了一句:需求里说的“或者”,到底是“两者任一即可”,还是“两者只能有一个”?对方愣了一下,说没想过这个问题。这就是问题的根子。自然语言里的“或者”是有歧义的,中文尤其明显——“你可以选A或者B”,有时候意思是A、B都行,有时候意思是二选一。而逻辑运算里的“或”是明确的、包含式的:只要有一个为真,结果就是真。想要表达“二选一、不能同时”,那得用异或。想要表达“两者必须同时满足”,那得用与。
这个案例里还有第二层问题:多个条件用||拼起来之后,加否定条件的难度会指数级上升。你加一个!isReadonly,得想清楚它管的是整个表达式还是只其中一个分支,括号稍微放错位置,语义就全变了。这就是为什么我说,真值表本身不难,难的是把自然语言的需求准确翻译成逻辑表达式,并且在需求变化时还能保持这个翻译的正确性。
1.2 理解逻辑运算的三个视角:开关、集合、电路
想把逻辑运算真正用明白,我建议从三个视角去看同一件事,它们各自对应一类应用场景。
第一个视角是开关。把每个操作数想成一盏灯,真就是亮,假就是灭。与运算像两个串联的开关,必须两个都合上电流才通;或运算像两个并联的开关,合上任一个就通;非运算像一个反向开关,按下去就切换状态。这个视角最直观,写业务代码时脑子里过一遍串并联图,很多错误当场就能发现。比如a && b || c,先算a && b(串联),再和 c 并联,这就清楚了。
第二个视角是集合。把真值看成“元素属于某个集合”,那么与就是交集,或就是并集,非就是补集,异或就是对称差——也就是“属于A或属于B,但不同时属于两者”的那部分。这个视角在写筛选条件、做数据过滤时特别好用。比如一个查询要筛“最近30天活跃,且不在黑名单里”的用户,用集合的语言说就是“活跃集合减去黑名单集合”,这时候用与加非,比在脑子里绕布尔代数清楚得多。
第三个视角是电路。这是逻辑运算的老家,也是它最本质的形态。与门、或门、非门是最基础的三种门电路,异或门可以由若干与或非门组合出来,同或门就是异或门后面接一个非门。数字电路里所有的加法器、比较器、选择器,拆到底都是这几种门的组合。理解了这个视角,你就能明白为什么计算机要用二进制、为什么位运算能一次处理一整排数据、为什么异或被叫做“不进位加法”。
三个视角不是互斥的,而是同一套规则的不同投影。我的经验是:写业务逻辑时用开关视角,做数据筛选时用集合视角,优化底层处理时用电路视角。遇到想不通的地方,换一个视角往往就通了。
2. 把五个符号还原成五个动作:与、或、非、异或、同或
2.1 与和或:自然语言的“并且/或者”并不等于运算符
先看与运算。符号在不同语言里写法不同,&&、and、&、AND都有人用,但语义是一致的:所有操作数都为真,结果才为真;只要有一个为假,结果就是假。它的一个重要特性是“一票否决”,这个特性带来两个实用推论。
第一个推论:把最可能为假的条件放在前面。在支持短路求值的语言里,a && b如果 a 为假,b 根本不会被求值。所以把代价低、命中率高的判断放前面,能省掉后面的计算。比如先判断user != null && user.hasPermission(),比反过来写安全得多,因为反着写可能在 user 为 null 时直接抛异常。
第二个推论:与运算可以用来做“守卫”。一段代码块前面挂一串&&,任何一个条件不满足就整体跳过,这是一种非常干净的防御式写法。我习惯把这类条件按“从粗到细”排序:先判断参数是否存在,再判断类型是否匹配,最后判断业务规则是否满足。这样读代码的人从上往下看,就能感受到检查的层次。
再看或运算。||、or、|、OR,语义是:只要有一个为真,结果就为真;全部为假时才为假。它的特性是“一票通过”,同样带来两个推论。
第一个推论:把最可能为真的条件放前面。道理和与运算对称,命中即短路。比如日志级别判断level === 'error' || level === 'fatal',如果 error 占绝大多数,就放前面。
第二个推论:或运算容易在需求变更时失控。这一点前面提过,但值得再说一遍。||拼接的条件越多,表达的“豁免规则”就越杂,最后往往变成一段没人敢动的历史遗留代码。我的做法是,一旦||的分支超过三个,就不要再往上堆了,改成查表或者策略映射:
const ALLOWED_ROLES = new Set(['admin', 'owner', 'temp-owner']); if (ALLOWED_ROLES.has(role)) { allow(); }这样加角色就是加一个数组元素,改动的风险和阅读成本都低得多。这不是逻辑运算本身的问题,而是它在工程上的表达边界。
2.2 非运算与德摩根定律:一次取反牵动全身
非运算看起来最简单,一个!或者not,真变假、假变真。但它是五个运算里最容易出事的,原因在于非运算作用在一个复合表达式上时,会牵动整个表达式的结构。
举个最常见的例子。需求是“用户未登录或者没有权限时拒绝”,直觉写法是:
if (!isLoggedIn || !hasPermission) deny();这个写法是对的。但很多人的第一反应是if (!(isLoggedIn && hasPermission)) deny();,这两个写法等价,靠的就是德摩根定律。
德摩根定律有两条:
| 原始形式 | 等价形式 |
|---|---|
!(A && B) | !A || !B |
!(A || B) | !A && !B |
用大白话记:非号进括号,与或要互换。否定进入括号内部时,外层的与变成或,或变成与,每个操作数各自取反。
这条定律的价值不在于炫技,而在于它能帮你把“否定一个复杂条件”这件事拆成可验证的小步骤。我处理复杂条件的习惯是:先把正向条件写清楚,需要取反时,老老实实套一遍德摩根,一步一步展开,而不是凭直觉在括号里外乱加叹号。直觉在两层嵌套以内还靠得住,超过两层基本必错。
这里有一个实实在在的坑:!(a && b)和!a && !b长得像,语义完全相反。前者是“不同时满足”,后者是“两者都不满足”。在代码评审里,这两种写法的差异经常被肉眼忽略,尤其是变量名很长、换行之后。我的建议是,遇到取反的复合条件,要么用德摩根展开成不含外层括号的形式,要么单独抽一个语义明确的布尔变量:
const canOperate = isLoggedIn && hasPermission; if (!canOperate) deny();变量名本身就是一次语义压缩,比一串叹号加括号好读太多。
2.3 异或:把“不同”这件事变成一个运算符
异或的规则是:两个操作数不同,结果为真;相同,结果为假。真值表如下:
| A | B | A XOR B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
很多人第一次看到异或会觉得它别扭:为什么“都为真”反而得到假?其实只要换个说法就通了——异或回答的问题是“这两个东西一样吗”,答案是“不一样”时为真。它是一个差异检测器。
异或有几个性质,值得单独列出来,因为后面所有用法都是从这里长出来的:
- 自反性:
a ^ a = 0。任何数和自己做异或,结果清零。 - 恒等性:
a ^ 0 = a。和零异或,原样返回。 - 可逆性:
a ^ b ^ b = a。同一个数异或两次,等于没异或。 - 交换律与结合律:
a ^ b = b ^ a,(a ^ b) ^ c = a ^ (b ^ c)。顺序无关,分组无关。
这四条性质组合起来,威力比想象中大。自反性加可逆性,意味着异或是一个自身的逆运算——加密和解密用同一套操作。结合律意味着你可以把一堆数按任意顺序异或,结果不变。这两点直接支撑了后面要讲的校验、找落单元素、交换变量等一堆用法。
还有一个视角值得提一句:异或其实就是不进位的二进制加法。0+0=0、0+1=1、1+0=1、1+1=0(进位被丢掉)。这个视角解释了为什么异或在硬件里如此廉价——它只需要几个逻辑门,延迟极低,所以在校验、编码、图形混合这些对性能敏感的场景里出场率很高。
2.4 同或:异或的镜像,专门用来回答“是否相同”
同或就是异或取反,规则是:两个操作数相同,结果为真;不同,结果为假。
| A | B | A XNOR B |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
如果异或是“差异检测器”,同或就是“等价检测器”。它回答的问题是“这两个东西一样吗”,答案是“一样”时为真。
在编程语言里,同或通常没有专门的运算符,需要用!(a ^ b)或者直接用a === b来表达。这导致它在业务代码里存在感很低,很多人学完真值表就忘了。但在两个地方它经常出现:一是数字电路,同或门(XNOR gate)是标准门电路之一,常用来做相等比较;二是底层数据处理,当你要判断两段比特是否完全一致时,同或的思路比逐位比较更直接。
同或也有几条对称的性质:a XNOR a = 1,a XNOR 0 = !a,a XNOR 1 = a。最后一条和异或的a ^ 1 = !a正好对应——和全1异或是取反,和全1同或是保持原样。这些性质在做掩码操作时会用到。
3. 布尔逻辑和位运算是两套战场,别把符号混着用
3.1 短路求值:布尔运算里那个容易被忽略的开关
布尔运算和位运算最容易被混淆的地方,就是它们共用了一些符号。在 C 系语言里,&&和&长得只差一个字符,||和|也是。但这两组东西的语义差别很大。
&&和||是布尔运算符,操作数是布尔值,结果也是布尔值,并且支持短路求值。所谓短路,就是左边的操作数已经能决定结果时,右边根本不求值。false && anything直接返回 false,true || anything直接返回 true。
&和|是位运算符,操作数是整数,按二进制位逐一计算,结果也是整数,不支持短路,两边一定都会求值。
这个差异在正常代码里没什么感觉,一旦右侧表达式带副作用,性质就完全变了。我见过这样的代码:
if (cache.has(key) || loadFromDisk(key)) { ... }意图是“缓存里有就用缓存,没有就从磁盘加载”。这个写法本身没错,短路保证了不会重复加载。但如果把||换成|,loadFromDisk就会无条件执行,缓存命中时也照样读盘,性能直接塌方,而且不报错,非常难发现。
反过来的坑更隐蔽:有人以为&也能短路,写了一个依赖短路来避免空指针的判断:
if (obj != null & obj.isValid()) { ... }&不短路,obj为 null 时右侧照常求值,照样抛异常。这种错误在测试覆盖到 null 场景之前,可能一直潜伏着。
记住一条:需要“跳过后续计算”的语义时,只能用
&&和||;明确要对整数的每一位做并行运算时,才用&、|、^、~。两者不要混用。
3.2 位运算:一次处理一整排比特的并行逻辑
位运算的价值在于并行。一个 32 位整数,用位运算做一次与操作,等于同时处理了 32 组独立的布尔判断。这个特性在几个场景里特别有用。
权限位。这是最经典的用法。把每种权限定义成一个 2 的幂:
const READ = 1 << 0; // 0001 const WRITE = 1 << 1; // 0010 const DELETE = 1 << 2; // 0100 const SHARE = 1 << 3; // 1000为什么用 2 的幂?因为每个值在二进制里只有一个 1,位置互不重叠,这样组合起来不会互相干扰。用户权限用一个整数表示,判断某个权限用与:
if ((userPerm & WRITE) !== 0) { /* 有写权限 */ }授予权限用或,撤销权限用与加取反:
userPerm |= WRITE; // 授予 userPerm &= ~WRITE; // 撤销toggle 某个权限用异或:
userPerm ^= WRITE; // 有则删,无则加这四种操作对应了集合的并、交、补、对称差,和前面讲的三个视角完全对上了。位运算在这里的优势不只是省内存,更重要的是原子性和批量性——一次操作处理一整组标志,不需要循环,也不存在中间态。
掩码。掩码就是“只关心某些位,其他位不管”。取一个整数的低 8 位,用x & 0xFF;取高 8 位,用(x >> 8) & 0xFF;判断某个位是否为 1,用(x >> n) & 1或者更简洁的x & (1 << n)。掩码的本质是“用一个已知模式的与运算,把不关心的位清零”。
低位技巧。x & -x这个表达式能取出 x 最低位的那个 1,其他位全清零。原理是补码表示下-x = ~x + 1,这个加一会让最低位的 1 保持不动,它右边的 0 全部变成 0(因为进位链止步于此),它左边的位全部取反。和原数一做与,结果就只剩那一个 1。这个技巧在树状数组、分组算法里出现频率很高。
3.3 运算符优先级:a & b == c这个坑每年都有人踩
运算符优先级这件事,我原本以为不值得单独讲,直到在三个不同的项目里看到同一个错误。
先说结论:在 C、C++、Java 这些语言里,==的优先级高于&,所以a & b == c会被解析成a & (b == c),而不是(a & b) == c。前者几乎肯定不是你想要的结果,而且由于b == c得到的是 0 或 1,和 a 做与之后往往还能编译通过,错误被静默吞掉。
更麻烦的是,不同语言的优先级规则不一样。Python 里&的优先级高于==,所以同样的表达式在 Python 里是(a & b) == c,结果是对的。这就导致一段从 Python 翻到 C 的代码,逻辑悄悄变了,而且测试用例如果只覆盖了“a & b 恰好等于 0 或 1”的情况,还真测不出来。
我的处理原则很简单:只要表达式里同时出现了位运算和比较运算,一律加括号,不靠记忆。下面这张表是我自己总结的常用顺序,从上到下优先级递减:
| 层级 | 运算符 | 记忆要点 |
|---|---|---|
| 1 | ()[]. | 括号和成员访问永远最先 |
| 2 | !~++-- | 一元运算,从右往左 |
| 3 | */% | 算术乘除 |
| 4 | +- | 算术加减 |
| 5 | <<>> | 移位 |
| 6 | <<=>>= | 关系比较 |
| 7 | ==!= | 相等比较 |
| 8 | & | 按位与 |
| 9 | ^ | 按位异或 |
| 10 | | | 按位或 |
| 11 | && | 逻辑与 |
| 12 | || | 逻辑或 |
注意第 7 和第 8 的顺序,这就是那个坑的位置。再注意&&和&分属两个层级,||和|也是。每次看到不带括号的混合表达式,我都会在评审里标出来,不是为了挑刺,是因为这类错误太安静了。
3.4 数据库里的三值逻辑:NULL 参与运算后的连锁反应
逻辑运算在数据库里还有一套变体,值得单独说,因为它的行为方式和编程语言不一样,坑也更深。
SQL 里的逻辑值不是两个,是三个:TRUE、FALSE、UNKNOWN。任何和 NULL 的比较都得到 UNKNOWN,包括NULL = NULL也是 UNKNOWN。三值逻辑的与或非真值表扩展如下:
| A | B | A AND B | A OR B |
|---|---|---|---|
| TRUE | UNKNOWN | UNKNOWN | TRUE |
| FALSE | UNKNOWN | FALSE | UNKNOWN |
| UNKNOWN | UNKNOWN | UNKNOWN | UNKNOWN |
规律可以这么记:AND 遇到 FALSE 就短路成 FALSE,OR 遇到 TRUE 就短路成 TRUE,其他涉及 UNKNOWN 的组合结果都是 UNKNOWN。NOT UNKNOWN 仍然是 UNKNOWN。
真正的问题出在WHERE子句:它只保留结果为 TRUE 的行,UNKNOWN 和 FALSE 一样被过滤掉。这意味着WHERE status != 'deleted'会漏掉所有 status 为 NULL 的行,因为NULL != 'deleted'是 UNKNOWN,不是 TRUE。这个行为在业务上经常是错的——用户的本意通常是“除了已删除的都要”,NULL 应该被包含进来。
我踩过这个坑之后,形成了一个固定写法:只要字段可能为 NULL,条件里就显式处理它。
WHERE status != 'deleted' OR status IS NULL或者用函数把 NULL 归到一个明确的默认值上:
WHERE COALESCE(status, 'active') != 'deleted'后者更简洁,但要注意它可能让索引失效,数据量大的时候要结合执行计划判断。这两种写法没有绝对优劣,取决于字段的可空性设计和查询频率。
4. 异或真正值得记住的几个用法
4.1 不用临时变量交换两数:原理、边界与不要用的场合
这是异或最出名的用法,几乎每本教材都会提:
a ^= b; b ^= a; a ^= b;推导过程很干净。第一步之后a = a ^ b;第二步b = b ^ (a ^ b) = a,b 变成了原来的 a;第三步a = (a ^ b) ^ a = b,a 变成了原来的 b。用了两次自反性和一次结合律。
但我要说的是,这个技巧在实际项目里几乎不该用。原因有三条,都是实打实的教训。
第一,它对“同一个变量”不成立。如果a和b指向同一块内存,第一步a ^= b就变成了a ^= a = 0,后面两步都在 0 上打转,最终两个变量都变成 0。在数组操作里尤其危险:写一个通用的交换函数,调用时传了swap(arr, i, i),原来的值就被抹掉了。而用临时变量的版本,temp = arr[i]; arr[i] = arr[i]; arr[i] = temp;结果是安全的。
第二,它只对整数类型成立。浮点数、字符串、对象、指针都不行。一个“通用交换”的模板如果用了异或,类型一换就得重写。
第三,现代编译器根本不需要这个优化。用临时变量的交换,编译器能识别出这是交换模式,在寄存器层面直接完成,没有任何额外开销。异或版本反而因为存在数据依赖链,在某些流水线上还慢一点。
所以我的态度是:作为原理理解,值得知道;作为工程写法,不要用。知道它是为了看懂别人代码和面试题,不是为了自己写。真正需要交换时,解构赋值[a, b] = [b, a]又短又安全,何必绕这一圈。
4.2 在一堆重复数据里揪出落单的那个
这是异或最漂亮的用法,也最实用。题目是:一个数组里,除了一个元素只出现一次,其他元素都出现两次,找出那个落单的。常规解法是用哈希表统计,空间 O(n);异或解法是全员异或一遍,空间 O(1)。
function findSingle(nums) { let result = 0; for (const n of nums) result ^= n; return result; }为什么成立?因为异或满足交换律和结合律,所有出现两次的元素两两配对,x ^ x = 0,全部抵消,最后只剩下那个落单的元素和初始的 0 异或,等于它本身。这个解法的优雅之处在于,它完全不关心元素的顺序,也不关心重复的元素是什么值。
这个思路可以再往上走一层:如果有两个元素各出现一次,其余都出现两次呢?全员异或的结果是x = a ^ b,其中 a、b 是那两个落单的。因为 a ≠ b,所以 x 一定不为 0,至少有一位是 1。取 x 最低位的那个 1(用x & -x),以这一位作为分组依据,把整个数组分成两组:这一位为 1 的,和这一位为 0 的。a 和 b 必然被分到不同组,因为它们在这一点上不同。然后每组各自全员异或,就分别得到 a 和 b。
function findTwoSingles(nums) { let xorAll = 0; for (const n of nums) xorAll ^= n; const lowBit = xorAll & -xorAll; // 取最低位的 1 let a = 0, b = 0; for (const n of nums) { if (n & lowBit) a ^= n; else b ^= n; } return [a, b]; }这个解法里有两个关键点值得注意。一是xorAll一定不为 0,如果为 0 说明所有元素都成对出现了,题目前提不成立,实际使用时应该加一个校验。二是x & -x在 JavaScript 里要小心,因为 JS 的位运算是按 32 位有符号整数处理的,超过 32 位的数会被截断。如果数据范围大,得用 BigInt 或者改成用x & (x - 1)的变体思路。
4.3 校验、掩码与可逆变换
异或的可逆性让它在校验场景里很常见。最简单的奇偶校验:把一段数据的所有字节异或起来,得到一个校验字节,接收方用同样的方式算一遍,对不上就说明传输过程出了问题。这个方案很弱,只能发现单比特错误和奇数个比特的错误,偶数个比特同时翻转就检测不到。但它的成本极低,所以在很多协议的头几个字节里还能看到。
稍微强一点的是纵向冗余校验,把多个数据块按位异或,生成一个校验块,可以恢复出任意一个损坏的数据块。这个方案的存储开销是 1/n(n 个数据块配 1 个校验块),在容量和可靠性之间取了个平衡。类似的思路在分布式存储的纠删码里也有,只是算法复杂得多,异或只是其中最基础的一环。
异或还常被用于简单的数据混淆。比如把用户 ID 和一个固定密钥异或之后再传给前端,避免 ID 直接暴露。要强调的是,这不是加密,只是混淆,因为密钥一旦泄露,数据就能被还原,而且异或混淆对已知明文攻击毫无抵抗力。它的合理定位是“防止肉眼直接看出规律”,而不是“防止被破解”。把它当加密用,是会出安全事故的。
掩码场景里,异或的用法是“翻转指定位”。x ^ (1 << n)把第 n 位取反,其他位不变;x ^ 0xFF把低 8 位全部取反。这个操作在图形处理里很常见,比如颜色取反、图像反色都是整段异或。
4.4 异或的两个自反陷阱
第一个陷阱前面提过,就是自己和自己异或会清零。除了变量交换,这个特性在其他地方也会咬人。比如一个“批量更新权限”的函数,用perm ^= delta来表达“切换”,调用时如果 delta 传了和当前权限完全相同的值,结果不是“保持不变”,而是清零。这种语义误解在业务代码里很危险,因为函数名往往叫toggle,调用方以为它是无害的。
第二个陷阱是异或的交换律会掩盖顺序错误。a ^ b ^ c和c ^ b ^ a结果一样,这在数学上是优点,但在工程上意味着“写错顺序不会报错”。一个本该按特定顺序链式处理的逻辑,如果误用了异或,会得到一个看起来合理但语义错误的结果,而且测试很难发现。我的建议是,异或只用在“顺序确实无关”的场景,比如集合式的校验、批量抵消;一旦顺序有意义,就不要用异或来表达。
5. 同或与等价判断:从真值表到掩码比较
5.1 同或的推导:它就是异或取反
同或没有独立的运算符,用~(a ^ b)表达。注意这里的~是按位取反,会得到一堆高位 1,所以实际使用时通常要配合掩码截断:
const xnor = (~(a ^ b)) & 0xFF; // 只保留低 8 位如果只是判断两个值是否相等,直接用a === b就够了,没必要绕到同或。同或真正有价值的地方,是**当你需要“逐位比较结果”而不是“一个整体的真假”**时。
举个例子:你有两段 8 位的状态码,想知道它们哪些位相同、哪些位不同。用异或得到的是“不同位为 1”的掩码,用同或得到的是“相同位为 1”的掩码。后者在需要“保留相同部分”的场景里更直接:
const same = ~(a ^ b) & 0xFF; // 相同位为 1 const diff = a ^ b; // 不同位为 1这两个结果互补,位数相加等于总位数。这个关系在计算汉明距离时用得上:popcount(a ^ b)是不同位的数量,总位数 - popcount(a ^ b)就是相同位的数量,后者本质就是同或的计数形式。
5.2 用同或思路做整段比特的相等比较
判断两个比特串是否完全相同,最直接的做法是逐位比较,但那样要写循环。用同或的思路可以一步到位:a ^ b === 0就表示完全相同。因为异或的结果为 0,意味着每一位都相同。
这个结论看起来平淡,但它在几个地方很有用。比如哈希比较、校验和比对、缓存键匹配,底层往往就是一次异或判零。再比如某些处理器指令集里有专门的“比较并设置”指令,硬件实现上就是做一次异或,看结果是否为零标志。
如果要找“第一个不同的位置”,异或也能帮上忙:a ^ b得到差异掩码,然后用x & -x取出最低位的差异,再取对数就知道是哪一位了。这个技巧在协议解析、错误定位里偶尔能派上用场,比自己写循环干净。
5.3 同或在状态机与协议校验中的影子
数字电路里,同或门(XNOR)常用于相等比较器。两个 n 位数比较是否相等,标准做法是逐位同或,然后所有结果相与——只要有一位不同,同或输出 0,与的结果就是 0。这个结构在硬件里是并行的,一个时钟周期就能出结果。
在软件层面,同或的思路体现在“全等判断”的各种实现里。比如判断两个数组是否完全相同,很多实现是先比长度,再逐元素比,本质上就是“所有位的同或相与”。再比如某些校验算法里,会对数据进行同或累积,得到的校验值对“相同模式”更敏感,适合检测特定类型的错误。
还有一类场景是模式匹配。假设你要在一个比特流里找某个固定模式,可以用同或算出“匹配掩码”,掩码里为 1 的位表示该位置和模式相同,然后统计连续 1 的长度,就能判断是否完整匹配。这个思路在简单的协议解析器里能省掉不少分支判断。
6. 实战:把一段又长又乱的条件判断拆开重装
6.1 先诊断:这段条件到底在表达什么
拿一段真实项目里改造过的代码做例子。需求是“订单可以取消”的判断,原始写法大概是这样:
function canCancel(order) { if (order.status === 'pending' || order.status === 'paid') { if (order.userId === currentUser.id || currentUser.isAdmin) { if (!order.shipped && !order.locked) { if (order.createdAt > Date.now() - 24 * 3600 * 1000 || currentUser.isAdmin) { return true; } } } } return false; }这段代码能跑,但有几个问题。第一,四层嵌套,读的时候要在脑子里维护四个条件栈。第二,currentUser.isAdmin出现了两次,一次在“谁能取消”,一次在“多久之内能取消”,语义重叠但不完全一样,改动时容易只改一处。第三,!order.shipped && !order.locked是两个否定相与,按德摩根可以写成!(order.shipped || order.locked),这样“已发货或已锁定就不可取消”的语义更直白。
诊断的第一步不是动手改,而是用自然语言把这段条件重新说一遍。我的表述是:非管理员只能在订单创建后 24 小时内、且订单未发货未锁定时,取消自己名下的待支付或已支付订单;管理员不受时间、归属限制,但仍受发货和锁定限制。
这么一说,问题就清楚了:管理员和普通用户的差异只在“归属”和“时间”两个维度上,“发货/锁定”是两者共同的前置条件。这个结构在原代码里被嵌套掩盖了。
6.2 用布尔代数做化简,而不是靠直觉删括号
把条件拆成独立的布尔变量,是化简的第一步:
const isCancellableStatus = order.status === 'pending' || order.status === 'paid'; const isOwner = order.userId === currentUser.id; const isAdmin = currentUser.isAdmin; const isFrozen = order.shipped || order.locked; const isWithinWindow = order.createdAt > Date.now() - 24 * 3600 * 1000;原表达式的逻辑结构是:
isCancellableStatus && (isOwner || isAdmin) && !isFrozen && (isWithinWindow || isAdmin)到这里就可以做布尔代数化简了。展开(isOwner || isAdmin) && (isWithinWindow || isAdmin),按分配律得到四项,其中isAdmin && isAdmin可以合并成isAdmin:
(isOwner && isWithinWindow) || isAdmin代回原式:
isCancellableStatus && !isFrozen && ((isOwner && isWithinWindow) || isAdmin)这个结果比原式短,而且结构清晰:三个并列的前置条件,加上一个“归属或管理员”的二选一。化简的依据是布尔代数的分配律和吸收律,每一步都可以验证,不是凭感觉删括号。这一点很重要——很多人在重构条件时靠直觉合并,看起来更短了,实际语义已经偏了,而且没有测试能发现,因为边界用例没覆盖到。
化简后还能看出一个原代码没有明确表达的事实:管理员和普通用户的差异,恰好只体现在(isOwner && isWithinWindow) || isAdmin这一项里。这个发现让后续需求变更的成本低了很多——如果哪天产品说“管理员也要受 24 小时限制”,只需要改这一项,不用碰其他部分。
6.3 重构后的写法与测试用例设计
重构后的代码:
function canCancel(order, user, now = Date.now()) { const CANCELLABLE = new Set(['pending', 'paid']); const WINDOW_MS = 24 * 3600 * 1000; const isCancellableStatus = CANCELLABLE.has(order.status); const isFrozen = order.shipped || order.locked; const isOwnerInWindow = order.userId === user.id && order.createdAt > now - WINDOW_MS; const isAdmin = user.isAdmin; return isCancellableStatus && !isFrozen && (isOwnerInWindow || isAdmin); }几个刻意的改动。第一,把now做成参数,默认取当前时间,这样测试时可以注入固定时间,不用 mock 全局的 Date。第二,CANCELLABLE用 Set 表达,加状态就是加元素,不用改条件结构。第三,isFrozen用正向表达(已发货或已锁定),外层用!isFrozen,比原来的两个否定相与好读。
测试用例的设计要围绕化简后的结构来,重点覆盖四个交叉点:
| 场景 | 状态 | 归属 | 时间 | 冻结 | 预期 |
|---|---|---|---|---|---|
| 普通用户正常取消 | paid | 本人 | 窗口内 | 否 | 允许 |
| 普通用户超时 | paid | 本人 | 窗口外 | 否 | 拒绝 |
| 管理员取消他人订单 | paid | 他人 | 窗口外 | 否 | 允许 |
| 管理员取消已发货订单 | paid | 他人 | 窗口内 | 是 | 拒绝 |
| 非法状态 | cancelled | 本人 | 窗口内 | 否 | 拒绝 |
第四行是关键,它验证了“管理员不受时间和归属限制,但仍受发货限制”这个语义。这类交叉用例是原始嵌套写法最容易出错的地方,因为嵌套结构会让人误以为所有条件都在同一层级上。
6.4 化简不等于可读:什么时候该停手
有一点必须说清楚:布尔化简的目标是可读性和可维护性,不是最短。数学上可以把任何布尔表达式化简到最简与或式,但最简式未必好读。
举个反例。a && b || a && c可以化简成a && (b || c),这个化简是好的,减少了重复。但a && b || !a && c化简成(a && b) || (!a && c)就已经到底了,再往下用异或之类的技巧变形,只会让人看不懂。我见过有人把条件写成a ? b : c的等价异或形式,纯粹为了炫技,评审时全员反对。
我的停止线是:当化简后的表达式能让一个不熟悉这段代码的人,在三十秒内说出它在判断什么,就停手。如果继续化简会让某个业务概念消失(比如把“窗口内”这个语义合并进一个大表达式里),那就不化简。前面那个例子里,我特意保留了isOwnerInWindow这个变量名,就是为了让“本人且在时间窗口内”这个业务概念在代码里可见。
7. 我在实际项目里踩过的几个逻辑坑
7.1 短路求值把副作用吃掉了
有一次排查一个“偶发的数据不一致”问题,现象是某些记录创建成功,但关联的统计数没更新。查了半天,发现问题出在一行看起来毫无问题的代码上:
if (config.enableStats && updateStats(record)) { ... }updateStats是有副作用的,它负责写统计数据并返回是否成功。问题在于config.enableStats为 false 时,updateStats根本不执行——这是短路的正常行为,但代码作者的意图是“无论开关如何都要更新统计,开关只管要不要记日志”。一个&&把副作用关掉了。
修复方式是把副作用从条件里拿出来:
const statsOk = updateStats(record); if (config.enableStats && statsOk) { ... }这件事给我的教训是:条件表达式里不要放有副作用的函数调用。条件应该是纯的、可重复求值的、不改变任何状态的。一旦条件里有副作用,短路求值就变成了一个隐藏的控制流开关,读代码的人很难意识到“这一行可能不执行”。如果确实需要在判断过程中做点什么,先执行,把结果存成变量,再用变量做判断。
7.2 位运算和布尔混用导致的类型隐式转换
JavaScript 里这个问题特别隐蔽,因为&&和||返回的不一定是布尔值,而是最后一个被求值的操作数。0 || 'default'得到'default','a' && 'b'得到'b',null && x得到null。这套规则在做默认值填充时很方便,但在类型判断时容易出事。
我遇到过一个案例:某处用flags & MASK做权限判断,正常情况下返回 0 或非 0,放进if里没问题。但这段值被传进了一个期望布尔值的函数,函数内部用=== true做严格比较,非 0 的整数永远不等于true,判断恒为假。整个权限系统看起来在工作,实际上所有判断都走了拒绝分支,只是恰好没有用户触发到需要放行的路径。
修复很简单,加一次显式转换:Boolean(flags & MASK)或者(flags & MASK) !== 0。但排查过程很长,因为类型转换是静默的,没有任何报错。我的习惯是,位运算的结果永远用显式比较!== 0转成布尔,不直接丢进条件里。多打几个字符,省掉一次深夜排查。
7.3 德摩根定律用反方向,代码反而更难读
德摩根定律有两个方向,化简和展开。新手容易犯的错是知道定律之后,看到取反就想展开,结果把好读的代码拆成了难读的。
比如!(isLoading || hasError)表示“既不在加载中也没有错误”,这个写法很紧凑,语义清楚。有人非要展开成!isLoading && !hasError,虽然等价,但两个否定连在一起,读的时候要在脑子里做两次反转。如果变量本身就是否定语义的(比如isNotReady),展开之后就是!isNotReady,双重否定,更难读。
我的判断标准是看变量的命名极性。如果变量都是正向命名(isLoading、hasError),那么!(a || b)这种带一个外层否定的写法是可读的,不必展开。如果变量里有否定命名(isNotXxx、disableXxx),那就应该调整命名,而不是靠德摩根去救。逻辑运算救不了糟糕的命名,这句话我经常跟团队里的人说。
反过来,该展开的时候要展开。当否定需要穿透多层嵌套,或者需要单独对某一项做特殊处理时,展开成不含外层括号的形式,能让每个条件的边界更清楚。判断的依据不是“哪个更短”,而是“哪个更容易被下一个人看懂并正确修改”。
最后分享一个小习惯:写完任何一段包含三个以上逻辑运算符的条件,我会用真值表或者边界用例手算一遍,尤其是含否定和异或的部分。花五分钟手算,比上线后花五小时排查划算得多。逻辑运算这东西,规则就那么几条,但组合起来的变化很多,机器不会替你发现语义错误,只有人自己想清楚才行。