表达式

掌握 C++ 表达式的求值规则——运算符优先级/结合律、类型转换、左值与右值,能正确写出复杂表达式并理解每一步发生了什么

学习目标

  • 能解释 C++ 中表达式求值的基本规则——运算符优先级、结合律如何决定运算顺序
  • 能正确使用算术运算符、赋值运算符和递增递减运算符写出预期的表达式,并区分 ++ii++ 的行为差异
  • 能回答:int x = 5; int y = x++ + ++x; 会导致什么问题?为什么?

机制总览

表达式:机制路径

  1. 1

    直觉:程序里每一个“算”都有规矩

    想象一条工厂流水线:原料从一端进入,经过切割、打磨、组装、喷漆——每一步都有固定的顺序,不能先喷漆再切割。每一步的操作员也只认特定规格的原料:你给切割工塞一块布,他只会摇头。

  2. 2

    表达式——把"计算"写出来

    在 C++ 里, 表达式(expression) 就是你告诉计算机"算一下这个"的基本单位。一个最简单的表达式可以只是一个变量名或一个字面值(比如 42 或 x ),它们本身就是一个求值结果。一旦你把运算符和操作数组合起来——比如 a + b c ——就形成了一个更复杂的表达式。

  3. 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++ 里, 就是你告诉计算机"算一下这个"的基本单位。一个最简单的表达式可以只是一个变量名或一个字面值(比如 42x),它们本身就是一个求值结果。一旦你把运算符和操作数组合起来——比如 a + b * c——就形成了一个更复杂的表达式。

每个表达式求值之后都会产生一个结果,并且结果也有自己的类型。比如 3 + 4 的结果是 7(类型是 int),3.0 + 4.0 的结果是 7.0(类型是 double)。

表达式的核心构成只有两类东西:

  • ——参与运算的数据,可以是变量、字面值,也可以是另一个子表达式的结果
  • ——对操作数执行的操作,比如 + 做加法,* 做乘法

运算符优先级——哪个先算?

当你写出 a + b * c 这句时,计算机看到三个东西:abc 和两个运算符 +*。问题来了——先算 + 还是先算 *?答案取决于

C++ 里 乘除 * / % 的优先级比加减 + -,这和你在数学课上学到的规则一模一样。所以 a + b * c 等价于 a + (b * c)

来用表达式树把这件事看清楚——叶子是最原始的数据,越深的层越先算:

+a×bc① 先算 b×c② 再算 a + ___表达式树:叶子 = 操作数,内部节点 = 运算符。越深层先算。
表达式树直观展示运算符优先级:乘法 `*` 在更深的层次,先于加法 `+` 求值。

运算符结合律——同级从左到右(或反过来)

如果一条流水线上两个工序的优先级完全一样怎么办?比如 a - b + c——减法和加法优先级相同。这时候 上场:加减是左结合的,也就是从左到右算——等效于 (a - b) + c

赋值运算符是右结合的:a = b = 0 分组为 a = (b = 0)。前缀一元运算符也按右到左分组,而后缀自增/自减属于优先级更高的 postfix expression。结合方向只决定语法分组,不应被读成运行时求值顺序。

下面是 C++ 常用运算符的优先级速查表:

算术运算符——加减乘除取余

C++ 提供了五种基本 + - * / %。前四种和数学课一样,注意两点:

  • 整数除法 / 会截断小数部分7 / 2 的结果是 3,不是 3.5。要得到小数结果,把至少一个操作数换成 double7.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;
}

赋值运算符——不只是等号

最基本的形式是 =——把右边的值写进左边的变量。但它不止于此:

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 += 1x = x + 1 简洁),而且意图更清晰——"在原值基础上加"。赋值表达式本身会返回被赋值之后的值,因此你可以链式赋值:

int a, b, c;
a = b = c = 0;   // 从右往左:c=0 返回 0→b=0 返回 0→a=0。三个变量都是 0。

递增与递减——加一减一的"快车道"

++-- 是 C++ 里最常用的两个 ——分别让变量加 1 和减 1。它们有前缀++i--i)和后缀i++i--)两种写法,对变量最终值的影响完全一样(i 都会变成 i+1),但表达式本身的返回值不同——这是 C++ 初学者最频繁踩的坑之一。

下面这张时间线图直观对比了两种写法在"运算的瞬间"到底发生了什么:

++i(前缀递增)—— 先加,后用i = 5++i → i=6返回新值 6初始先自增 +1再用新值i++(后缀递增)—— 先用,后加i = 5返回旧值 5(背后 i → 6)表达式 = 5初始先用旧值然后 i 才变`++i = 先加再用` `i++ = 先用再加` —— 区分就看 ++ 在变量名前还是后。
`++i`(前缀)先自增再使用;`i++`(后缀)先返回旧值再自增。二者对 `i` 的最终值影响相同,但表达式返回值不同。

核心口诀:前缀先加再用,后缀先用再加

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++ 怎么处理?答案是 :编译器会自动把"窄类型"提升为"宽类型",确保运算在统一的类型下进行。

3.14 + 10 中,10 这个 int 会被自动转换为 double 类型的 10.0,然后两个 double 相加得 13.14。整个过程你不需要写任何转换代码。

下面的转换链图展示了 C++ 内建类型之间的隐式转换方向:

bool(1B)char(1B)short(2B)int(4B)long(8B)float(4B)double(8B)隐式转换方向(窄 → 宽)—— 编译器自动帮你做,不丢信息C++ 的「类型提升」规则:如果两个操作数类型不同,编译器先把窄类型转成宽类型再运算。逆方向需显式 static_cast,如 static_cast<int>(3.14) → 3。
从左到右是"安全方向"——小容量类型自动转为大容量类型。int→float 虽是隐式但可能丢精度(float 有效数字仅约 7 位)。逆方向必须显式 static_cast。

很多时候,隐式转换的方向你可能需要反过来——比如把一个 double 变成 int。这时候你必须用 ,最推荐的是 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;第二次比较实际拿 boolc 比。
  • Member access. 接对象,-> 接指针;ptr->m 等价于 (*ptr).m,括号来自 postfix/member precedence。
  • Conditionalcond ? 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 一步步看。

分步1 / 6

① 初始状态:声明并初始化 a 和 b

+a×bc① 先算 b×c② 再算 a + ___表达式树:叶子 = 操作数,内部节点 = 运算符。越深层先算。
表达式树直观展示运算符优先级:乘法 `*` 在更深的层次,先于加法 `+` 求值。

程序首先定义两个整型变量 a = 5b = 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:

  1. 不确定优先级就加括号——括号最早算,且让意图一目了然
  2. 别在一个表达式里多次改同一个变量——拆成多行写,编译器更喜欢、你也更不会搞错
  3. 隐式转换要心中有数——窄转宽安全、宽转窄必须用 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 % 46 % -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。单独用时 ++ii++ 效果一样(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。左值通常标识有身份、可定位的对象;右值通常表示可读取或可移动的值,并影响赋值和引用绑定规则。

资料与写作方式声明

本章以C++ Primer, Fifth Edition, Chapter 4: Expressions权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…