☰
C++运算符全解析:从算术、位运算到优先级与重载的避坑指南
2026/10/15 17:30:45 网站建设 项目流程

1. 从一段编译报错说起:为什么运算符值得单独开一篇笔记

刚学C++那会儿,我写过一段自认为逻辑天衣无缝的代码,结果编译器甩给我一个警告,运行结果也跟预期差了十万八千里。排查了半天才发现,问题出在一个小小的取模运算符上——我拿它去处理浮点数了。这件事让我意识到,运算符这东西,看着简单,实际上每个都有自己严格的适用边界和隐藏规则。你天天在用+、-、*、/,但真要把它们的行为边界、优先级、类型转换规则说清楚,很多人是含糊的。

这篇笔记就是围绕C++里的运算符做一次系统梳理,覆盖算术、关系、逻辑、位运算、赋值以及其他常用运算符。不管你是刚接触C++的新手,还是写了几年代码但想回头补基础的老手,这篇内容都能帮你把运算符这块地基夯实。我会尽量把每个运算符的“为什么这样设计”“什么时候会出问题”“实际写代码怎么用”讲透,而不是干巴巴地列一张表。

运算符本质上是C++提供给我们的操作符,它们作用于操作数,产生结果。C++的运算符体系相当庞大,按功能可以分成几大类:算术运算符负责数值计算,关系运算符负责比较,逻辑运算符负责布尔判断,位运算符直接操作二进制位,赋值运算符负责写入,还有像sizeof、?:、,、成员访问这类“其它运算符”。每一类都有自己的脾气,用错了轻则结果不对,重则程序崩溃或者出现难以复现的诡异行为。

我打算按类别逐个拆解,每个类别里会讲清楚它的行为规则、常见陷阱、以及我在实际项目中踩过的坑。文章会比较长,但运算符这东西值得花时间一次性搞明白,后面写代码能少走很多弯路。

2. 算术运算符:加减乘除取模,每个都有脾气

2.1 整数除法和取模:新手最容易翻车的地方

算术运算符包括+、-、*、/、%,还有自增++和自减--。前四个大家都很熟悉,但/和%在整数场景下的行为,是新手翻车的高发区。

先看整数除法。在C++里,两个整数相除,结果仍然是整数,小数部分直接被截断。比如7 / 2的结果是3,不是3.5。这个规则本身不难理解,但问题在于很多人写着写着就忘了当前操作数是什么类型。我见过一个同学写了个计算平均分的逻辑,总分和科目数都是int,结果算出来的平均分永远是整数,小数部分全丢了。他还纳闷为什么每次都是整分。

int total = 350; int count = 3; double avg = total / count; // 结果是 116,不是 116.666...

上面这段代码的问题在于,total / count先按整数除法算出116,然后才把116转成double赋给avg。正确的做法是至少让一个操作数变成浮点类型:

double avg = static_cast<double>(total) / count; // 116.666...

再来说取模%。取模运算返回的是除法后的余数,比如7 % 3等于1。但取模有一个硬性限制:两个操作数都必须是整数类型。你拿double去取模,编译器直接报错。这就是我开头提到的那个坑。当时我想判断一个浮点数是不是整数,顺手写了if (x % 1 == 0),结果编译都过不了。后来改成了用std::fmod函数,或者用x == std::floor(x)来判断。

还有一个容易被忽略的点:负数取模的结果符号。在C++11之前,负数取模的结果是实现定义的,不同编译器可能给出不同答案。C++11之后标准明确了:取模结果的符号跟被除数(左操作数)一致。比如-7 % 3的结果是-1,而7 % -3的结果是1。这个规则跟数学上的“模运算”不完全一样,数学上通常要求结果非负,但C++不是这么干的。如果你需要非负的模结果,得自己处理:

int mod(int a, int b) { int r = a % b; return r < 0 ? r + b : r; }

2.2 自增自减的前置与后置:不只是写法差异

++和--有前置和后置两种形式。前置形式++i是先加一,然后返回加完之后的值;后置形式i++是先返回当前值,然后再加一。对于单独一条语句i++;或者++i;来说,两者效果一样,性能上对于内置类型也没有区别。但在表达式内部使用的时候,差异就大了。

int i = 5; int a = ++i; // i 变成 6,a 也是 6 int b = i++; // b 是 6,i 变成 7

这里有个实际写代码时的建议:在表达式中尽量避免混用自增自减。像arr[i++] = i;这种代码,行为是未定义的,不同编译器可能给出不同结果。C++标准没有规定这种表达式中各部分的求值顺序,所以别去考验编译器的脾气。我自己的习惯是,自增自减只单独成句使用,不嵌在复杂表达式里。

对于自定义类型(比如迭代器),前置自增通常比后置自增效率高,因为后置版本需要先拷贝一份原对象再返回。所以在写循环的时候,用++it而不是it++是一个好习惯。虽然现代编译器优化后差距可能被抹平,但养成这个习惯没坏处。

2.3 算术运算中的类型转换与溢出

算术运算符还有一个隐藏行为:整数提升和寻常算术转换。简单说,当两个操作数类型不同时,C++会把它们转换成同一种类型再运算。转换的规则大致是:小类型向大类型靠拢,有符号和无符号混合时往往向无符号靠拢。

这个规则最坑的地方在于有符号和无符号的混合运算。看这个例子:

unsigned int u = 10; int i = -5; if (i < u) { // 你以为是 true?实际上是 false }

因为i会被转换成unsigned int,-5变成一个非常大的正数,所以i < u是false。这种bug非常隐蔽,编译器可能只给个警告甚至不警告。我的经验是:尽量避免在同一个表达式里混用有符号和无符号整数。如果实在避不开,显式做类型转换,让意图清晰。

溢出是另一个问题。有符号整数溢出在C++标准里是未定义行为,也就是说编译器可以做任何事,包括但不限于产生垃圾值、优化掉你的判断逻辑。无符号整数溢出则是明确定义的,会按模运算回绕。比如unsigned int的最大值加一变成零。实际项目中,涉及金额、计数这类场景,要特别注意会不会溢出。用long long或者做边界检查都是常见手段。

3. 关系与逻辑运算符:短路求值是把双刃剑

3.1 关系运算符返回的是bool,不是整数

关系运算符有==、!=、<、>、<=、>=,它们用来比较两个操作数,返回bool类型的结果。这一点看起来没什么好说的,但有一个经典陷阱:把赋值=写成比较==。

int x = 5; if (x = 0) { // 这是赋值,x变成0,条件为false // 永远不会执行 }

编译器通常会给出警告,但很多人不看警告。防御性写法是把常量写在左边:if (0 == x),这样如果不小心写成if (0 = x),编译器会直接报错,因为不能给常量赋值。这个习惯叫“Yoda条件”,虽然看起来有点怪,但确实能挡住一类低级错误。

另外,浮点数不要直接用==比较。因为浮点数在计算机里是近似存储的,0.1 + 0.2并不严格等于0.3。正确的做法是判断两个浮点数的差值是否小于一个很小的阈值(epsilon):

bool almostEqual(double a, double b, double eps = 1e-9) { return std::fabs(a - b) < eps; }

3.2 逻辑运算符的短路行为:用好了是利器,用错了是陷阱

逻辑运算符有三个:&&(逻辑与)、||(逻辑或)、!(逻辑非)。&&和||有一个非常重要的特性:短路求值。对于&&,如果左操作数为false,右操作数根本不会被求值,整个表达式直接为false。对于||,如果左操作数为true,右操作数也不会被求值。

这个特性用好了非常有用。比如判断指针非空再访问成员:

if (ptr != nullptr && ptr->value > 0) { // 如果 ptr 为空,ptr->value 不会被求值,不会崩溃 }

但如果右操作数带有副作用,短路就可能带来意外:

int i = 0; if (false && (i = 10)) { // i = 10 不会执行 } // i 仍然是 0

我见过有人在条件里做函数调用,指望函数一定被执行,结果因为短路没执行,导致后续逻辑出错。所以我的建议是:不要在逻辑表达式的右操作数里放有副作用的操作。条件判断就纯粹做判断,动作放到条件体里面去做。

还有一个细节:&&和||的优先级。&&的优先级高于||,所以a || b && c等价于a || (b && c)。但!的优先级又高于&&和||。实际写代码时,我强烈建议用括号把逻辑分组写清楚,别依赖优先级记忆。括号不花钱,但读代码的人会感谢你。

3.3 逻辑运算符与位运算符的混淆

新手常犯的另一个错误是把&&写成&,把||写成|。&和|是位运算符,它们不会短路,而且操作的是整数的每一个二进制位。对于bool类型,&和&&在结果上可能碰巧一样,但行为不同:&会强制求值两边。如果右边有副作用或者开销很大,用&就亏了。

更危险的是,&和|的优先级比==还低。if (a & b == c)实际上会被解析成if (a & (b == c)),这几乎肯定不是你想要的。所以记住:逻辑判断用&&和||,位操作用&和|,别混。

4. 位运算符:直接跟二进制打交道

4.1 六个位运算符的基本行为

位运算符有六个:&(按位与)、|(按位或)、^(按位异或)、~(按位取反)、<<(左移)、>>(右移)。它们直接操作整数的二进制表示,在底层编程、性能优化、状态压缩等场景里非常有用。

按位与&:两个位都为1时结果才为1。常用来做“掩码”操作,提取或清除某些位。比如x & 0xFF可以取出x的最低8位。

按位或|:两个位只要有一个为1结果就为1。常用来设置某些位。比如x | 0x01可以把最低位设为1。

按位异或^:两个位不同时结果为1,相同时为0。异或有个有趣的性质:a ^ b ^ b == a。所以异或常用来做简单的加密或者交换两个数(虽然实际项目中不建议用异或交换,可读性差)。

按位取反~:把每一位都翻转,0变1,1变0。注意~是一元运算符,只作用于一个操作数。

左移<<:把二进制位向左移动指定的位数,右边补0。左移n位相当于乘以2的n次方(在不溢出的前提下)。

右移>>:把二进制位向右移动指定的位数。对于无符号数,左边补0;对于有符号数,左边补什么取决于实现,通常是补符号位(算术右移),但标准并没有强制规定。

4.2 移位运算的未定义行为与边界

移位运算有几个容易踩的坑。首先,移位的位数不能是负数,也不能大于等于操作数的位宽。比如对于一个32位的int,移32位或者更多是未定义行为。我曾经写过一段代码,想用1 << 32来生成一个掩码,结果在某些平台上得到0,在某些平台上得到1,完全不可移植。正确的做法是用1ULL << 32(先提升到64位)或者用std::bitset。

其次,左移有符号数如果溢出也是未定义行为。比如int x = 1 << 31;,如果int是32位,这已经溢出了,标准不保证结果。无符号数的移位则是明确定义的,会按模回绕。

右移对于有符号负数的行为也需要注意。大多数平台对负数做算术右移,即保持符号位,所以-8 >> 1通常得到-4。但标准说这是实现定义的,不能完全依赖。如果要做可移植的位操作,建议用无符号类型。

4.3 位运算的实际应用场景

位运算在实际项目里用得很多,只是很多人没意识到。比如:

  • 权限系统:用不同的位表示不同的权限,READ = 1 << 0、WRITE = 1 << 1、EXECUTE = 1 << 2,然后通过|组合权限,通过&检查权限。
  • 状态压缩:一个整数可以存32个布尔状态,节省内存。
  • 哈希函数:很多哈希算法里用异或和移位来混合数据。
  • 图形编程:颜色值通常用0xAARRGGBB这样的格式存储,提取各通道需要移位和掩码。
// 权限示例 const unsigned READ = 1 << 0; // 0001 const unsigned WRITE = 1 << 1; // 0010 const unsigned EXECUTE = 1 << 2; // 0100 unsigned perm = READ | WRITE; // 0011 bool canRead = perm & READ; // true bool canExec = perm & EXECUTE; // false

用位运算做权限管理,代码紧凑,判断高效。但要注意别把位运算和逻辑运算搞混,perm & READ的结果是整数,不是bool,在条件里用没问题,但如果要返回bool,最好显式转换或者用!= 0判断。

5. 赋值与复合赋值:不只是写起来短

5.1 赋值运算符的返回值和链式赋值

赋值运算符=大家最熟悉,但它有一个特性容易被忽略:赋值表达式返回的是被赋值对象的引用。这意味着可以链式赋值:

int a, b, c; a = b = c = 10; // 从右往左,c=10返回c的引用,b=c,a=b

链式赋值看起来简洁,但可读性一般,而且如果类型不一致可能出问题。比如int和double混在一起链式赋值,可能发生隐式转换,丢精度。我个人的习惯是尽量避免链式赋值,一行一个赋值,清晰明了。

另外,赋值运算符的优先级很低,比关系运算符和算术运算符都低。所以a = b + c没问题,但a = b == c要小心,它等价于a = (b == c),把比较结果赋给a。这种代码虽然合法,但容易让人误解,建议加括号。

5.2 复合赋值运算符的隐式类型转换

复合赋值运算符包括+=、-=、*=、/=、%=、&=、|=、^=、<<=、>>=。它们把运算和赋值合在一起,写起来短,而且有一个重要的行为差异:复合赋值会先把右操作数转换成左操作数的类型,再运算。

int i = 10; double d = 3.14; i += d; // i 变成 13,d 被转换成 int 后是 3

如果写成i = i + d,i + d的结果是double,再赋给i会有一个隐式转换警告。而i += d则不会有这个警告,因为复合赋值内部做了转换。这个差异在写代码时要注意,别因为没警告就以为没问题,精度丢失是实实在在的。

5.3 赋值与初始化的区别

还有一个基础但重要的区分:赋值和初始化是两回事。初始化是在对象创建时给值,赋值是对象已经存在后修改它的值。对于基本类型,两者差别不大,但对于类类型,初始化调用构造函数,赋值调用赋值运算符,性能和行为可能不同。

int a = 10; // 初始化 int b; // 默认初始化 b = 10; // 赋值

在C++11之后,还多了列表初始化int c{10};,它能防止窄化转换,比如int d{3.14}会编译报错,而int d = 3.14只会警告。所以我现在更倾向于用花括号初始化,更安全。

6. 其它运算符:那些不常被归类的家伙

6.1 条件运算符:唯一的三元运算符

条件运算符?:是C++里唯一的三元运算符,形式是条件 ? 表达式1 : 表达式2。如果条件为真,返回表达式1的值,否则返回表达式2的值。它常用来替代简单的if-else,让代码更紧凑。

int max = (a > b) ? a : b;

但条件运算符有几个注意点。首先,两个表达式的类型要能兼容,否则可能发生意外的类型转换。比如一个返回int,一个返回double,整个表达式的结果类型是double。其次,条件运算符的优先级很低,仅高于赋值和逗号,所以嵌套使用时最好加括号。

我见过有人用嵌套条件运算符写很复杂的逻辑,三层四层套在一起,可读性极差。我的建议是:条件运算符只用于简单的二选一场景,超过一层嵌套就改用if-else,别为了省行数牺牲可读性。

6.2 sizeof:编译期求值的运算符

sizeof是一个运算符,不是函数。它返回类型或对象占用的字节数,结果类型是size_t。sizeof在编译期求值,所以不会带来运行时开销。

sizeof(int) // 通常是 4 sizeof(double) // 通常是 8 sizeof(arr) // 数组总字节数 sizeof(arr) / sizeof(arr[0]) // 数组元素个数

用sizeof求数组长度是一个经典技巧,但要注意:数组作为函数参数传递时会退化成指针,此时sizeof得到的是指针大小,不是数组大小。所以这个技巧只在数组还在当前作用域内有效。

void func(int arr[]) { // sizeof(arr) 是指针大小,不是数组大小 }

另外,sizeof作用于空类时结果是1,因为每个对象必须有唯一地址。作用于引用时返回被引用类型的大小。这些细节在面试里常被问到,实际写代码时也要心里有数。

6.3 逗号运算符与成员访问运算符

逗号运算符,会依次求值左右两边的表达式,然后返回右边表达式的值。它最常见的用途是在for循环里同时更新多个变量:

for (int i = 0, j = 10; i < j; ++i, --j) { // ... }

但逗号运算符很容易和函数参数分隔符混淆。在函数调用func(a, b)里,逗号是分隔符,不是逗号运算符。只有在明确需要顺序求值的场景才用逗号运算符,否则容易写出让人困惑的代码。

成员访问运算符有.和->。.用于对象直接访问成员,->用于指针访问成员。ptr->member等价于(*ptr).member。这两个运算符优先级很高,仅次于::、()、[]等。实际写代码时,->用起来更简洁,但要注意指针是否为空。

还有作用域解析运算符::,用于访问命名空间或类的成员。比如std::cout、MyClass::staticVar。这个运算符在组织代码结构时非常重要,但严格来说它不算“运算”,更多是名字查找的一部分。

7. 优先级与结合性:别背表,用括号

运算符优先级和结合性是很多人头疼的东西。C++有几十个运算符,优先级表很长,背下来不现实。我的建议是:记住几个大原则,然后不确定就加括号。

大原则是这样的:一元运算符(!、~、++、--、sizeof等)优先级高于二元运算符;算术运算符高于移位运算符,移位高于关系运算符,关系高于位运算符(&、^、|),位运算符高于逻辑运算符(&&、||),逻辑高于条件运算符,条件高于赋值运算符,逗号运算符优先级最低。

但这个顺序里有几个反直觉的地方。比如&的优先级比==低,所以if (a & b == c)会先算b == c。再比如<<的优先级比+低,所以cout << a + b会先算a + b再输出。这些地方如果记不清,加括号就完事了。

结合性方面,大多数二元运算符是左结合的,也就是从左往右算。赋值运算符是右结合的,所以a = b = c从右往左。条件运算符也是右结合的。这些规则在写复杂表达式时要注意,但同样,加括号能解决大部分问题。

我自己的编码习惯是:任何超过两个运算符的表达式,都加括号明确分组。代码是给人看的,不是给编译器看的。编译器不在乎你加了多少括号,但读代码的人在乎。

8. 几个我实际踩过的运算符坑

8.1 浮点数取模的替代方案

前面提过,%不能用于浮点数。那如果确实需要对浮点数取模怎么办?用std::fmod:

#include <cmath> double r = std::fmod(7.5, 2.0); // 1.5

std::fmod返回的符号跟被除数一致,跟整数取模的规则类似。还有一个std::remainder,行为略有不同,它返回的是四舍五入后的余数,符号可能跟被除数不同。实际用哪个取决于你的需求,大多数场景用fmod就够了。

8.2 位运算的符号扩展问题

有一次我写了一个从字节流里解析整数的逻辑,用移位和或运算拼接四个字节。代码大概是这样:

uint32_t value = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];

如果data是unsigned char数组,这段代码没问题。但如果data是char数组,而char在某些平台上是带符号的,那么data[0]如果大于127,会被符号扩展成一个负数,左移之后结果就完全错了。解决办法是先把每个字节转成unsigned char或者uint32_t再移位:

uint32_t value = (static_cast<uint32_t>(static_cast<unsigned char>(data[0])) << 24) | ...;

这个坑我在处理二进制协议的时候踩过,排查了很久才发现是符号扩展的问题。从那以后,处理字节数据我一律用unsigned char或uint8_t。

8.3 逻辑运算符的短路与函数副作用

还有一次,我写了一个检查函数,里面用&&连接了两个条件,第二个条件是一个有副作用的函数调用。结果因为第一个条件为假,第二个函数根本没执行,导致状态没更新。代码大概是这样:

if (isValid() && updateState()) { // ... }

我本意是“如果有效就更新状态”,但isValid()返回假的时候,updateState()被短路掉了。后来改成了分开写:

if (isValid()) { updateState(); // ... }

这个教训让我记住了:逻辑运算符只用来做纯判断,不要在里面塞有副作用的操作。短路求值是一个优化特性,不应该被依赖来实现业务逻辑。

8.4 自增自减在宏定义里的展开问题

虽然现在宏用得少了,但维护老代码时还是会遇到。比如一个宏#define SQUARE(x) ((x) * (x)),如果调用SQUARE(i++),展开后变成((i++) * (i++)),i被加了两次,而且求值顺序不确定,结果不可预测。这种问题在宏里很常见,解决办法是用内联函数替代宏,或者至少用static inline函数。C++里大多数宏都可以用constexpr函数或模板替代,更安全。

9. 运算符重载:自定义类型怎么参与运算

运算符重载是C++的一个强大特性,允许我们给自定义类型定义运算符的行为。比如给一个复数类重载+,让两个复数对象可以直接相加。但运算符重载有几个原则需要遵守,否则容易写出让人困惑的代码。

首先,不能改变运算符的优先级、结合性和操作数个数。你重载的+仍然是二元运算符,优先级跟内置的+一样。其次,不能重载内置类型之间的运算符,比如你不能让两个int相加变成别的东西。运算符重载至少要有一个操作数是自定义类型。

重载方式有两种:成员函数和友元函数。成员函数形式下,左操作数是调用对象;友元函数形式下,两个操作数都可以是参数。对于对称的二元运算符(比如+、==),通常用友元函数更自然,因为左右操作数可以互换。

class Complex { public: Complex(double r, double i) : re(r), im(i) {} Complex operator+(const Complex& other) const { return Complex(re + other.re, im + other.im); } private: double re, im; };

运算符重载要遵循直觉。+就应该是加法,==就应该是相等比较,别搞出反直觉的行为。如果某个运算符对自定义类型没有自然含义,就别重载它。比如给一个“人”类重载+表示“生孩子”,这种设计虽然能编译,但维护的人会想打人。

另外,赋值运算符=比较特殊。编译器会自动生成一个默认的拷贝赋值运算符,如果你没写的话。但如果类里有指针成员或者需要深拷贝,就必须自己重载,否则会出现浅拷贝问题,两个对象指向同一块内存,析构时重复释放导致崩溃。这是C++里经典的三法则/五法则问题,这里不展开,但要知道赋值运算符不是随便就能默认的。

10. 写代码时关于运算符的几条个人习惯

说了这么多运算符的规则和坑,最后分享几条我在实际写代码中养成的习惯,都是踩坑之后总结出来的。

第一条,不确定优先级就加括号。别觉得加括号显得水平低,代码可读性比炫技重要得多。我见过太多因为优先级问题导致的bug,加个括号就能避免。

第二条,有符号和无符号不要混用。如果实在避不开,显式转换,并且加注释说明为什么这么转。编译器警告要当回事,别随手关掉。

第三条,浮点数比较用epsilon。永远不要用==直接比较两个浮点数,除非你明确知道它们都是精确可表示的(比如0.5、0.25这种2的幂次)。

第四条,位运算操作无符号类型。有符号类型的移位和溢出行为有很多未定义或实现定义的地方,用无符号类型能避免很多麻烦。

第五条,逻辑表达式里不放副作用。短路求值是优化,不是控制流工具。条件判断就纯粹判断,动作放到条件体里。

第六条,自增自减单独成句。别在复杂表达式里混用i++和++i,未定义行为不值得冒险。

第七条,运算符重载要克制。只重载有自然语义的运算符,别为了炫技给自定义类型加一堆奇怪的运算符。

这些习惯看起来琐碎,但每一条背后都有真实的bug教训。运算符是C++里最基础的东西,但基础不牢,地动山摇。花时间把这些规则搞清楚,后面写代码会顺畅很多。

我在实际项目中还发现一个现象:很多人对运算符的理解停留在“能用就行”,遇到问题就上网搜一段代码复制过来,不去深究为什么这样写。短期看省了时间,长期看欠了技术债。运算符的规则虽然多,但都是有逻辑的,理解了设计意图,记忆负担会小很多。比如为什么整数除法要截断?因为C++要跟底层硬件行为保持一致,大多数CPU的整数除法指令就是截断的。为什么逻辑运算符要短路?因为这样能写出更安全、更高效的代码。理解了这些“为什么”,规则就不再是死记硬背的负担了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询