表达式、运算符与控制流 / Expressions, Operators, and Control Flow
本文属于 C++ 语言基础模块。
我们不会从运算符表开始背。
我们先观察一个程序,再把“它为什么这样运行”拆成值、类型、求值、副作用和控制流几个问题。
1. 先预测:程序到底会打印什么
先看一个小程序:
#include <iostream>
bool ready() {
std::cout << "ready()\n";
return false;
}
bool load() {
std::cout << "load()\n";
return true;
}
int main() {
if (ready() && load()) {
std::cout << "done\n";
}
}很多初学者第一次看到它时会猜:
ready()
load()但实际输出只有:
ready()原因不是编译器“聪明地删掉了”load()。
原因是 && 有一个明确的语言规则:
左边为 false
|
v
整个 && 已经不可能为 true
|
v
右边不再求值这里的“求值”是本文的第一个重要词。
求值可以先理解成:
程序按照语言规则计算一个表达式,并可能读取对象、修改对象或调用函数。
ready() 被求值,所以它打印文字。
load() 没有被求值,所以它没有打印文字。
把 && 改成 &:
if (ready() & load()) {
std::cout << "done\n";
}再次运行,通常会看到:
ready()
load()这两个运算符看起来只差一个字符,但它们不是同一类东西:
&&
+-- 逻辑与
+-- 面向真假
+-- 有短路求值
&
+-- 按位与
+-- 面向整数的二进制位
+-- 通常需要两边都求值这就是为什么表达式不能只看“最后结果”。
我们还必须问:
它的类型是什么?
它会不会修改对象?
它会不会调用函数?
它的左右两边是否都执行?
它最终改变了哪条控制流?2. 本文先建立一张地图
本文会从最容易观察的地方逐步深入:
一个表达式
|
+-- 产生什么值?
|
+-- 具有什么类型?
|
+-- 是否产生副作用?
|
+-- 子表达式按什么顺序求值?
|
v
控制流决定哪些表达式执行
|
v
编译器把它转换成比较、跳转和循环
|
v
CPU 按指令和分支预测执行这里有三个层次:
语言层
+-- C++ 规定表达式和控制结构的含义
编译器层
+-- 把这些含义转换成中间表示和机器指令
处理器层
+-- 执行寄存器运算、比较、跳转和内存访问三层不能混成一句话。
例如:
if会生成一条je指令。
这句话有时在某个平台、某个优化级别下成立,但它不是 C++ 语言保证的事实。
更准确的说法是:
C++ 的
if表达一个条件分支;编译器通常会把它实现为条件跳转、条件移动或其他等价机器代码。
3. 本文基础词小注释
3.1 表达式是什么
表达式是由名字、字面量、运算符和函数调用组合出来的代码片段。
2 + 3
count
count + 1
read_value()它们都可以被求值。
有些表达式主要产生值:
2 + 3有些表达式还会产生副作用:
++count副作用是对程序外部可观察状态的改变,例如:
修改变量
写文件
打印输出
调用系统调用
分配或释放资源3.2 运算符是什么
运算符是用来组合或操作值的符号。
left + right这里 + 是运算符,left 和 right 是操作数。
有些运算符只有一个操作数:
!ready
++count有些运算符有两个操作数:
left + right有些运算符看起来像运算符,实际上还会影响控制流:
left && right
condition ? a : b3.3 语句是什么
语句是程序执行过程中的一个动作单位。
count = 3;
++count;
return count;表达式加分号,常常可以形成表达式语句。
if、while、for 和 return 也是语句形式。
3.4 控制流是什么
控制流是程序执行路径经过哪些语句、跳过哪些语句、重复哪些语句。
开始
|
v
判断条件
|
+-- true --> 路径 A
|
+-- false --> 路径 B顺序执行、分支、循环和函数调用共同组成一个程序的控制流。
3.5 类型为什么会影响表达式
2 + 3 和 2.0 + 3.0 都像是在做加法,但它们的操作数类型不同:
2 + 3 // int
2.0 + 3.0 // double类型会影响:
如何解释比特
使用哪种运算
结果是什么类型
是否发生转换
哪些写法被允许4. 从一个表达式拆出五个问题
看下面的代码:
int count = 3;
int result = count + 2;不要只说“结果是 5”。
可以按五个问题拆解:
1. 表达式是什么?
count + 2
2. 它的类型是什么?
int
3. 它的值是什么?
5
4. 它是否修改了对象?
没有
5. 它何时被求值?
初始化 result 时再看:
int result = count++;这里就不同了:
读取 count 的旧值
|
v
把旧值作为表达式结果
|
v
count 自增结果值和对象的新状态不是同一个问题。
int count = 3;
int result = count++;执行后:
result == 3
count == 4这也是后置自增容易让人困惑的原因。
5. 算术运算:先区分整数和浮点数
5.1 整数除法不是小数除法
#include <iostream>
int main() {
std::cout << 7 / 3 << '\n';
std::cout << 7.0 / 3.0 << '\n';
}结果通常类似:
2
2.33333第一行两个操作数都是整数。
所以执行整数除法,结果也是整数。
第二行至少有浮点操作数,使用浮点运算。
7 / 3
|
+-- int / int
+-- 结果为 int
+-- 小数部分被截去
7.0 / 3.0
|
+-- double / double
+-- 结果为 double5.2 类型转换可能悄悄发生
int count = 7;
double average = count / 3;很多人以为 average 会得到 2.333...。
实际上,除法在赋值前就已经完成:
count / 3
|
+-- int / int
+-- 得到 2
|
v
再把 2 转成 double
|
v
average == 2.0如果想得到浮点结果:
double average = static_cast<double>(count) / 3;static_cast<double> 是显式转换。
它告诉读者和编译器:
我知道这里要把
count当成double参与运算。
5.3 加法、减法和乘法
int a = 7;
int b = 3;
int sum = a + b;
int difference = a - b;
int product = a * b;这些运算的结果类型会受操作数类型影响。
当两个操作数都是 int 时,通常使用整数运算。
但是整数也有范围。
#include <limits>
int maximum = std::numeric_limits<int>::max();如果继续给有符号整数加一,不能把它简单理解成“自动变成更大的数”。
有符号溢出可能导致未定义行为。
这里的重点不是背上限,而是理解:
类型不是装数字的无限盒子
|
v
类型规定表示范围和运算规则5.4 余数 %
int remainder = 17 % 5;17 / 5 的整数商是 3。
17 % 5 的余数是 2。
余数经常用于:
if (value % 2 == 0) {
// value 是偶数
}但要注意负数的结果也应通过小实验确认,不要凭小学数学的“余数必须为正”想当然。
6. 比较和逻辑:从值进入控制流
比较表达式通常产生 bool:
int score = 86;
bool passed = score >= 60;常见比较运算符:
< 小于
<= 小于或等于
> 大于
>= 大于或等于
== 相等
!= 不相等结果只有两种:
true
false但是不要因此把比较看成“只是计算”。
比较结果经常决定下一条控制流:
score >= 60
|
+-- true --> 通过路径
|
+-- false --> 不通过路径6.1 if 读取的是条件的真假
if (score >= 60) {
std::cout << "pass\n";
}执行步骤:
计算 score >= 60
|
v
得到 true 或 false
|
v
true 执行大括号内语句
false 跳过大括号内语句在 C++ 中,整数也可以转换成真假:
if (count) {
// count 不为 0 时进入
}虽然合法,但初学阶段最好把意图写清楚:
if (count != 0) {
}6.2 ! 是逻辑非
bool ready = false;
if (!ready) {
std::cout << "not ready\n";
}! 把真变假,把假变真。
6.3 && 是有条件的逻辑与
if (ptr != nullptr && *ptr > 0) {
use(*ptr);
}这里左边不只是一个条件。
它还是右边能够安全执行的前置条件:
ptr != nullptr ?
|
+-- false --> 停止,不读取 *ptr
|
+-- true --> 可以继续读取 *ptr这就是短路求值的工程价值。
6.4 || 也会短路
if (cached() || load_from_disk()) {
use_value();
}如果 cached() 返回 true,右侧不会执行。
cached() == true
|
v
整个 || 已经为 true
|
v
不调用 load_from_disk()因此把函数调用放在逻辑表达式中时,要把副作用考虑进去。
7. && 和 &:一个小实验看懂区别
创建 logic.cpp:
#include <iostream>
bool left() {
std::cout << "left\n";
return false;
}
bool right() {
std::cout << "right\n";
return true;
}
int main() {
std::cout << "logical:\n";
bool a = left() && right();
std::cout << "bitwise:\n";
bool b = left() & right();
std::cout << a << ' ' << b << '\n';
}先用最简单的命令编译:
g++ logic.cpp -o logicg++ 是 C++ 编译器命令。
logic.cpp 是输入源码。
-o logic 指定输出程序叫 logic。
运行:
./logic你会看到逻辑与部分只调用 left(),按位与部分通常调用两个函数。
这次实验要观察的不是最后 a 和 b 都可能为 false。
真正重要的是:
结果相同
不代表
执行过程相同8. 优先级、结合性和求值顺序不是一回事
看这段:
int result = 2 + 3 * 4;结果是 14,因为乘法在语法分组上优先于加法:
2 + (3 * 4)加括号可以明确意图:
int result = 2 + (3 * 4);如果想先加:
int result = (2 + 3) * 4;8.1 优先级解决什么
优先级回答:
代码如何被分组?
a + b * c通常被解析为:
a + (b * c)8.2 求值顺序解决什么
求值顺序回答:
已经分好组之后,哪些子表达式先执行?
不要把这两个问题混为一谈。
函数调用的多个实参尤其容易引起误解:
consume(next(), next());不要假定左边的 next() 一定先执行。
如果先后顺序重要,就拆开:
int first = next();
int second = next();
consume(first, second);拆开以后,顺序不仅清楚,调试器也更容易观察。
9. 自增、自减和副作用
9.1 前置自增
int count = 3;
int result = ++count;执行过程:
count 从 3 变成 4
|
v
表达式结果为 4最终:
count == 4
result == 49.2 后置自增
int count = 3;
int result = count++;执行过程:
先产生旧值 3
|
v
result 得到 3
|
v
count 再变成 4最终:
count == 4
result == 39.3 为什么不要写复杂自增表达式
int i = 0;
// int result = i++ + i++;这类代码把同一个对象的多次修改和读取压在一起。
更好的写法:
int left = i++;
int right = i++;
int result = left + right;这里每一步都变成可观察的语句。
代码更长了几行,但推理成本大幅下降。
10. 条件运算符和赋值表达式
10.1 条件运算符
int absolute = value >= 0 ? value : -value;它可以读成:
如果 value >= 0
|
+-- 选择 value
|
+-- 否则选择 -value只有被选择的分支会被求值:
bool ok = condition ? safe_value() : fallback_value();不要把复杂业务流程塞进三目运算符。
10.2 赋值本身也是表达式
int a;
int b;
a = b = 0;赋值从右向左结合,所以先让 b 变成 0,再把结果赋给 a。
但下面的代码非常容易是错误:
if (status = read_status()) {
}它的意思是“先赋值,再把结果转成 bool”。
如果确实需要在条件中初始化,使用带初始化语句的 if:
if (auto status = read_status(); status.ok()) {
use(status);
}这会把 status 的作用域限制在 if 和 else 内。
11. 位运算:把整数看成一组位
位运算不是逻辑运算的“高级写法”。
它直接操作整数表示中的位。
#include <cstdint>
std::uint8_t flags = 0;
constexpr std::uint8_t read = 1u << 0;
constexpr std::uint8_t write = 1u << 1;可以把四位简化画成:
bit: 3 2 1 0
read: 0 0 0 1
write: 0 0 1 0
read|write 0 0 1 1设置位:
flags |= read;检查位:
bool can_read = (flags & read) != 0;清除位:
flags &= static_cast<std::uint8_t>(~read);位运算常见于权限、硬件寄存器、压缩状态和协议字段。
12. if:把条件变成两条路径
int score = 86;
if (score >= 90) {
std::cout << "A\n";
} else if (score >= 60) {
std::cout << "pass\n";
} else {
std::cout << "fail\n";
}控制流图:
score >= 90 ?
/ \
true false
| |
v v
A score >= 60 ?
/ \
true false
| |
v v
pass fail每个 else if 都是对前一个条件为假的路径继续判断。
12.1 大括号是控制流的边界
即使只有一行,也建议保留大括号:
if (ready) {
start();
}否则后续添加日志时容易出现视觉误读:
if (ready)
start();
log(); // 不属于 if13. switch:按离散状态选择
enum class Command {
start,
stop,
status
};
switch (command) {
case Command::start:
start();
break;
case Command::stop:
stop();
break;
case Command::status:
print_status();
break;
}switch 适合有限的离散状态。
没有 break 时,程序可能继续执行下一个 case。
case 'y':
case 'Y':
confirm();
break;如果是故意贯穿,也可以写:
[[fallthrough]];14. 循环:重复执行,但必须有进展
14.1 while
int count = 3;
while (count > 0) {
std::cout << count << '\n';
--count;
}执行路径:
初始化 count=3
|
v
检查 count > 0
|
+-- false --> 结束
|
+-- true --> 循环体
|
v
count--
|
+---- 回到条件14.2 do while
do {
read_once();
} while (should_retry());它至少执行一次循环体。
14.3 for
for (int i = 0; i < 3; ++i) {
std::cout << i << '\n';
}它可以理解为:
int i = 0;
while (i < 3) {
std::cout << i << '\n';
++i;
}14.4 范围 for
int values[]{10, 20, 30};
for (int value : values) {
std::cout << value << '\n';
}这里的 value 是每个元素的副本。
修改原元素:
for (int& value : values) {
value *= 2;
}只读而不复制:
for (const int& value : values) {
std::cout << value << '\n';
}14.5 半开区间
[0, size)
|
+-- 包含 0
+-- 不包含 size数组有三个元素时,合法下标是:
0, 1, 2错误:
for (std::size_t i = 0; i <= values.size(); ++i) {
use(values[i]);
}正确:
for (std::size_t i = 0; i < values.size(); ++i) {
use(values[i]);
}15. break、continue 和 return
for (int value : values) {
if (value < 0) {
continue;
}
if (value == target) {
break;
}
process(value);
}value < 0
|
+-- true --> continue,跳到下一轮
|
+-- false --> 继续
|
v
value == target
|
+-- true --> break,结束循环
+-- false --> process(value)return 不只是结束循环,而是结束整个当前函数。
16. CPU 怎样看待 if 和循环
if (x < y) {
A();
} else {
B();
}抽象地看,编译器会形成:
比较 x 和 y
|
+-- 条件为真 --> A
|
+-- 条件为假 --> B循环则可能接近:
loop:
比较条件
条件不满足 -> exit
执行循环体
跳回 loop
exit:实际汇编可能使用条件跳转、条件移动、循环展开或向量化。
这些是实现选择。
语言层只要求可观察行为符合规则。
17. 分支预测和性能
现代 CPU 会猜测条件分支:
程序提供分支
|
v
CPU 猜下一条路径
|
+-- 猜对 --> 继续执行
|
+-- 猜错 --> 丢弃部分工作并重新取指但正确顺序是:
先写出正确、清楚的控制流
|
v
用测试验证语义
|
v
用 benchmark 或 profiler 找到热点
|
v
再讨论分支预测18. 一个完整实验:条件、循环和汇编
创建 branch.cpp:
#include <iostream>
int main() {
for (int value : {2, 3, 4, 5}) {
if (value % 2 == 0) {
std::cout << value << " is even\n";
} else {
std::cout << value << " is odd\n";
}
}
}先编译:
g++ branch.cpp -o branch再运行:
./branch输出应体现奇偶判断。
然后生成汇编:
g++ -S branch.cpp -o branch.s先找比较和跳转,不必试图一次读懂所有指令。
19. 常见误解
误解一:括号里的每个函数都会执行
&&、|| 和条件运算符都可能跳过子表达式。
误解二:优先级就是执行顺序
优先级首先决定语法分组。
误解三:& 是 && 的缩写
一个是按位运算,一个是逻辑短路运算。
误解四:循环写出来就会自动结束
循环必须有条件变假或主动退出的路径。
误解五:i <= size 可以访问最后一个元素
size 通常是一过尾位置,不能解引用。
误解六:编译器把所有 if 都变成跳转
不一定,可能使用条件移动或其他等价形式。
20. 自测题
问题一
为什么 false && load() 不调用 load()?
**答案:**左侧已经为假,逻辑与不可能为真,右侧不需要求值。
问题二
int value = 7 / 3; 为什么不是 2.333?
**答案:**两个操作数都是 int,先执行整数除法。
问题三
++count 和 count++ 的主要差别是什么?
**答案:**前者先修改再产生新值,后者先产生旧值再修改。
问题四
为什么不要依赖 consume(next(), next()) 的先后?
**答案:**多个实参的求值先后不能简单按书写顺序假定。
问题五
为什么 i < size 常比 i <= size 正确?
**答案:**合法下标是半开区间 [0, size)。
21. 本篇总结
表达式不只是“算一个数”。
它可能:
产生值
|
+-- 具有类型
+-- 触发转换
+-- 修改对象
+-- 调用函数
+-- 影响控制流控制流不只是 if 和 for 的语法。
它最终会影响:
编译器如何组织基本块
|
v
CPU 如何比较和跳转
|
v
程序哪些状态会被读取或修改最值得保留的判断顺序是:
先问值
|
v
再问类型
|
v
再问副作用
|
v
再问求值顺序
|
v
最后问控制流和性能