最近在代码评审里看到好几处与运算符相关的低级错误,而且不是只有新人会犯。恰好经常有读者问我:“算术、关系、逻辑、位运算……这些运算符我都认识,可为什么写出来就是不对?”今天就以“运算符大全”为线索,把四类运算符连同使用时的真实坑位一起串一遍。这不是给你贴一张语法表,而是把容易出问题的地方全部摊开来讲,配合代码示例和排查思路,无论是刚开始写代码,还是写了几年想查漏补缺,这篇笔记都值得你花十分钟看完。
先交代一个背景:我在模拟项目X里做过一段时间代码评审,发现很多线上问题最后都能追溯到一条“看似无害”的表达式上。有些是因为取模符号不同,有些是短路没触发,有些是位运算优先级捣乱。事后检查都不是复杂问题,但当时就是能卡住一整个下午。所以这篇内容的核心只有一个:搞清楚每一个符号背后到底发生了什么。
1. 运算符家族全景:每一类运算符在解决什么问题
1.1 运算符的本质:从CPU指令到语言符号
很多人学运算符是死记符号,但我建议你先换个角度看:在CPU层面,运算就是一条条指令。CPU内部有专门的算术逻辑单元,加法有加法指令,位移有位移动指令,按位与有按位与指令。高级语言之所以提供运算符,本质上是把这些高频使用的机器指令抽象成人类更容易读的符号。
所以你看a + b这样的代码,编译器最终会把它翻译成类似ADD的机器指令。同理,a & b翻译成AND指令,a << 2翻译成SHL指令。理解了这一层,很多事情就通了:为什么位运算“快”?因为它在指令层面就是一步到位;为什么不同语言的运算符行为有差异?因为语言编译器给出的语义细节不同。不要只把运算符当成“数学符号”,它是你与机器指令之间的一层翻译。
1.2 四类运算符的功能边界
运算符看起来都在同一个表达式里,但服务的问题完全不同:
| 类型 | 常用符号 | 核心作用 |
|---|---|---|
| 算术运算符 | + - * / % ++ -- | 对数值做变形、计算 |
| 关系运算符 | > < >= <= == != | 比较大小、判断相等 |
| 逻辑运算符 | && || ! | 组合真假条件 |
| 位运算符 | & | ^ ~ << >> >>> | 直接操作二进制位 |
必须强调一个容易混淆的点:逻辑运算符和位运算符虽然符号长得像,但完全是两回事。&&是从“整体真假”的角度去判断,比如a && b是问“a 和 b 是否都为真”;而&是拿两个数的每一位分别做与运算,返回的是一个数,不是布尔值。很多初学者把&和&&混用,在条件判断里写出匪夷所思的结果,后面我会专门展开。
1.3 不同语言之间的运算符差异
我见过不少人把某种语言的经验直接搬到另一种语言,用起来才发现完全不是那么一回事。举几个最常见的差异:
- 整数除法:在 C 和 Java 里,
5 / 2结果是2,整数除法直接截断;在 Python 3 里,5 / 2结果是2.5,要得到整数得用//。 - 取模符号:Java 的
-7 % 3是-1,Python 的-7 % 3是2,规则不同,后面详细说。 - 自增自减:C、Java、JavaScript 都有
++,但 Python 里根本没有这个运算符,++i在 Python 里会被解析成+(+(i)),也就是正号嵌套。 - 加号双义:JavaScript 里
+既做加法又做字符串拼接,"1" + 2结果是字符串"12",如果你以为在做加法,结果就完全错了。
所以碰到一门不熟悉的语言,第一件事就是去查它的运算符手册,不要默认“所有语言都一样”。这不是看不起谁,而是我踩过太多次这种坑。
2. 算术运算符:加减乘除之外的隐藏细节
2.1 整除、取模与负数运算的差异
%这个符号大家都会用,但负数取模的结果在不同语言里不一样。Java 里:
int a = -7 % 3; System.out.println(a); // -1Python 里:
print(-7 % 3) # 2为什么完全不同?根源在于除法舍入方向。Java 的整数除法是向零取整,-7 / 3在数学上是-2.33,向零取整得到-2,余数就是-7 - (-2 * 3) = -1。Python 的整除是向负无穷取整,-7 // 3得到-3,余数就是-7 - (-3 * 3) = 2。
这个差异在工程里很致命。比如你实现一个环形数组索引,(index - step) % length,如果index - step是负数,Java 版本可能得到一个负数下标,直接越界;Python 版本则天然得到非负下标。统一的做法是把取模结果转成正数:
int safeIndex = ((index - step) % length + length) % length;或者直接用 Java 的Math.floorMod。总之,涉及负数取模时,不要凭感觉写,先确认语言规则。
2.2 自增自减与赋值表达式的副作用
i++和++i的区别,背概念是“先使用后自增”和“先自增后使用”,但实际写复杂表达式时很容易翻车。看这个例子:
int i = 0; int a = i++; int b = ++i; System.out.println(i + " " + a + " " + b); // 2 0 2第一行i++:先把 0 赋给a,再把i变成 1。第二行++i:先把i从 1 变成 2,再把 2 赋给b。这是 Java 的明确行为。但在 C 和 C++ 里,像k = i++ + i++这种写法属于未定义行为,甚至换一个编译器结果都可能不同,因为“计算顺序”和“副作用生效时机”由编译器自由决定。我自己在 C 语言里试过同一段代码在不同优化级别下输出完全不一样,当场就明白了为什么这么多前辈强调不要写这种代码。
给一个团队规范:自增自减永远单独成一行,不要嵌进复杂表达式。如果你发现自己在写result = i++ * 2 + --j,停下来,拆开写。省不了多少行,却能省掉大量排查时间。
2.3 浮点数加法:0.1加0.2为什么不等于0.3
这大概是算术运算符里最著名的坑。在 JavaScript 里跑一下:
console.log(0.1 + 0.2); // 0.30000000000000004原因是计算机用二进制浮点数表示小数,而 0.1 和 0.2 在二进制里都是无限循环小数,存储时只能近似。这不是某个语言的 bug,而是 IEEE 754 浮点标准的固有特性。凡是涉及“精确计算”的地方,比如金额、库存,绝对不要直接用浮点数。
工程上的常见解法:
- 金额统一用最小单位整数存储,比如“分”,最后再转换显示。
- 使用语言自身的高精度类型,比如 Python 的
decimal.Decimal、Java 的BigDecimal。 - 需要比较两个浮点数是否相等时,用误差范围而不是
==:
function approxEqual(a, b, epsilon = 1e-10) { return Math.abs(a - b) <= epsilon; }我见过有同事因为0.1 + 0.2 === 0.3的判断失败,导致一段优惠计算逻辑在特定输入下永远不生效。排查了半天才定位到这一行。记住:浮点数比较永远用阈值,不要用等号。
3. 关系运算符与类型转换:真正比大小之前发生了什么
3.1 关系运算的规则与相等性判断
关系运算符负责比较大小和相等性,但比较规则比你想的要复杂。对基本数字类型,直接比较数值大小;对字符串,很多语言是比较字符编码的字典序;对对象引用,比如 Java 里的==,比较的是“两个引用是否指向同一个对象”,不是内容是否相同。
一个非常经典的坑是字符串与数字的比较。在 JavaScript 里:
console.log("10" < "9"); // true这不是在比较数字 10 和 9,而是在比较两个字符串的字典序:第一个字符"1"和"9"做比较,"1"排在"9"前面,所以结果是true。如果你从接口拿到的是字符串版本的数字,直接拿去做比较,就会出现这种近乎荒谬的结果。正确做法是先把"10"和"9"显式转成数字再比较。
3.2 隐式类型转换:自动帮你转换,但不保证是你想要的
很多动态语言会在比较时偷偷做类型转换。JavaScript 里==会因为两侧类型不同而自动转换:
console.log("5" == 5); // true console.log([] == 0); // true[] == 0为什么是true?因为空数组先被转换成空字符串"",空字符串再被转换成数字0,最后0 == 0成立。这种规则大部分时候不会有人主动去记,结果就是条件判断里出现诡异分支。
Java 里也有类似陷阱。如果比较两个内容相同的字符串:
String s1 = new String("hello"); String s2 = new String("hello"); System.out.println(s1 == s2); // false因为==比较的是引用地址,两个对象地址不同。要比较内容得用s1.equals(s2)。这在面试题里出现频率极高,现实中坑也不少——我在某跨平台系统的登录模块里就见过有人用==比较两个字符串,导致密码永远校验失败。
工程建议很直接:Java 里字符串比较永远用equals;JavaScript 里能写===就别写==,把隐式转换挡在门外。
3.3 空值参与比较的陷阱
空值参与关系运算时,坑特别隐蔽。先看 Java 里的拆箱问题:
Integer a = null; if (a == 1) { // NullPointerException }a == 1里,a是Integer包装类型,1是基本类型int,Java 会先对a做自动拆箱,于是null变成int时直接抛空指针异常。很多人以为a == null没问题,就把a拿去做其他判断,忽略了拆箱的发生位置。正确写法是先判空再比较:
if (a != null && a == 1) { }JavaScript 里有个特殊规则:null == undefined是true,但null和undefined与其他任何值做相等比较都是false。用==判空的时候,想只判断null,结果把undefined也捞进来了;想判断两个都为空,反而写不对。最省心的做法是放弃==的模糊语义,明确使用严格相等或者专门的空值检查。这些细节平时用不到,一旦用到就是线上事故级别的体验。
4. 逻辑运算符与短路求值:条件组合里的隐形杀手
4.1 逻辑运算符真值表、短路与位级逻辑的区别
逻辑运算符处理的是真假值,核心是&&(逻辑与)、||(逻辑或)、!(逻辑非)。它们的运行机制有一个特性叫短路求值:
a && b:如果a已经是假,整个表达式结果已经确定,b不会执行。a || b:如果a已经是真,整个表达式结果已经确定,b不会执行。!a:直接取反,没有短路问题。
这个特性经常被当成一种技巧使用,但同时也制造了大量 bug。有个经典案例:某同学在登录逻辑里写if (isAdmin || checkPermission(user)),他以为两个都会执行,结果管理员登录时checkPermission根本没被调用,非管理员的权限状态也一直没有刷新。问题就是短路求值:第一个条件为真,后面的就不会跑了。
对比位运算&和|:它们没有短路机制,左右两边一定会被计算,然后再按位合并。Java 和 JavaScript 里两层符号都存在,写错符号不只是结果不同,性能也可能受影响——如果你把&&写成&,即使左边已经确定结果,右边仍然会执行,本来避开的计算全部照跑。
4.2 非布尔值参与逻辑运算时的返回行为
这个坑在 JavaScript 里尤其多。很多语言要求逻辑运算符两侧必须是布尔值,但 JS 不一样:&&和||返回的是操作数本身,而不是布尔值。看这个:
const name = null || "默认值"; console.log(name); // "默认值"只要左侧是假值,||就会返回右侧操作数。这个特性被广泛用来设置默认值。但问题来了:
const age = 0 || 18; console.log(age); // 18如果age是有效值0,0 || 18仍然返回18,因为0是假值。你设置的默认值把合法数据覆盖了。现代 JS 提供了??(空值合并运算符)来解决这个问题,它只会在左侧是null或undefined时返回右侧,不会把0、空字符串、false当成假值处理:
const age = 0 ?? 18; console.log(age); // 0所以,一旦你确定“空值”只是null或undefined,就用??;如果你确实想处理所有假值,才用||。这是我和团队反复强调的一条规则,尤其处理用户输入时特别关键。
4.3 短路求值的经典陷阱与错误用法
短路求值的另一个常见应用是“安全检查”,比如:
const person = { name: "张三" }; if (person && person.name) { console.log(person.name); }这没问题。但有人会为了“简洁”,把整个业务调用塞进逻辑表达式里:
function update() { return config && loadConfig(config); // config 为 null 时 loadConfig 不会执行 }表面上是“config 存在才加载”,但副作用藏在表达式里,读代码的人很难一眼看出loadConfig被条件性地跳过了。等到某天config为假值但业务仍需要执行时,bug 就出现了。
React 生态里还有一个高频坑:渲染列表或组件时写{count && <Child />},当count是数字0时,页面不会渲染<Child />,而是直接渲染出一个0。你看到界面上莫名其妙出现一个0,排查半天发现又是短路表达式惹的祸。正确写法是显式判断,比如{count > 0 && <Child />}。
我的建议很朴素:短路用于“防御性判断”可以,但不要把业务逻辑藏进表达式里。要想执行就写if + 显式调用,想让判断一目了然就提升为变量。代码不需要刻意追求一行,清晰永远排在第一位。
4.4 逻辑组合与德摩根定律
多个逻辑运算符组合时,很容易在取反上出错。德摩根定律:
!(a && b)等价于!a || !b!(a || b)等价于!a && !b
在实际代码里,只顾着写业务逻辑时最容易把取反搞错。比如判断“用户不是管理员也不是超级用户”时,应该写成:
if (!isAdmin && !isSuperUser) { }有人会下意识写成!(isAdmin && isSuperUser),这两个逻辑完全不同:前者要求两个都不成立才进入,后者只要有一个成立就进入。我建议团队里所有超过两个条件的逻辑表达式,都拆成带明确名字的布尔变量:
boolean isNormalUser = !isAdmin && !isSuperUser; if (isNormalUser) { }一段好的代码,重点不在于运算技巧,而在于读过的人能否在十秒内说出它到底在做什么。
5. 位运算:二进制视角下的一组高效工具
5.1 位运算符到底在算什么
位运算看名字就知道,它直接操作二进制位。常用符号有:
&:按位与,两个位都是 1 结果才是 1。|:按位或,两个位有一个是 1 结果就是 1。^:按位异或,两个位不同结果是 1。~:按位取反,0 变 1,1 变 0。<<:左移,右边补 0。>>:右移,保留符号位。>>>:无符号右移,左边补 0。
举个例子,比如5 & 3:
0101 (5) & 0011 (3) -------- 0001 (1)所以5 & 3的结果是1。这种逐位运算在很多语言里都有明确指令支持,性能理论上很快。但我必须提醒一句:现代编译器优化能力很强,位运算的性能优势在很多场景下已经被稀释了。真正有价值的是它“节省内存、表达状态位”的能力,这一点后面两个实战最能体现。
5.2 实战一:用位标志组合权限与状态
位运算最经典的应用之一是权限系统。假设一个资源有三种操作权限:读、写、执行。不用位运算的常规做法是维护三个布尔字段,或者一个枚举集合;用位运算,一个int就能搞定:
int READ = 1 << 0; // 0001 int WRITE = 1 << 1; // 0010 int EXECUTE = 1 << 2; // 0100给用户赋权:
int permission = READ | WRITE; // 0011,有读和写权限校验是否有某个权限:
boolean canRead = (permission & READ) != 0; boolean canWrite = (permission & WRITE) != 0; boolean canExecute = (permission & EXECUTE) != 0;增加权限:
permission |= READ; // 添加读权限移除权限:
permission &= ~READ; // 把 READ 位清零这套方案的好处显而易见:状态紧凑,一个整数在数据库里就是一个字段,序列化出来也很短,判断时又是一条&指令。我参与过某跨平台系统的用户中心建设,最开始用布尔字段列表,每个状态都要维护一个表字段,后来重构为位标志之后,权限相关的存储和传输都简化了很多。
但也要注意缺点:权限一多,数字就不好读懂,比如permission = 1060一般人很难立刻看出是哪几种权限的组合。所以代码里一定要有常量定义,并且注释里写清楚每一位的含义,否则时间一长没人维护得住。
5.3 实战二:取模、奇偶、快速乘除与像素处理
奇偶判断:(n & 1) == 0表示偶数,(n & 1) == 1表示奇数。因为二进制最低位是 1 一定是奇数,是 0 一定是偶数。
快速乘除 2 的幂:左移一位相当于乘以 2,右移一位相当于除以 2,这是二进制天然的性质:
int a = 8; int doubled = a << 1; // 16 int half = a >> 1; // 4不过要特别小心负数右移的取整方向:Java 的>>是算术右移,保留符号位,结果向负无穷取整。-7 >> 1的结果是-4,而整数除法-7 / 2的结果是-3,两者并不相等。如果只记得“右移等于除以 2”,很容易在负数场景下踩雷。
颜色通道提取:图像处理里常见一个整型表示 RGBA 颜色,比如0xAABBCCDD。要用位运算拆出各个通道:
int color = 0xAABBCCDD; int alpha = (color >>> 24) & 0xFF; int red = (color >>> 16) & 0xFF; int green = (color >>> 8) & 0xFF; int blue = color & 0xFF;这里的关键是& 0xFF,用来把高于第 8 位的部分全部清零,只留下我们想要的字节。我在做某图像处理 Demo 时天天用这套,处理像素缓冲效率很高。
异或的对称性质:a ^ b ^ b等于a。利用这个特性可以做很简单的对称加密和解密:
byte data = 0b10101100; byte key = 0b01010101; byte encrypted = data ^ key; byte decrypted = encrypted ^ key; // 又变回 data也能用来交换两个变量而不需要第三个临时变量:
a ^= b; b ^= a; a ^= b;虽然这段代码很酷,但可读性差,团队里通常不推荐。至于简单异或加密,安全性太低,只适合做做学习练习或低敏感数据的混淆,千万别拿来保护真正的敏感数据。
5.4 位运算容易翻车的几个地方
位运算有几个反直觉的现象,列出来给大家避坑。
移动的距离可能超出预期。Java 里1 << 32并不是 0,而是 1。因为 int 类型的移位操作数只会取低 5 位,32 的低 5 位是 0,所以相当于1 << 0。如果把它当作“移 32 位一定变 0”,就错了。long 类型取低 6 位,1L << 64也会绕回1L << 0。
JavaScript 的按位运算只作用于 32 位有符号整数。比如:
console.log(0x1FFFFFFFF | 0); // -1数字超过 32 位后会被截断,结果跟你用数学直觉算的完全不一样。真要处理大整数位运算,用BigInt更安全。
不要盲目炫技。位运算写起来短,但读起来费劲。权限系统、状态位、像素处理、底层协议这些场景确实适合,可如果只是为了让一行代码看起来更“聪明”而用位运算,后续维护的人大概率会在心里骂人。我在代码评审中看到最多的问题不是位运算算错了,而是“用了位运算但没必要”。能用枚举、集合、布尔变量说清楚的事,别硬拗成位运算。
6. 优先级、结合性与代码可读性:排错半小时的根源
6.1 运算符优先级表
很多 bug 的根源不是运算符本身,而是优先级。下面是常用的优先级从高到低排序(节选):
| 优先级从高到低 | 运算符 |
|---|---|
| 1 | 括号() |
| 2 | 一元运算符++ -- ! ~ |
| 3 | 乘除取模* / % |
| 4 | 加减+ - |
| 5 | 移位<< >> >>> |
| 6 | 关系< > <= >= instanceof |
| 7 | 相等== != === !== |
| 8 | 按位与& |
| 9 | 按位异或^ |
| 10 | 按位或| |
| 11 | 逻辑与&& |
| 12 | 逻辑或|| |
| 13 | 条件? : |
| 14 | 赋值= += -= 等 |
这张表不需要死记,但几个关键梯度必须印在脑子里:关系运算符高于相等运算符,相等运算符高于按位运算,按位运算高于逻辑运算,逻辑运算高于条件运算符和赋值。这句话就能解释绝大多数优先级 bug。
6.2 优先级不同导致的实际事故
一个很经典的例子是位运算和相等运算混在一起。在 C 语言里:
int flags = 3; // 二进制 0011 int MASK = 2; // 二进制 0010 if (flags & MASK == 1) { // 开发者预期:判断 flags 里的 MASK 位是否为 1 }由于==的优先级高于&,表达式被解析成:
flags & (MASK == 1)MASK == 1是假,值为 0,flags & 0也是 0,条件永远不成立。而开发者的本意应该写成:
if ((flags & MASK) != 0)另一个常见问题出现在逻辑非和括号上。!a && b与!(a && b)含义完全不同,前者是“a 为假且 b 为真”,后者是“并非 a 和 b 同时为真”。少写一对括号,整个条件语义就反了。还有一种组合写法a && b || c,虽然按优先级能算出结果,但到底是想表达(a && b) || c还是a && (b || c)?读到代码的人根本没法判断你的意图。
6.3 用括号和命名守住底线
带过这么多年项目,我越来越认同一件事:代码的优先级正确是底线,可读性才是上线。给团队定了三条硬规矩。
第一,只要表达式里同时出现位运算、逻辑运算、比较运算中的任意两类,就统一加括号。哪怕你知道优先级,也要加,因为你不能保证每个读代码的人都记得住。第二,超过三个操作数的表达式,优先抽取成带名字的布尔变量,后面可以直接引用。第三,能用if说清楚的逻辑,不要硬塞进&&或||里。特别是那些带着副作用的表达式,比如函数调用内部会修改状态,放进短路表达式,执行与否完全取决于前面的条件,这是在埋雷。
现代工程里还可以用静态检查工具把关,比如 ESLint 里的规则no-mixed-operators会自动警告混合运算符表达式。代码评审时我也会格外注意这类写法,宁可让新人多写几行,也不要让他们用一行高难度表达式挑战全队的阅读理解能力。
7. 常见问题排查速查表
7.1 运算符相关高频问题速查
我把日常开发和评审中最常见的运算符问题整理成一个速查表,方便你直接对照排查:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
0.1 + 0.2 !== 0.3 | IEEE 754 浮点数无法精确表示十进制小数 | 金额用整数单位,比较用误差阈值 |
"10" < "9"结果为true | 字符串按字典序比较首字符 | 先转成数字再比较 |
JS 里"5" == 5为真 | ==触发隐式类型转换 | 统一使用=== |
[] == 0为真 | 数组先转字符串再转数字 | 使用===,避免空数组与其他值比较 |
Java 里两个内容相同的字符串==结果为false | 比较的是引用地址 | 字符串用equals比较内容 |
a == 1抛异常,但a看起来是 null | 包装类型自动拆箱导致 NPE | 先判空,再进行数值比较 |
| `age = 0 | 18` 得到 18 | |
flags & MASK == 1语义不对 | 相等运算符优先级高于位运算符 | 写成(flags & MASK) != 0,并且加括号 |
Java 中1 << 32不是想象的大数 | int 移位只取低 5 位 | 用 long 或显式控制移位长度 |
| JS 大整数按位运算结果异常 | 按位运算强制转换到 32 位 | 使用BigInt |
C 语言里i++ + i++结果不稳定 | 属于未定义行为 | 永远不要写出这种表达式 |
7.2 排查运算符错误的通用路径
遇到运算符相关的诡异问题,我有一套固定的排查路径,照着走基本都能快速定位。
第一步,先把参与运算的变量和它们的类型打出来,确认两边到底是什么。很多时候你以为比较的是数字,实际是字符串;你以为在做加法,实际在拼字符串。第二步,查语言文档里的优先级表,确认有没有隐含的优先级问题。尤其是位运算、逻辑运算和比较运算混在一起的时候,十有八九问题出在这里。第三步,把表达式拆开,分步打印中间结果。比如先算perm & READ,再比较结果是否大于 0,中间每一步的结果出来了,问题自然暴露。第四步,找一张最小复现用例,独立跑一下。很多复杂环境下的运算符问题,剥掉无关逻辑后都能还原成一个极简表达式,原因一下子就清楚了。
再补充一个实用的习惯:如果团队有静态检查工具,把“禁止混合运算符”这类规则开起来,能在提交代码之前就挡住一半的坑。剩下的一半,靠代码评审时多问一句“这个表达式在什么情况下会走哪个分支”,也能提前暴露。
最后说一点个人体会。我刚开始写代码时特别喜欢把一行表达式写得极短,觉得那种写法看起来很专业。后来有一次,正是因为短路求值没有触发,线上少执行了一段关键逻辑,排查到半夜。从那以后,我给自己定了三条铁律:第一,表达式里有位运算或比较运算时,一律显式加括号;第二,金额相关运算绝不用浮点数;第三,任何逻辑表达式超过三个条件就拆成带名字的布尔变量。这些规矩不一定能提升性能,但能显著降低出问题的概率。这篇内容算是我这些年运算符踩坑经历的一次汇总,希望看完的你能少踩几个。