2. 算数运算符、赋值运算符、比较运算符、逻辑运算符、位运算符
写代码这件事,有个特别容易忽略的真相:你写的前一万行代码,本质上都是在跟运算符打交道。赋值、判断、循环、位操作,看着基础,但恰恰是这些基础东西最容易埋坑。我见过太多人对着莫名其妙的bug熬夜排查,最后发现不过是一个赋值写成了比较,或者逻辑运算符的短路特性没搞清楚。今天我想把这五类运算符一次性聊透,不只是列个表格告诉你“加减乘除是什么”,而是把每一类背后的运行机制、容易踩的坑、实际场景中的正确用法全部摆出来。不管你是刚接触编程的新手,还是写了两三年的老手,这五千字里应该都有你用得上的东西。
为什么要专门写这个话题?因为运算符是几乎所有编程语言的公共底座,你换语言、换框架、换项目,这些东西始终在那里。而且不同语言之间运算符的行为差异非常大,一个Java老手去看Python的赋值操作,或者一个Python选手去写C++的位运算,多多少少会踩到对方的“语言陷阱”。我尽量把这些横向差异也点出来,你以后切语言的时候至少心里有数。
1. 算数运算符、赋值运算符、比较运算符、逻辑运算符、位运算符的整体设计思路
1.1 五类运算符各自的定位与适用场景
先把五类运算符摆在一起看。它们不是五座孤岛,而是一条处理数据的流水线:算数运算符负责“生成新值”,赋值运算符负责“存储新值”,比较运算符负责“判断值的关系”,逻辑运算符负责“组合条件”,位运算符负责“在二进制层面直接操作值”。
举个例子,你写一个简单的用户登录校验:
boolean canLogin = (username != null && password.length() >= 8) && isAccountActive;这一行里五种运算符全用上了。username != null是比较运算符,password.length() >= 8也是,&&是逻辑运算符,=是赋值运算符。看起来简单平淡,但每个运算符都有自己的执行顺序和潜在陷阱。我拆开讲。
算数运算符不只是+ - * / %这五个,还有自增自减++ --,以及各种语言的幂运算**或Math.pow()。它的核心定位是“数值计算”,但它真正的难点不在计算本身,在于类型转换和精度问题。最经典的例子:int a = 5 / 2;在Java和C++里结果是2,不是2.5。如果你不是刻意做整除,这就是个隐形的bug。
赋值运算符的核心功能是“把右边的值存入左边的变量”,但它延伸出的复合赋值运算符(+=、-=、*=这些)才是最常用的。这里有个很多新手不知道的细节:a += b在Java中其实隐含了一次类型转换,等价于a = (int)(a + b),而不是简单的a = a + b。有些场景下两者行为完全不同。
比较运算符是条件判断的基础,但不同语言的分裂非常严重。C/C++/Java里==比较基本类型时比的是值,比较引用类型时比的是内存地址。JavaScript里==会做隐式类型转换,===才是严格的类型和值同时比较。Python里==直接比较内容。这些差异意味着你不能拿着一门语言的习惯去写另一门语言。
逻辑运算符&&、||、!,核心机制是“短路求值”。false && anything(),后面的anything()根本不会执行;true || anything()也一样。这个特性用好了是优化,用不好就是隐藏的bug,尤其当你把带副作用的函数调用放在短路表达式的右侧时。
位运算符&、|、^、~、<<、>>、>>>是五类里最“底层”的,直接对二进制位操作。实际业务代码里用得相对少,但在权限系统、标志位处理、性能敏感场景、图像处理和底层驱动中,它依然是王者。
2. 算数运算符的细节与实操要点
2.1 整数除法、取模运算和精度陷阱
算数运算符第一个容易踩的坑就是整数除法。Java里:
int result = 7 / 2; // result = 3,直接截断小数部分如果要保留小数,至少要有一方是浮点类型:7.0 / 2、7 / 2.0、或者(double) 7 / 2都可以。但注意,(double)(7 / 2)没用,因为括号里先做了整数除法得到3,再转成double也还是3.0。
取模运算%同样有细节。正整数取模没问题,但负数的取模结果不同语言不一样:
// Java中 int a = -7 % 3; // 结果是-1,Java的结果符号和左操作数一致Python中同样的表达式结果是2,因为Python的取模结果是“结果与除数同号或为0”的,数学意义上更贴近模运算。这就是典型的语言差异。做跨平台算法移植的时候,遇到负数取模必须单独写测试用例验证,否则极容易出错。
浮点数运算更是重灾区。0.1 + 0.2 == 0.3在几乎所有主流语言中都是false,因为二进制浮点数无法精确表示0.1和0.2。这个问题在Java、C++、JavaScript、Python里都存在。我见过一个真实案例,一个财务系统统计金额,直接用double累加,跑了一个月后对不上账,最后排查出来是精度累计误差。处理金额正确姿势是使用整数类型加最小单位(分)来算,或者使用语言自带的十进制类型——Java用BigDecimal,Python有Decimal模块。
2.2 自增自减的前置与后置
i++和++i的区别每个面试都会问,但实际工作中同样容易踩坑。后置自增i++是“先使用后自增”,表达式的结果是自增前的值;前置自增++i是“先自增后使用”,表达式结果是自增后的值。
int i = 0; int a = i++; // a=0, i=1 int j = 0; int b = ++j; // b=1, j=1这本身不难,难在组合使用:int result = i++ + ++i;这种代码我在实际项目里见过不止一次,它的结果在不同编译器环境下都不一定一样,甚至属于未定义行为。这种代码除了在面试题里存在,在日常开发中应该严格避免。你要自增就单独一行写i++,别跟其他运算混在一起。
注意:在Java和C++中,
i++和++i在单独使用时性能差异微乎其微,现代编译器基本都会优化成同一套指令。纠结++i还是i++性能没有意义,明确语义才是第一位的。
还有一点:Python没有++运算符。你写i++在Python里会直接报语法错误,i += 1才是正解。每门语言的运算符设计理念不同,切语言时这些内建语法是第一个要适应的。
3. 赋值运算符与比较运算符的用法辨析
3.1 赋值与比较的经典混淆
“赋值运算符”和“比较运算符”放在一节里讲,是因为它们之间的混淆实在太常见了。=是赋值,==是比较,这个基础中的基础,但在C/C++里有个著名的坑——赋值表达式本身也有值,可以直接嵌入到条件判断中:
int x = 5; if (x = 10) { // 这个判断永远为true! // 因为x=10这个赋值表达式的结果就是10,10是非零值所以为真 }更可怕的是,这个代码编译期不会报错,顶多给个warning。C/C++老手都建议给条件分支里的变量加常量前置,比如写成if (10 == x),这样万一少写一个等号变成if (10 = x),编译器会直接报错,因为不能给常量赋值。这叫“尤达条件”,我不是说它漂亮,但在C/C++里确实是个防御技巧。
Java在这点上做了改进,赋值表达式不支持作为条件判断,if (x = 10)直接编译错误。JavaScript更特殊,if (x = 10)依然是合法的,而且赋值表达式后的值是真值,所以条件成立,依然是隐藏bug的重灾区。
3.2 相等比较“内容”还是“引用”
Java和C++里,==比较引用类型时比的是内存地址,不是内容。String是引用类型,所以:
String a = new String("hello"); String b = new String("hello"); boolean r1 = (a == b); // false,两个不同对象 boolean r2 = a.equals(b); // true,内容相同但如果你这样写:
String c = "hello"; String d = "hello"; boolean r3 = (c == d); // 通常是true,因为字符串常量池复用了同一个对象这就有意思了。同一个==,换个写法结果就不同,底层是JVM的字符串常量池机制。所以Java里比较字符串内容永远用equals,别用==。这几乎是每个Java新手都会踩的坑,我当年也栽过。
Python则没有这个问题,Python的==默认比较内容(调用__eq__方法),is才是比较内存地址。这跟Java的语义正好反过来。
3.3 复合赋值运算符的隐藏类型转换
复合赋值运算符+=、-=、*=这些看似只是语法糖,实际有额外功能。Java中最经典的问题:
short s = 1; s = s + 1; // 编译错误!s+1的结果是int,不能自动转回short s += 1; // 编译通过!复合赋值运算符隐含强转short类型做算术运算时会自动提升为int,因此s + 1是int类型,赋值给short需要强转。但+=不同,它在Java的JLS(Java语言规范)中定义是E1 = (T)((E1) op (E2)),也就是自带一次强制转换。在实际编码中,如果你明知会有超出范围的赋值,+=依然会静默溢出,这一点要小心。
类似地,C/C++的+=没有这种Java式的强制转换,而是完全等价于展开表达式。所以Java里的这个行为不是“通用规则”,是Java特有的,换语言时别想当然。
4. 逻辑运算符的短路机制与应用实例
4.1 短路求值的完整图解与异常规避
逻辑运算符最核心的机制是短路求值,理解这一点等于理解了逻辑运算符的一半。
A && B:如果A是false,B不会被执行。A || B:如果A是true,B不会被执行。
很多人知道这条规则,但真正写代码时不一定能主动利用它。短路求值最经典的用途是“先判定再操作”,比如Java中判空:
// 不这么写 if (user != null) { if (user.getAddress() != null) { String city = user.getAddress().getCity(); } } // 可以这么写,逻辑等价且更紧凑 if (user != null && user.getAddress() != null) { String city = user.getAddress().getCity(); }如果不依赖短路,user.getAddress()会在user为null时直接抛空指针异常。短路求值保证了前面条件不成立时,后面的代码不会执行。这是空指针防御最常用的手法。
短路的另一个负面场景是副作用丢失:
boolean flag = fetchFlag(); if (flag && updateCount() > 0) { // 如果flag为false,updateCount()不会执行! }如果updateCount()有修改数据库、计数加一等副作用,那么当flag为false时这个副作用也不会发生。所以,不要让有副作用的函数作为&&或||的右操作数。先把副作用执行完再用逻辑运算符组合结果,这样逻辑清晰也不容易被短路坑到。
4.2 不同语言逻辑运算返回值对比
这部分是个大坑。C/C++和Java的逻辑运算符返回值是boolean(C中为int 0/1),但Python和JavaScript的逻辑运算符返回值是操作数本身。
Python:
a = 0 or "hello" # a = "hello",因为0是假值,or返回右边的操作数 b = 1 and "world" # b = "world",因为1是真值,and返回右边的操作数 c = "" and "never" # c = "",因为空字符串是假值,and直接返回左边的假值JavaScript逻辑类似,undefined、null、0、''、NaN都是假值:
let name = user.name || "匿名用户"; // 如果user.name是空字符串或undefined,name就是"匿名用户"这种写法在Python和JS里是设置默认值的最常见手段。但如果你是从Java/C++转到Python或JS,刚开始肯定不习惯——Java里你已经给boolean c = a && b;做好了只有true/false的心理预期,而Python里return a and b可能返回一个对象、一个数字、甚至一个None,内容完全不同。
提示:如果要判断逻辑真假而不是取操作数,Python中务必用
bool()包一层,JS里用!!也能达到同样效果。
4.3 逻辑运算符真值表速查
不要觉得真值表太基础,越是基础越需要清晰掌握。这里我重新整理一份三个操作数组合的速查真值表,覆盖AND、OR、NOT三种运算:
| A | B | A && B | A || B | !A |
|---|---|---|---|---|
| true | true | true | true | false |
| true | false | false | true | false |
| false | true | false | true | true |
| false | false | false | false | true |
实际写代码时,我常建议大家在心里默念两句话:&&是“两者同时为真才为真,一假即假”,||是“两者同时为假才为假,一真即真”。这两句话背熟了,逻辑表达式的化简和优化就会快很多。
5. 位运算符的底层原理与高频实战场景
5.1 从二进制理解按位与、按位或、异或、取反与移位
位运算符操作的是二进制补码表示。Java中int是32位,第一位是符号位。位运算符包括:
&按位与:两边都是1结果才是1。|按位或:任一边是1结果就是1。^按位异或:两边不同结果为1,相同为0。~按位取反:0变1,1变0,包括符号位。<<左移:低位补0,相当于乘以2的n次方。>>右移:带符号右移,高位补符号位,相当于除以2的n次方。>>>无符号右移:高位补0,Java特有。
用两个简单的例子走一遍:
// 按位与示例 int a = 6; // 二进制 0000 0110 int b = 3; // 二进制 0000 0011 int c = a & b; // 逐位计算:0000 0110 & 0000 0011 = 0000 0010 = 2 // 左移示例 int d = 1 << 4; // d = 16这里我要重点说明>>和>>>的区别。-16 >> 2的结果是-4,因为带符号右移时高位补的是符号位1;而-16 >>> 2在Java中的结果是1073741820,因为无符号右移高位补0,负数符号位被丢掉后变成了大正数。这个差异在底层数据结构实现中非常关键,比如哈希表的扩容计算里经常能看到用>>>来保证非负。
5.2 位运算经典应用:权限管理、掩码与快速运算
位运算最经典的应用场景是权限系统设计。假设一个用户有四种权限:读(1)、写(2)、执行(4)、管理(8)。这些权限值都是2的幂次,二进制上各自占据一个独立的位:
public static final int PERMISSION_READ = 1; // 0001 public static final int PERMISSION_WRITE = 2; // 0010 public static final int PERMISSION_EXECUTE = 4; // 0100 public static final int PERMISSION_ADMIN = 8; // 1000 // 给用户赋权:读 + 写 + 执行 int userPerm = PERMISSION_READ | PERMISSION_WRITE | PERMISSION_EXECUTE; // 结果二进制为 0111 // 校验是否有写权限 boolean hasWrite = (userPerm & PERMISSION_WRITE) != 0; // 移除执行权限 userPerm = userPerm & ~PERMISSION_EXECUTE; // 切换管理员权限(有变无、无变有) userPerm = userPerm ^ PERMISSION_ADMIN;这个方案比用多个boolean字段优雅得多:一个整数就能装下32种独立权限,数据库存储也方便,查询权限时直接与运算,性能极好。我在实际项目中用这种方式做过物联网设备的控制权限系统,几百万个设备在线时,单个整数的位运算只需要几条CPU指令,比查表快几个数量级。
再说一个常见的位运算优化:判断奇偶性。n & 1的结果为1则n是奇数,0则是偶数。相比n % 2,位运算在很多语言里性能更优,语义上也更底层。同样,乘以2的幂次时用n << 1代替n * 2,但不是所有编译器都能自动优化,你手写明白原理,至少知道为什么有些代码跑得快。
异或运算还有个著名的骚操作:不借助第三个变量交换两个数。假设a = 3、b = 5:
a = a ^ b; // a = 3 ^ 5 b = a ^ b; // b = (3 ^ 5) ^ 5 = 3 a = a ^ b; // a = (3 ^ 5) ^ 3 = 5这个技巧面试题喜欢考,但实际开发中我并不推荐,可读性太差,编译器对临时变量的优化也不差。我写出来是为了让你理解异或的性质:一个数与自身异或结果为0,与0异或结果为自身,还可以用来做简单的数据校验和加密。
5.3 位运算在算法中的性能优势
位运算号称“程序员的数学加速器”,典型场景是判断一个数是不是2的幂次:
boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }这个技巧的逻辑:2的幂次在二进制上只有一个位是1。比如8的二进制是1000,8 - 1是0111,两者与运算结果为0;非2的幂次,比如10的二进制是1010,10 - 1是1001,与运算结果是1000,不为0。
类似的位运算还有:
- 取二进制中1的个数:
n & (n - 1)每次消掉最右边的1,循环计数。 - 判断符号是否相反:
(a ^ b) < 0,异或后符号位为1说明一正一负。 - 求平均且防溢出:
(a + b) >>> 1,或者(a & b) + ((a ^ b) >> 1)。
这些算法在竞赛题、底层库、嵌入式代码里很常见。就算日常业务代码不直接写位运算,理解它们也能帮你读懂框架源码里的那些“神秘算法”。
6. 常见问题与排查技巧实录
6.1 运算符优先级踩坑清单
运算符优先级是隐藏bug的重灾区。当多个运算符混在一个表达式里时,优先级不搞清楚就是猜。我整理一份常用优先级表,从高到低:
| 优先级 | 运算符 | 说明 |
|---|---|---|
| 最高 | () [] . | 括号、数组访问、成员访问 |
| 高 | ++ -- ! ~ | 自增自减、逻辑非、按位取反 |
| 中上 | * / % | 乘除取模 |
| 中 | + - | 加减 |
| 中下 | << >> >>> | 移位运算 |
| 低 | < > <= >= instanceof | 大小比较 |
| 更低 | == != | 相等比较 |
| 更低 | & ^ | | 按位运算 |
| 低 | && || | 逻辑短路运算 |
| 最低 | ?: = += | 三元运算符、赋值 |
最容易出问题的是位运算符的优先级还低于==。比如:
// 本意:判断(a & b) == 0 // 实际:因为==优先级高于&,所以按下式计算 if (a & b == 0) { ... } // 实际执行的是 a & (b == 0),b==0的结果是boolean,再按位与int,直接编译报错或语义错误所以我建议:逻辑运算符、位运算符混合比较运算时,无条件加括号。加括号不是可选项,是必须项。我自己职业生涯里因为优先级问题排查过的bug少说也有十几次,每一次最初都以为是算法逻辑写错了,最后都是优先级在捣乱。
6.2 逻辑运算符与位运算符混合场景的真实bug
这个bug是我在实际项目中印象最深的一个。当时有一段判断权限的代码:
// 代码需求:只有当用户拥有执行权限时才允许操作 int perm = user.getPermission(); // 比如值是 4(执行权限) if (perm & PERMISSION_EXECUTE) { // 执行操作 }这是C语言风格写法,C里if接受整数判断,但在Java里if (perm & PERMISSION_EXECUTE)编译不通过,因为&的返回结果是int,不是boolean。不少人看到错误提示后改成:
if (perm & PERMISSION_EXECUTE == PERMISSION_EXECUTE) { ... }结果依然不对——因为==优先级高于&,实际执行顺序是:
if (perm & (PERMISSION_EXECUTE == PERMISSION_EXECUTE)) // 等价于 perm & true,编译报错正确写法是:
if ((perm & PERMISSION_EXECUTE) == PERMISSION_EXECUTE) { ... }这种bug在面试题里经常“钓”人,在实际代码review里我也提过类似的问题。解决之道就是:位运算+比较运算必须整体括起来,不要相信记忆中的优先级,编译器会无条件遵循规则,不会按你的“本意”执行。
6.3 常见问题速查表
我把平时群里、技术论坛里问得最多的问题整理成一张速查表,直接对照查就行:
| 问题 | 原因分析 | 解决方案 |
|---|---|---|
0.1 + 0.2 != 0.3 | 浮点数二进制存储精度问题 | 用BigDecimal/Decimal,或者转整数计算 |
i++和++i混在复杂表达式里结果不确定 | 执行顺序依赖编译器实现 | 单独成行使用自增自减 |
字符串用==比较却返回false | 比较的是引用地址不是内容 | Java用equals,JS用===,Python用== |
if (x = 5)总是成立 | 赋值表达式的结果是5,5是真值 | 写if (5 == x)或在能编译报错的语言用IDE提示 |
&&右侧的函数没有执行 | 短路求值生效,false&&...直接跳过右侧 | 单独执行副作用函数后再组合判断 |
1 << 32结果意外为1 | 移位数量对32取模,32移位等于不移 | 注意数据类型的位宽,超过位宽用长整型 |
Python里++i报语法错误 | Python没有自增运算符 | 使用i += 1 |
Javashort s; s += 1;编译通过 | 复合赋值隐含强转 | 注意范围溢出问题 |
-7 % 3在不同语言结果不同 | 负数取模的定义差异 | 移植算法时单独验证负值情况 |
a & b == c结果奇怪 | ==优先级高于& | 括号明确顺序,别信记忆 |
这张表我建议你收藏一下,没事翻翻,比临时搜Stack Overflow快得多。
6.4 我的几条独家避坑心得
最后分享几条写代码的实际体会,不算教科书内容,但都是我踩过坑之后沉淀下来的。
第一,任何涉及运算符的复杂表达式,先拆行再合并。比如条件判断,不要写一长串if (a && b || c && !d),要么加括号分清楚,要么拆成多个布尔变量单独表达,再组合。代码可读性永远比少写两行的优越感重要。
第二,切语言时,先看运算符语义差异。Java转Python的人,最容易在==与is、++与+=、&&返回boolean与and返回操作数这三个点上出问题。写代码第一周不用管别人怎么说,把自己当新手,多写几个测试用例验证运算符行为,后面就顺畅了。
第三,位运算要用就整段注释。因为位运算写出来读起来难,过三个月你自己都未必能一眼看懂。在代码里把二进制的计算过程注释出来,比如// 权限 = 读(1) | 写(2) = 3 (二进制0011),这行注释能省下后人无数时间。
第四,短路求值不止用于判空,还能控制初始化逻辑。比如在配置加载场景中,config = loadFromRedis() || loadFromDb() || defaultConfig();这种表达式能让配置的降级链路异常清晰。前提是你用的语言支持这种返回操作数语义(Python和JS可以,Java/C++不行)。但你要注意,如果前面的加载函数抛异常而不是返回假值,短路挡不住异常,所以还是要配合异常处理一起用。