三、运算符与表达式:算术、关系、逻辑、位、赋值、自增自减运算符,运算符优先级与结合性,表达式求值

运算符是C++语言中用于执行数据操作的符号,而表达式则是由运算符、操作数和标点符号组成的有效序列。在C++编程中,几乎所有的计算逻辑都依赖于运算符的正确使用。本章将系统性地剖析C++中的六大类运算符:算术、关系、逻辑、位、赋值以及自增自减,并深入探讨运算符的优先级与结合性规则。理解这些规则不仅是编写正确代码的前提,更是后续进行项目实战(如嵌入式控制、算法设计、游戏开发)时优化性能与规避隐蔽Bug的关键。本章将通过原理剖析、对比表格、代码案例与常见误区复盘,帮助您建立扎实的表达式求值思维。

C++ 运算符与表达式知识框架 表达式求值 算术运算符 关系运算符 逻辑运算符 位运算符 赋值运算符 自增/自减运算符 优先级与结合性规则 决定表达式求值顺序,避免歧义与副作用

3.1 算术运算符与表达式

核心原理

算术运算符用于执行基本的数学运算。C++中的算术运算符分为一元运算符和二元运算符。一元运算符包括正号(+)和负号(-),二元运算符包括加(+)、减(-)、乘(*)、除(/)和取模(%)。其中,除法运算符(/)的行为取决于操作数的类型:如果两个操作数都是整数,则执行整数除法,结果向零截断;如果至少有一个操作数是浮点数,则执行浮点除法。取模运算符(%)要求两个操作数均为整数,返回除法后的余数,其结果的符号与被除数(左操作数)相同。

关键点:整数除法与浮点除法的区别是初学者最容易犯的错误之一。例如,7 / 2 的结果是 3 而不是 3.5,因为两个操作数都是整数。若要得到浮点结果,应写成 7.0 / 2 或 7 / 2.0。

判断方法或操作流程

在编写算术表达式时,应遵循以下判断流程:

  1. 识别操作数类型:确认每个操作数是整数(int、short、long等)还是浮点数(float、double)。
  2. 确定运算类型:根据操作数类型决定除法是整数除法还是浮点除法。
  3. 检查溢出风险:对于有符号整数运算,注意结果是否超出类型范围。
  4. 考虑类型转换:当混合使用整数和浮点数时,整数会被隐式转换为浮点数。

案例拆解

假设需要计算一个班级学生的平均分,总分为 456,人数为 7。如果直接写 int avg = 456 / 7;,得到的结果是 65,丢失了小数部分。正确的做法是使用浮点数:double avg = 456.0 / 7;,结果为 65.142857...。在实际项目中,这种精度差异可能影响成绩排名、财务计算等场景。

常见误区

  • 取模运算的符号问题:-7 % 3 的结果是 -1,而不是 2。C++标准规定,取模结果的符号与被除数相同。
  • 除零错误:整数除以零会导致程序崩溃(运行时异常),浮点数除以零会得到 inf 或 nan,但不会崩溃。务必在运算前检查除数。
  • 隐式类型转换的陷阱:int a = 5; double b = a / 2; 的结果是 2.0 而不是 2.5,因为 a / 2 先执行整数除法得到 2,再转换为 2.0。

3.2 关系运算符与表达式

核心原理

关系运算符用于比较两个值的大小或相等性,结果为布尔类型(bool),即 true 或 false。C++提供六种关系运算符:大于(>)、小于(<)、大于等于(>=)、小于等于(<=)、等于(==)、不等于(!=)。关系运算符常用于条件判断(如 if 语句)和循环控制。

运算符含义示例结果
>大于5 > 3true
<小于5 < 3false
>=大于等于5 >= 5true
<=小于等于5 <= 4false
==等于5 == 5true
!=不等于5 != 5false

判断方法或操作流程

使用关系运算符时,需要注意以下几点:

  • 浮点数比较:由于浮点数的精度限制,直接使用 == 比较两个浮点数往往不可靠。应使用值差小于某个极小值(如 1e-9)的方式。
  • 连续比较:C++不支持数学中的 a < b < c 写法,这会被解析为 (a < b) < c,即先计算 a < b 得到布尔值,再与 c 比较。应使用 a < b && b < c。
  • 字符比较:字符类型(char)可以按ASCII码值进行比较,如 'a' < 'b' 为 true。

案例拆解

在温度控制系统中,需要判断当前温度是否在安全范围内。假设温度传感器返回浮点数 temp,安全范围为 20.0 到 80.0 摄氏度。错误的写法是 if (20.0 <= temp <= 80.0),这会导致逻辑错误。正确的写法是 if (temp >= 20.0 && temp <= 80.0)。此外,由于浮点误差,判断边界时应使用容差:if (temp >= 20.0 - 1e-9 && temp <= 80.0 + 1e-9)。

常见误区

风险提示:将赋值运算符 = 误写为等于运算符 == 是C++中最常见的错误之一。例如 if (x = 5) 会将 5 赋值给 x,然后判断 x 是否为非零(此处为真),导致逻辑错误且难以排查。建议在编译时开启 -Wall 警告选项,编译器会对此类错误给出警告。

3.3 逻辑运算符与短路求值

核心原理

逻辑运算符用于组合多个条件表达式,包括逻辑与(&&)、逻辑或(||)和逻辑非(!)。逻辑与和逻辑或具有短路求值特性:对于 expr1 && expr2,如果 expr1 为 false,则整个表达式为 false,不再计算 expr2;对于 expr1 || expr2,如果 expr1 为 true,则整个表达式为 true,不再计算 expr2。短路求值可以避免不必要的计算,甚至防止程序错误(如除零)。

判断方法或操作流程

在编写逻辑表达式时,应遵循以下原则:

  1. 将最可能为 false 的条件放在 && 的左侧,以尽早短路,提高效率。
  2. 将最可能为 true 的条件放在 || 的左侧,同样为了尽早短路。
  3. 利用短路求值保护代码:例如 if (ptr != nullptr && ptr->value > 10),当 ptr 为空指针时,不会执行 ptr->value,避免空指针解引用。

案例拆解

考虑一个用户登录验证场景:需要检查用户名非空且密码长度大于等于6位。代码可以写成:

std::string username = getUsername();
std::string password = getPassword();
if (!username.empty() && password.length() >= 6) {
    // 执行登录逻辑
} else {
    // 提示输入错误
}

这里利用短路求值,如果用户名为空,则不会计算 password.length(),节省了一次字符串长度计算。更重要的是,如果 password 是一个可能未初始化的对象,短路求值可以避免访问无效数据。

常见误区

  • 混淆位运算符与逻辑运算符:& 和 | 是位运算符,&& 和 || 是逻辑运算符。位运算符不会短路,且操作数必须是整数类型。
  • 逻辑非的优先级:!x > 5 被解析为 (!x) > 5,而不是 !(x > 5)。因为 ! 的优先级高于 >。
  • 过度依赖短路求值:如果 expr2 有副作用(如修改变量),短路求值可能导致副作用不执行,从而产生隐蔽Bug。

3.4 位运算符与位操作

核心原理

位运算符直接操作整数的二进制位,包括按位与(&)、按位或(|)、按位异或(^)、按位取反(~)、左移(<<)和右移(>>)。位运算在底层系统编程、嵌入式开发、数据压缩和性能优化中至关重要。例如,使用位掩码可以高效地管理多个布尔标志。

运算符名称规则示例(8位)
&按位与两个位都为1时结果为10b1100 & 0b1010 = 0b1000
|按位或至少一个位为1时结果为10b1100 | 0b1010 = 0b1110
^按位异或两个位不同时结果为10b1100 ^ 0b1010 = 0b0110
~按位取反0变1,1变0~0b1100 = 0b...0011
<<左移各二进位全部左移,高位丢弃,低位补00b0001 << 2 = 0b0100
>>右移各二进位全部右移,低位丢弃0b1000 >> 2 = 0b0010

判断方法或操作流程

位运算的典型应用场景包括:

  • 权限管理:使用一个整数表示多个权限位,如 READ = 1 << 0,WRITE = 1 << 1,EXECUTE = 1 << 2。
  • 快速乘除:左移一位相当于乘以2,右移一位相当于除以2(对于无符号数)。
  • 掩码操作:提取或清除特定的位。例如 value & 0xFF 提取低8位。
  • 交换变量:使用异或可以在不使用临时变量的情况下交换两个整数:a ^= b; b ^= a; a ^= b;。

案例拆解

在嵌入式开发中,经常需要操作寄存器的特定位。假设一个8位寄存器 reg,需要将第3位(bit2)置1,同时保持其他位不变。正确操作是 reg |= (1 << 2);。如果需要将第3位清零,则 reg &= ~(1 << 2);。如果需要判断第3位是否为1,则 if (reg & (1 << 2))。这些操作在硬件驱动中极为常见。

常见误区

风险提示:右移操作对于有符号整数的行为是“实现定义”的。对于负数,右移可能是算术右移(符号位填充)或逻辑右移(0填充)。在跨平台代码中,应避免对有符号负数进行右移操作,或使用无符号类型。

3.5 赋值运算符与复合赋值

核心原理

赋值运算符(=)用于将右侧的值赋给左侧的变量。C++还提供复合赋值运算符,如 +=、-=、*=、/=、%=、<<=、>>=、&=、^=、|=。复合赋值运算符将运算与赋值结合,例如 x += 5 等价于 x = x + 5。赋值运算符的结合性是右结合,即 a = b = c 被解析为 a = (b = c)。

判断方法或操作流程

赋值表达式的求值规则:

  1. 右结合性:从右向左依次赋值。
  2. 赋值表达式的值:整个赋值表达式的值等于左侧变量被赋值后的值。例如 int a; int b = (a = 5); 中,b 的值为 5。
  3. 类型转换:如果右侧值的类型与左侧变量类型不一致,会发生隐式类型转换,可能导致精度损失。

案例拆解

在循环中累加求和时,复合赋值运算符可以简化代码并提高可读性:

int sum = 0;
for (int i = 1; i <= 100; ++i) {
    sum += i;  // 等价于 sum = sum + i;
}

在硬件寄存器操作中,reg |= 0x04; 比 reg = reg | 0x04; 更简洁,且在某些编译器优化下生成的机器码更高效。

常见误区

  • 赋值与等于混淆:在条件判断中误用 = 代替 ==,这是最常见的错误。
  • 链式赋值的类型问题:int a; double b; a = b = 3.14; 中,b 被赋值为 3.14,然后 a 被赋值为 3(截断)。
  • 复合赋值与普通赋值的细微差别:对于复杂表达式,x += y 与 x = x + y 在语义上等价,但 x 只被求值一次。如果 x 是一个带有副作用的表达式(如 arr[i++]),两者行为可能不同。

3.6 自增与自减运算符

核心原理

自增(++)和自减(--)运算符用于将变量的值增加或减少1。它们有两种形式:前缀形式(++x)和后缀形式(x++)。前缀形式先增加变量的值,然后返回增加后的值;后缀形式先返回变量的当前值,然后再增加变量的值。这两种形式在独立语句中效果相同,但在表达式中会产生不同的结果。

判断方法或操作流程

选择前缀还是后缀形式,应遵循以下原则:

  • 独立语句:两者等价,但推荐使用前缀形式,因为后缀形式在某些编译器下可能产生临时对象,略微降低性能(对于内置类型差异不大,但对于迭代器等复杂类型差异明显)。
  • 在表达式中:需要明确返回值的使用场景。例如 int y = x++; 中 y 得到 x 的旧值;而 int y = ++x; 中 y 得到 x 的新值。

案例拆解

在数组遍历中,arr[i++] = value; 将 value 赋给 arr[i],然后 i 自增。而 arr[++i] = value; 先将 i 自增,再赋值给 arr[i]。在循环中,for (int i = 0; i < n; ++i) 与 for (int i = 0; i < n; i++) 效果相同,但前者在C++标准库迭代器中性能更优。

常见误区

风险提示:在同一表达式中多次修改同一个变量是未定义行为。例如 i = i++ + ++i; 的结果在不同编译器上可能不同,甚至导致程序崩溃。C++标准规定,在两个序列点之间修改变量多次是未定义行为。应避免编写此类代码。

3.7 运算符优先级与结合性

核心原理

运算符优先级决定了在表达式中先执行哪些运算。优先级高的运算符先被求值。结合性决定了当多个相同优先级的运算符连续出现时,求值的顺序(从左到右或从右到左)。C++的优先级规则复杂,但可以通过以下表格快速查阅关键运算符的优先级(从高到低):

优先级运算符结合性
1(最高)() [] -> . 后缀++/--左到右
2! ~ 前缀++/-- + - * & (类型) sizeof右到左
3* / %左到右
4+ -左到右
5<< >>左到右
6< <= > >=左到右
7== !=左到右
8&左到右
9^左到右
10|左到右
11&&左到右
12||左到右
13?:右到左
14= += -= *= /= %= <<= >>= &= ^= |=右到左
15(最低),左到右

判断方法或操作流程

在实际编程中,不建议完全依赖优先级表,而是使用括号明确表达意图。判断表达式求值顺序的步骤:

  1. 加括号:根据优先级规则,将表达式隐式地加上括号。
  2. 确定结合性:对于相同优先级的运算符,根据结合性确定顺序。
  3. 注意副作用:即使确定了运算顺序,也不要在一个表达式中多次修改同一变量。

案例拆解

考虑表达式 int result = 2 + 3 * 4 >> 1;。根据优先级,乘法(*)优先级高于加法(+),加法高于右移(>>),右移高于赋值(=)。因此求值顺序为:3 * 4 = 12,然后 2 + 12 = 14,然后 14 >> 1 = 7,最后 result = 7。如果意图是先计算 (2 + 3) * 4,则必须写成 int result = (2 + 3) * 4 >> 1;,结果为 20 >> 1 = 10。

常见误区

经验提醒:不要试图记住所有运算符的优先级。在团队协作中,代码的可读性比“炫技”更重要。当表达式较复杂时,使用括号明确分组,既避免错误,也方便他人阅读。例如,if ((a & b) == c) 比 if (a & b == c) 更清晰,后者会被解析为 a & (b == c),因为 == 优先级高于 &。

3.8 表达式求值综合实战与复盘

核心原理

表达式求值不仅仅是运算符的简单堆砌,还涉及类型转换、副作用、求值顺序等多个维度。C++标准规定,除了 &&、||、?: 和逗号运算符外,其他二元运算符的操作数求值顺序是未指定的(unspecified)。这意味着编译器可以自由选择先计算左操作数还是右操作数。因此,依赖操作数求值顺序的代码是不可移植的。

案例拆解(完整闭环)

背景与约束:某嵌入式项目需要计算传感器数据的校验和。传感器返回一个16位无符号整数数组 data[4],校验和定义为所有元素按位异或的结果。项目要求代码高效、可移植,且不能使用第三方库。

初始现象与目标:初版代码为 uint16_t checksum = data[0] ^ data[1] ^ data[2] ^ data[3];。该代码在目标编译器上运行正常,但在更换编译器后,结果出现偶发错误。

关键输入数据与日志:通过调试发现,当 data[2] 和 data[3] 的值较大时,错误概率增加。进一步分析,问题出在隐式类型转换上:data[0] ^ data[1] 的结果是 int 类型(因为整数提升),然后与 data[2] 异或,结果仍为 int,最后赋值给 uint16_t 时发生截断。虽然最终结果通常正确,但在某些边界情况下,符号位扩展可能导致意外行为。

分析判断过程:查阅C++标准,发现 uint16_t 在大多数平台上被提升为 int(如果 int 能表示所有 uint16_t 的值),这导致中间结果类型为有符号 int。当异或结果的最高位(bit15)为1时,作为有符号数解释为负数,再进行后续异或时,符号位会扩展,导致最终结果错误。

实际操作步骤:修复方案是将每个元素显式转换为 uint16_t 后再异或,或者使用无符号类型进行中间计算:

uint16_t checksum = static_cast<uint16_t>(data[0]) ^
                    static_cast<uint16_t>(data[1]) ^
                    static_cast<uint16_t>(data[2]) ^
                    static_cast<uint16_t>(data[3]);

更简洁的方式是使用 uint32_t 作为累加器,最后截断:uint16_t checksum = static_cast<uint16_t>(data[0] ^ data[1] ^ data[2] ^ data[3]);,但需确保 uint32_t 能容纳所有中间结果。

结果对比与复盘:修复后,代码在不同编译器上均表现稳定。复盘发现,根本原因是对“整数提升”规则理解不足。在C++中,小于 int 的整数类型在运算前会被提升为 int,这可能导致有符号性问题。经验教训:涉及位运算时,应明确使用无符号类型,避免隐式符号扩展。

常见误区总结