表达式
掌握 C++ 表达式的求值规则——运算符优先级/结合律、类型转换、左值与右值,能正确写出复杂表达式并理解每一步发生了什么
学习目标
- 能解释 C++ 中表达式求值的基本规则——运算符优先级、结合律如何决定运算顺序
- 能正确使用算术运算符、赋值运算符和递增递减运算符写出预期的表达式,并区分
++i和i++的行为差异 - 能回答:
int x = 5; int y = x++ + ++x;会导致什么问题?为什么?
机制总览
表达式:机制路径
- 1
直觉:程序里每一个“算”都有规矩
想象一条工厂流水线:原料从一端进入,经过切割、打磨、组装、喷漆——每一步都有固定的顺序,不能先喷漆再切割。每一步的操作员也只认特定规格的原料:你给切割工塞一块布,他只会摇头。
- 2
表达式——把"计算"写出来
在 C++ 里, 表达式(expression) 就是你告诉计算机"算一下这个"的基本单位。一个最简单的表达式可以只是一个变量名或一个字面值(比如 42 或 x ),它们本身就是一个求值结果。一旦你把运算符和操作数组合起来——比如 a + b c ——就形成了一个更复杂的表达式。
- 3
官方运算符族与求值契约
Chapter 4 还包括 logical/relational operators、member access、conditional、bitwise、 sizeof 、comma operator 和 order of evaluation。它们不能只靠优先级表带过,因为“表达式怎样分组”和“操作数何时求值”是两套规则。
章级决策实验
表达式:机制与证据
切换《表达式》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 直觉:程序里每一个“算”都有规矩
想象一条工厂流水线:原料从一端进入,经过切割、打磨、组装、喷漆——每一步都有固定的顺序,不能先喷漆再切割。每一步的操作员也只认特定规格的原料:你给切割工塞一块布,他只会摇头。
可核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:程序里每一个“算”都有规矩」的契约。
学完《表达式》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
表达式:失效与核验
直觉:程序里每一个“算”都有规矩
典型失效
若把「直觉:程序里每一个“算”都有规矩」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:程序里每一个“算”都有规矩」的契约。
表达式——把"计算"写出来
典型失效
若把「表达式——把"计算"写出来」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「表达式——把"计算"写出来」的契约。
官方运算符族与求值契约
典型失效
若把「官方运算符族与求值契约」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「官方运算符族与求值契约」的契约。
直觉:程序里每一个“算”都有规矩
想象一条工厂流水线:原料从一端进入,经过切割、打磨、组装、喷漆——每一步都有固定的顺序,不能先喷漆再切割。每一步的操作员也只认特定规格的原料:你给切割工塞一块布,他只会摇头。
在 C++ 里,每一次「算」就是这条流水线上的一个加工步骤。你把要算的数据(原料)和要做的动作(加工)组合起来,告诉计算机按什么顺序一步步算出结果。优先级高的工序先做(就像先切割再喷漆),不同类型的料要先统一规格才能放同一条线。
没有这些规矩会怎样?a + b * c 到底先算加法还是乘法,全看计算机心情——结果乱套,bug 遍地。这一章就把流水线的运行手册讲透。
表达式——把"计算"写出来
在 C++ 里,↡由操作数和运算符组成的计算单元,求值后会产生一个结果。最简单的表达式就是一个变量或一个字面值;复杂的表达式由多个运算符和操作数组合而成。 就是你告诉计算机"算一下这个"的基本单位。一个最简单的表达式可以只是一个变量名或一个字面值(比如 42 或 x),它们本身就是一个求值结果。一旦你把运算符和操作数组合起来——比如 a + b * c——就形成了一个更复杂的表达式。
每个表达式求值之后都会产生一个结果,并且结果也有自己的类型。比如 3 + 4 的结果是 7(类型是 int),3.0 + 4.0 的结果是 7.0(类型是 double)。
表达式的核心构成只有两类东西:
- ↡参与运算的数据,可以是变量、字面值或者另一个表达式的求值结果。比如在 a + b 里,a 和 b 就是 + 的两个操作数。——参与运算的数据,可以是变量、字面值,也可以是另一个子表达式的结果
- ↡对操作数执行的操作,如 +、-、*、/、% 等。每个运算符规定了它能接受的操作数数量和类型、运算的规则,以及结果的类型。——对操作数执行的操作,比如
+做加法,*做乘法
运算符优先级——哪个先算?
当你写出 a + b * c 这句时,计算机看到三个东西:a、b、c 和两个运算符 +、*。问题来了——先算 + 还是先算 *?答案取决于 ↡当表达式中有多个运算符时,决定哪些运算符先算的规则。优先级高的先算,优先级低的后算。比如 * 的优先级比 + 高,所以 a+b*c 先算 b*c 再算 +。。
C++ 里 乘除 * / % 的优先级比加减 + - 高,这和你在数学课上学到的规则一模一样。所以 a + b * c 等价于 a + (b * c)。
来用表达式树把这件事看清楚——叶子是最原始的数据,越深的层越先算:
运算符结合律——同级从左到右(或反过来)
如果一条流水线上两个工序的优先级完全一样怎么办?比如 a - b + c——减法和加法优先级相同。这时候 ↡当同一表达式中多个同优先级的运算符出现时,决定这些运算符从左到右还是从右到左求值的规则。大多数是左结合(从左到右),少数(赋值 = 和一元的 ++ --)是右结合。 上场:加减是左结合的,也就是从左到右算——等效于 (a - b) + c。
赋值运算符是右结合的:a = b = 0 分组为 a = (b = 0)。前缀一元运算符也按右到左分组,而后缀自增/自减属于优先级更高的 postfix expression。结合方向只决定语法分组,不应被读成运行时求值顺序。
下面是 C++ 常用运算符的优先级速查表:
算术运算符——加减乘除取余
C++ 提供了五种基本 ↡执行算术运算的运算符:+(加)、-(减)、*(乘)、/(除)、%(取余/取模)。它们要求操作数通常是相同类型,结果类型也是操作数的类型。:+ - * / %。前四种和数学课一样,注意两点:
- 整数除法
/会截断小数部分:7 / 2的结果是3,不是3.5。要得到小数结果,把至少一个操作数换成double:7.0 / 2才是3.5。 - 取余
%的操作数必须是整数:7 % 2结果是1(7 除以 2 的余数)。%不能用于浮点数。
#include <iostream>
int main() {
int a = 7, b = 2;
std::cout << a + b << std::endl; // 9
std::cout << a - b << std::endl; // 5
std::cout << a * b << std::endl; // 14
std::cout << a / b << std::endl; // 3(整数除法截断)
std::cout << a % b << std::endl; // 1(取余数)
std::cout << 7.0 / 2 << std::endl; // 3.5(浮点除法)
return 0;
}赋值运算符——不只是等号
↡把右侧的值写入左侧变量的运算符。最基本的 = 叫简单赋值,复合赋值如 += -= *= /= 把运算和赋值合并成一步。赋值的返回结果是被赋值之后左侧变量的值,因此可以链式赋值(a = b = 0)。 最基本的形式是 =——把右边的值写进左边的变量。但它不止于此:
C++ 还有 ↡把某个算术或位运算与赋值合并成一步的运算符;它只求值一次左操作数,意图是基于原值更新。——把运算和赋值合并成一步:
int x = 10;
x += 3; // 等价于 x = x + 3; → x 变成 13
x -= 5; // 等价于 x = x - 5; → x 变成 8
x *= 2; // 等价于 x = x * 2; → x 变成 16
x /= 4; // 等价于 x = x / 4; → x 变成 4
x %= 3; // 等价于 x = x % 3; → x 变成 1复合赋值的好处是:少写一次变量名(x += 1 比 x = x + 1 简洁),而且意图更清晰——"在原值基础上加"。赋值表达式本身会返回被赋值之后的值,因此你可以链式赋值:
int a, b, c;
a = b = c = 0; // 从右往左:c=0 返回 0→b=0 返回 0→a=0。三个变量都是 0。递增与递减——加一减一的"快车道"
++ 和 -- 是 C++ 里最常用的两个 ↡++ 运算符让变量加 1,-- 运算符让变量减 1。可以放在变量前面(前缀,如 ++i)或后面(后缀,如 i++),两者对变量最终值的影响相同,但表达式的返回值不同。——分别让变量加 1 和减 1。它们有前缀(++i、--i)和后缀(i++、i--)两种写法,对变量最终值的影响完全一样(i 都会变成 i+1),但表达式本身的返回值不同——这是 C++ 初学者最频繁踩的坑之一。
下面这张时间线图直观对比了两种写法在"运算的瞬间"到底发生了什么:
核心口诀:前缀先加再用,后缀先用再加。
int i = 5, j = 0;
// ++i:先自增为 6,再赋给 j。结果 i=6, j=6。
j = ++i;
// i++:先把旧值 6 赋给 j,然后 i 自增为 7。结果 i=7, j=6。
j = i++;类型转换——当不同规格的原料被放进同一条流水线
当你把两种不同类型的值放进同一个表达式时——比如 3.14 + 10,一个 double 加一个 int——C++ 怎么处理?答案是 ↡编译器在不需要程序员明确指定的情况下自动执行的类型转换。通常把「窄类型」(容量小的)转为「宽类型」(容量大的),遵循「不丢失信息」的原则。比如 int 和 double 一起运算,int 会被自动提升为 double。:编译器会自动把"窄类型"提升为"宽类型",确保运算在统一的类型下进行。
3.14 + 10 中,10 这个 int 会被自动转换为 double 类型的 10.0,然后两个 double 相加得 13.14。整个过程你不需要写任何转换代码。
下面的转换链图展示了 C++ 内建类型之间的隐式转换方向:
很多时候,隐式转换的方向你可能需要反过来——比如把一个 double 变成 int。这时候你必须用 ↡由程序员明确要求的类型转换,表明可能发生信息损失或类型语义变化,并接受对应的编译期检查。,最推荐的是 C++ 风格 ↡C++ 常用的显式类型转换运算符,用目标类型和表达式说明转换意图,并在编译期检查可转换性。:
double pi = 3.14159;
int rounded = static_cast<int>(pi); // 显式转换:把 pi 截断为 3
int bad = pi; // 隐式截断(虽然能编译,但不推荐)
// int bad2{pi}; // ❌ 列表初始化会拒绝窄化转换static_cast 的好处:它明确告诉编译器也告诉读代码的人——"我知道这里会丢精度,这是我故意的"。用 static_cast 代替 C 风格转换 (int)pi,代码意图更清晰、更安全。
官方运算符族与求值契约
Chapter 4 还包括 logical/relational operators、member access、conditional、bitwise、sizeof、comma operator 和 order of evaluation。它们不能只靠优先级表带过,因为“表达式怎样分组”和“操作数何时求值”是两套规则。
↡表达式子操作数在运行时被计算、读取和产生副作用的先后约束;它独立于优先级和结合律。在 C++11 中常常未指定。优先级只把 a + b * c 分组为 a + (b * c),不保证函数参数或大多数二元运算符的左操作数先算。若同一 scalar object 的修改与另一次修改/读取彼此 unsequenced,行为未定义。
&&、||、conditional ?: 与 comma operator 的选定分支具有明确定序,并可 short-circuit;bitwise &、| 不是逻辑短路。f() && g() 在 f() 为假时不会调用 g(),但 f() & g() 会计算两边。
↡表达式结果是否标识可定位对象的分类;左值通常有身份可取地址,右值通常用于读取或移动。影响赋值、引用绑定和 member access。Assignment 左侧必须是可修改 lvalue;decltype((x)) 对 lvalue 得到引用;std::move(x) 只把表达式转换为可移动的 xvalue,不立即移动对象。
常被遗漏的运算符族可以按责任检查:
- Logical/relational:比较产生
bool,不要链写a < b < c;第二次比较实际拿bool与c比。 - Member access:
.接对象,->接指针;ptr->m等价于(*ptr).m,括号来自 postfix/member precedence。 - Conditional:
cond ? a : b只求值一个分支,结果类型由两个分支共同决定。 - Bitwise:只用于 integral operands;位移量必须合法,signed 位运算要特别警惕表示与溢出。
- sizeof:返回
size_t,通常不求值普通 operand;对数组得到完整数组大小,对指针只得指针大小。 - Comma:先求值左侧并丢弃其值,再求值右侧;不要与函数参数之间的逗号混淆。
Widget object;
Widget* pointer = &object;
auto a = object.member; // . 访问对象成员
auto b = pointer->member; // 等价于 (*pointer).member
auto bytes = sizeof object; // 对象大小
auto chosen = ready ? a : b; // 只求值选中的分支动手:拆解一个复杂表达式的求值过程
下面把一个嵌套赋值 + 复合运算的表达式一步步拆解,看计算机到底在每一步做了什么运算、每个变量的值怎么变化。
猜一猜:
int a = 5, b = 2; int c = ++a * b-- + 3;执行完以后 a、b、c 各是什么?通过下面 Stepper 一步步看。
① 初始状态:声明并初始化 a 和 b
程序首先定义两个整型变量 a = 5、b = 2。这行执行完后,内存里有了两块格子:a 那块存着 5,b 那块存着 2。接下来进入求值环节。
代码速查:表达式常用写法
下面把你日常写代码时最常见的表达式模式汇总在一起,快速对照:
// 算术运算
int sum = a + b;
int diff = a - b;
int product = a * b;
int quotient = a / b; // 整数除法会截断
int remainder = a % b; // 取余,操作数必须是整数
// 复合赋值(比 x = x + 1 更简洁)
x += 1; // 自增 1
x *= 2; // 翻倍
x %= 10; // 取模后赋回
// 递增递减(单独用效果一样,有赋值目标时才区分)
++i; // 前缀:先加再用,返回值是新值
i++; // 后缀:先用后加,返回值是旧值
// 类型转换
double d = 3.14159;
int i = static_cast<int>(d); // 显式截断 → 3
auto f = static_cast<float>(d); // 显式变 float三行"安全法则"帮你避免 90% 表达式 bug:
- 不确定优先级就加括号——括号最早算,且让意图一目了然
- 别在一个表达式里多次改同一个变量——拆成多行写,编译器更喜欢、你也更不会搞错
- 隐式转换要心中有数——窄转宽安全、宽转窄必须用
static_cast说清楚
容易踩的坑
小结
- 运算符优先级和结合律决定了复杂表达式里的运算顺序——乘除优先于加减,同级从左到右,赋值和一元右结合
- 前置
++i和后置i++对变量最终值的影响相同,但子表达式的返回值不同——前缀返回新值、后缀返回旧值 - 不同类型出现在同一表达式里会触发隐式转换——窄类型自动转宽类型,逆方向需要用
static_cast显式转换 - 为避免未定义行为,不要在一条表达式里多次修改同一个变量
- 不确定运算顺序时——加括号;不确定类型转换方向时——用
static_cast;表达式太长拆成多行——清晰永远比聪明重要
练习
问题 1(改代码型) 下面这段代码里藏着一个未定义行为和一个类型转换问题。找出它们并写出修正后的代码。
#include <iostream>
int main() {
int i = 3;
int j = ++i * i++ + i; // 问题 1
unsigned int u = 5;
int n = -2;
std::cout << u + n << std::endl; // 问题 2
return 0;
}问题 2(问答型) 指出 6 % 4、-6 % 4、6 % -4 各自的结果。C++ 里取余结果的符号规则是什么?下面代码输出什么?解释原因。
#include <iostream>
int main() {
std::cout << 6 % 4 << std::endl; // ①
std::cout << -6 % 4 << std::endl; // ②
std::cout << 6 % -4 << std::endl; // ③
return 0;
}问题 3(综合型) 逐行写出下面程序的每一步输出,并解释每步 a、b 的值如何变化。
#include <iostream>
int main() {
int a = 10, b = 3;
std::cout << a / b << std::endl; // ①
std::cout << a % b << std::endl; // ②
a += b++ * 2;
std::cout << a << std::endl; // ③
std::cout << b << std::endl; // ④
a = static_cast<double>(a) / 3;
std::cout << a << std::endl; // ⑤
return 0;
}名词解释
本章出现的专业名词,用大白话再讲一遍。
- 表达式(expression)
由操作数和运算符组成的计算单元,求值后会产生一个结果。最简单的表达式可以只是一个变量名
x或字面值42,复杂的表达式由多个运算符和操作数层层嵌套构成。详见本章「表达式」一节和 ExpressionTreeDiagram 图示。- 操作数(operand)
参与运算的数据,"原料"。可以是一个变量名(如
a),一个字面值(如42),或另一个子表达式的结果。一个运算符要求多少个操作数取决于它的种类——+要两个(二元),++只要一个(一元)。- 运算符(operator)
对操作数执行的操作,"加工动作"。C++ 有算术运算符(
+-*/%)、赋值运算符(=)、比较运算符(< > == !=)、逻辑运算符(&& || !)等几十种,各有各的优先级和结合律规则。- 优先级(precedence)
当表达式中有多个运算符时,决定哪些运算符先算的规则。优先级高的先算——就像
*优先级比+高,所以a+b*c先算b*c。详见本章「运算符优先级」一节和 PrecedenceTable 图示。- 结合律(associativity)
同一表达式中多个同优先级运算符出现时,决定从左到右还是从右到左求值的规则。绝大多数运算符是左结合(从左到右),只有赋值
=和一元++ -- !等少数是右结合(从右到左)。详见本章「运算符结合律」一节。- 算术运算符(arithmetic operator)
执行基本数学运算的运算符:
+(加)、-(减)、*(乘)、/(除)、%(取余)。整数/会截断小数部分(不是四舍五入),%只能用于整数。详见本章「算术运算符」一节。- 赋值运算符(assignment operator)
把右侧的值写入左侧变量的运算符。
=是最基本的简单赋值。复合赋值如+=-=*=/=%=把运算和赋值合并成一步(x+=1等价于x=x+1)。详见本章「赋值运算符」一节。- 复合赋值运算符(compound assignment)
把算术运算和赋值合并的运算符。比如
x += 1就是x = x + 1的简写。好处是少打一次变量名、意图更清晰。不光是+=——-=*=/=%=&=|=^=<<=>>=都是同类。- 递增递减运算符
++让变量加 1,--让变量减 1。单独用时++i和i++效果一样(i 都变 i+1),但它们表达式的返回值不同:前缀++i先加 1 再返回新值,后缀i++先返回旧值再加 1。详见本章「递增与递减」一节和 IncrementDecrementDiagram 图示。- 隐式类型转换(implicit conversion)
编译器在不需要你明确指定的情况下自动做的类型转换。通常把"窄类型"转成"宽类型"(如 int→double),遵循"不丢信息"的原则。混用 signed 和 unsigned 时也有隐式转换——这是 bug 高频来源。详见本章「类型转换」一节和 TypeConversionDiagram 图示。
- 显式转换(explicit cast)
你明确写出来的类型转换,告诉编译器"我知道可能会丢数据,故意这么做的"。推荐用 C++ 风格的
static_cast<目标类型>(值),比 C 风格(类型)值更安全、可读性更好。详见本章「类型转换」一节。- static_cast
C++ 提供的最常用的显式类型转换操作符。语法:
static_cast<目标类型>(表达式)。名字里有"static"是因为它在编译期就做完转换检查,不依赖运行时。可用于数值类型转换、父类子类指针转换等。详见本章「类型转换」一节。- 求值顺序
子表达式的值计算和副作用在运行时的先后约束。它独立于优先级与结合律;同一对象的未定序修改和读取可能造成未定义行为。
- 左值与右值
表达式的 value category。左值通常标识有身份、可定位的对象;右值通常表示可读取或可移动的值,并影响赋值和引用绑定规则。