下面这行代码看起来像是在计算平均值:
int total{11};
int count{4};
double average = total / count;average 最终却是 2.0,不是 2.75。问题不在赋值目标,而在右侧表达式:total 和 count 都是 int,所以除法在赋值前已经完成了整数运算。等结果再转换成 double,被舍去的小数部分不会回来。
表达式里的许多错误都长这样:代码符合语法,编译器也能生成程序,但程序员误判了分组、类型或执行顺序。读一个表达式时,可以固定问四件事:
这四个问题会贯穿后面的例子。所有完整程序都按 C++17 编写;如果某段代码专门展示编译错误或未定义行为,会明确标出,不应拿去运行。
表达式是一段可以被求值的代码。字面量 42 是表达式,变量名 price 是表达式,price * count、函数调用 read() 和赋值 count = 5 也都是表达式。
运算符说明要做什么,操作数提供参与运算的内容。操作数本身还可以是表达式,因此复杂表达式是一层套一层的结构:
(price + shipping) * quantity最外层是乘法。它的左操作数是括号内的加法表达式,右操作数是 quantity。
每次读表达式,都可以做一张“四项清单”:

++ 的结果是标识 count 对象的左值,并修改 count,而 count + 1 是不标识对象的纯右值,也不修改 count。“纯右值”和“左值”稍后会专门解释。此处先抓住重点:两个表达式都能算出值,只有第二个会改动对象。
表达式后面加分号,就可以形成表达式语句:
count = 5;
++count;
print_total();这些语句分别产生赋值、递增和函数调用的效果。反过来,下面两条语句虽然合法,却把结果直接丢掉了:
1 + 2;
count * 10;如果表达式既没有保存结果,也没有可观察的副作用,它通常没有实际作用,编译器也可能给出警告。
初始化和赋值也要分开理解:
int score{80}; // 初始化:对象刚建立时取得第一个值
score = 95; // 赋值:替换对象已有的值赋值运算本身仍然有结果,所以可以链式写:
int left{0};
int right{0};
left = right = 7;这段代码会把两个对象都设为 7。它能成立,是因为 right = 7 求值后还能把赋值结果交给外层赋值。不过,能写成一个表达式不等于总该这样写;出现不同业务动作时,分成两条语句更容易检查。
常量表达式是可以在编译期求值的表达式:
constexpr int minutes_per_hour{60};
constexpr int seconds_per_minute{60};
constexpr int seconds_per_hour = minutes_per_hour * seconds_per_minute;seconds_per_hour 的初始化不依赖运行时输入,编译器可以直接得到 3600。如果一个值要到运行时才能取得、但初始化后不再修改,则可以用 const 保存。
表达式的类型、值类别和副作用是三件独立的事。只知道“结果等于多少”还不够;赋值是否合法、引用能否绑定、程序状态是否改变,都依赖另外两项。
同一个表达式里出现多个运算符时,编译器先按语法规则确定分组。最可靠的心智模型不是“从左往右扫”,而是画出一棵表达式树。
2 + 3 * 4 的结构是:
+
/ \
2 *
/ \
3 4乘法节点位于更深的子树中,因此整个表达式按 2 + (3 * 4) 分组。

2 + 3 * 4 先计算乘法得到 14;括号把加法变成更深的子表达式后,(2 + 3) * 4 得到 20。运算符可以按操作数数量区分:
同一个符号还可能承担不同角色。-value 是一元取负,left - right 是二元减法;*p 是解引用,a * b 是乘法。编译器根据语法位置和操作数类型判断含义。
优先级决定不同运算符谁结合得更紧。日常代码最常见的层级如下,从上到下逐渐降低:
结合性只在同一优先级连续出现时决定分组方向。加减乘除通常左结合:
10 - 5 - 3 // (10 - 5) - 3,结果是 2赋值和条件运算符通常右结合:
a = b = 3 // a = (b = 3)下面的完整程序同时展示优先级、结合性和一个常见陷阱:
#include <iostream>
int main() {
std::cout << std::boolalpha;
std::cout << "2 + 3 * 4 = " << 2 + 3 * 4 << '\n';
std::cout << "(2 + 3) * 4 = " << (2 + 3) * 4 << '\n';
std
输出:
2 + 3 * 4 = 14
(2 + 3) * 4 = 20
10 - 5 - 3 = 2
a = 3, b = 3
0 < 5 < 3 = true最后一行并不是说 5 < 3 成立。表达式按 (0 < 5) < 3 分组,第一次比较得到 true,参与数值比较时相当于 1,于是又计算 1 < 3。区间判断要明确写成:
bool inside = (0 < value) && (value < 3);复合赋值也不是纯粹的文本缩写。target += value 大致表达 target = target + value,但 target 只求值一次。若左侧包含下标递增或函数调用,机械展开可能改变行为。更好的做法是让赋值目标本身保持简单。
括号应表达意图,不必包住每一个普通算术步骤。a + b * c 已是熟悉结构;混合比较、逻辑运算或业务条件时,(age >= 18) && has_id 会更容易确认。
优先级和结合性只决定表达式怎样分组。它们没有承诺所有子表达式在运行时按同样的先后顺序求值;这两个问题必须分开分析。
下面的观察器把完整括号式、表达式树和逐步求值放在同一幅画面中。切换案例时,先预测根节点是哪个运算符,再用树结构检查分组是否正确。
表达式的结果类型不是由接收结果的变量倒推出来的。编译器先决定各个子表达式怎样运算、产生什么类型,最后才把结果用于初始化或赋值。
这正是开头平均值错误的原因:
double average = total / count;右侧的两个操作数都是 int,所以 total / count 先得到 int。左侧的 double 只负责接收已经算完的结果。
内置算术常经历两步:先做整数提升,再把两边转换成适合共同计算的类型。
下面的程序把几个结果放在一起观察:
#include <iostream>
int main() {
int total{11};
int count{4};
char letter{'A'};
auto integer_average = total / count;
auto precise_average = static_cast<double>(total) / count;
auto next_code = letter + 1;
std::cout <<
输出:
integer average = 2
precise average = 2.75
next code = 66letter + 1 的结果不是 char,而是整数提升后的 int。在使用 ASCII 的环境中,'A' 的编码是 65,所以示例输出为 66;可移植代码不应把所有字符编码细节都硬编码成 ASCII 数值。
有符号和无符号整数混用更隐蔽:
int debt{-1};
unsigned int credit{1};
bool smaller = debt < credit; // 结果通常与直觉相反比较前,debt 会被转换为 unsigned int,负数变成该无符号类型中的一个很大值。因此循环下标、容器长度和差值运算应尽量使用协调的类型,不要等警告出现后再用强制转换掩盖问题。
从 double 到 int 会向零截断小数部分;从较大范围的整数类型到较小范围的类型,也可能无法保留原值。以下两行能编译,但可能丢信息:
double price{19.95};
int whole = price; // 得到 19花括号初始化会把潜在窄化变成编译错误:
int whole{price}; // 编译错误:可能窄化如果丢弃小数确实是设计要求,可以明确表达:
int whole = static_cast<int>(price);static_cast 让审查者看见转换意图,但它不会自动检查范围,也不会改成四舍五入。若业务要求“最近整数”,应选用相应的舍入函数;若目标值可能越界,应先检查范围。
T{value} 与 static_cast<T>(value) 因此表达两种不同态度:前者要求初始化不能窄化,后者声明程序员接受这次转换。
编译期求值不会改变算术规则:
constexpr double bad_factor = 9 / 5; // 1.0
constexpr double good_factor = 9.0 / 5; // 1.8bad_factor 的目标虽然是 double,右侧仍先做整数除法。只要让一个操作数成为浮点类型,整个除法才会转为浮点运算。
显式转换不是“安全通行证”。它只把转换写得明显。目标范围、截断方式和业务含义仍需由代码检查。
数学整数可以无限增长,内置整数类型却只能使用固定数量的位。实数可以有无限小数位,浮点类型也只能保存有限精度。表达式语法完全正确,结果仍可能因为表示范围而偏离数学答案。
浮点数首先带来舍入误差。下面的程序在常见的 IEEE 754 double 环境中会把误差显示出来:
#include <iomanip>
#include <iostream>
int main() {
double sum = 0.1 + 0.2;
std::cout << std::setprecision(17);
std::cout << "sum = " << sum << '\n';
std::cout << std::boolalpha
<< "sum == 0.3: "
在本例环境中的输出:
sum = 0.30000000000000004
sum == 0.3: false这不是随机误差。0.1、0.2 和 0.3 都需要用有限二进制位近似表示,几次近似不一定落在同一个最终位模式上。处理测量值或计算结果时,应根据问题允许的误差比较,而不是默认用 == 判断所有浮点结果。
整数溢出需要分两类:
0,但“有定义”不表示符合业务要求。一个很容易漏掉的问题是:转换发生得太晚。
int width{1'000'000};
int height{1'000'000};
long long unsafe_area = width * height; // 先在 int 中相乘,可能溢出
long long safe_area = 1LL * width * height; // 从第一个乘法起使用 long longunsafe_area 的左侧类型救不了右侧已经发生的溢出。1LL 让第一个乘法就进入 long long,后续操作也沿用更宽类型。

long long 不代表乘法会自动在 long long 中进行;使用 1LL 才能让提升发生在第一次乘法之前。更宽类型仍然有上限。处理外部输入时,可以在乘法前检查:
#include <limits>
bool multiplication_fits(long long left, long long right) {
if (left < 0 || right < 0) {
return false; // 这个简化版本只接受非负数
}
if (right == 0) {
return true;
}
return left <= std::
把检查写成除法,是为了避免“用一次可能溢出的乘法去检查乘法是否溢出”。真实项目还要根据是否允许负数补齐其他分支。
浮点转整数也有两个问题:小数部分会向零截断;若截断后的值超出目标整数可表示范围,则不能依赖结果。整数转浮点虽然能表示更大的数量级,也可能丢失低位精度。
可用 std::numeric_limits<T> 查询当前实现的范围和性质,不要把某台机器上的 int 位数当成所有环境的保证。
实践中可以按这个顺序检查数值表达式:
表达式除了有类型,还具有值类别。值类别回答的是:这个结果是否关联到一个有身份、可定位的对象,以及它可以怎样被使用。
入门阶段先掌握三类:
本文只需知道将亡值属于现代分类的一部分;资源移动和右值引用有各自的使用场景,不需要为了写普通算术表达式提前引入。
变量名 x 是左值,因为它标识一个具体对象。x + 1 是纯右值,因为加法产生的是计算结果,不是名为某物的持久对象。
这会直接影响赋值:
int x{10};
x = 20; // 正确:x 是可修改左值
x + 1 = 20; // 编译错误:加法结果不是可修改左值“能放在赋值号左边”只是助记法,并不完整:
const int limit{100};表达式 limit 仍是左值,因为它标识对象;但它受 const 保护,不是可修改左值,所以不能给它重新赋值。
引用绑定能把区别显示得更清楚:
int value{10};
int* pointer{&value};
int& first = value; // 绑定左值
int& second = *pointer; // 解引用结果也是左值
const int& third = value + 1; // const 引用可以绑定临时结果下面的程序展示引用如何继续指向原对象:
#include <iostream>
int main() {
int value{10};
int& alias = value;
const int& temporary = value + 1;
alias += 5;
std::cout << "value = " << value << '\n';
std::cout << "alias = " <<
输出:
value = 15
alias = 15
temporary = 11alias 不是一份独立副本,对它赋值就是修改 value。temporary 绑定的是计算 value + 1 时产生的临时结果;这个结果在绑定发生时是 11,不会随着 value 后续变成 15 而重新计算。
值类别属于表达式,不是对象永远不变的标签。一个对象可以通过不同表达式被不同方式看待。日常代码里,先能区分“标识对象的表达式”和“单纯计算值的表达式”,就足以解释大多数赋值与引用问题。
左值不等于“可修改”。const 对象名也是左值;真正能作为普通赋值目标的是可修改左值。
表达式树回答“怎样分组”,求值顺序回答“运行时先算哪一部分”。只有把两者分开,才能正确分析带副作用的代码。
副作用是求值过程中对程序状态造成的改变,常见情况包括:
对没有副作用的 a + b * c,操作数先后通常不影响结果。把 a、b、c 换成会修改同一状态的函数调用,顺序就可能决定行为。
C++17 中,常用规则可以整理成下表:

&&、|| 与 ?: 则会根据已得到的条件,只求值必要的部分。函数实参在 C++17 中各自完整求值,不会彼此交错,但先后仍可能是 a() 再 b(),也可能相反。如果顺序影响结果,就不该把两个调用塞进同一参数列表。
下面的程序只使用有明确顺序的规则:
#include <iostream>
bool trace(const char* name, bool result) {
std::cout << name << '\n';
return result;
}
int main() {
bool first = trace("A", false) && trace("B", true
输出:
A
C
first = false
second = trueB 和 D 都没有输出,说明对应右操作数没有求值。
下面这段则不要运行:
int i{0};
int result = i++ + i++; // 未定义行为:+ 的两侧未定序地修改同一对象一次运行给出的数字没有解释价值。编译器可以优化、重排,甚至基于“程序不会触发未定义行为”的前提生成完全出乎直觉的结果。
另一个常见问题没有未定义行为,却仍然不稳定:
consume(read_left(), read_right());如果两个读取动作必须按顺序发生,应明确拆开:
先执行 auto left = read_left();,把第一个动作及其结果固定在一条完整语句中。
再执行 auto right = read_right();,让第二个动作明确发生在第一条语句之后。
最后调用 consume(left, right);。此时参数求值顺序不再影响前两个读取动作。
最实用的规则是:一个表达式里若修改了某个对象,就不要再安排另一个依赖其新旧值的动作。让每条语句只承担一个明显的状态修改,调试时也更容易观察中间值。
有些运算符不仅产生值,还明确控制哪些子表达式会被执行。正确使用它们,可以把前置条件和简单选择写得很紧凑。
逻辑与 && 常用于逐层保护:
if (pointer != nullptr && pointer->ready()) {
use(*pointer);
}只有指针非空,右侧成员访问才会发生。类似地,检查下标时可以写:
if (index < values.size() && values[index] > 0) {
// 此处访问 values[index] 才有前面的范围保护
}逻辑或 || 常用于后备条件:左侧已经为真时,右侧不再计算。
短路适合保护“只有条件满足才合法”的读取,但不要把必须发生的业务动作偷偷放在右侧:
saved || save(); // 若保存是必要动作,这种写法会掩盖意图清楚的 if 更合适。
条件运算符在表达式层面二选一:
const char* label = score >= 60 ? "pass" : "retry";它先求条件,只求值选中的分支。不过,两个分支都必须在语法和类型上有效,编译器还要为整个条件表达式确定类型:
auto value = ready ? 1 : 2.5;不论选中哪一边,value 都推导为 double,因为 int 分支会转换到与 double 分支协调的类型。
条件运算符适合短小、对称、只负责选值的场景。分支里若要记录日志、修改多个对象或处理错误,应改用 if。嵌套条件运算符虽然右结合,但很难快速确认分支对应关系:
// 能写,但不利于维护
const char* level = score >= 90 ? "A" : score >= 60 ? "B" : "C";内置逗号运算符会先求左侧,再求右侧,并把右侧结果作为整体结果:
int result = (log_start(), compute());括号很重要,因为逗号运算符优先级最低。它最常见的合理位置是 for 的更新部分;下面假设 size > 0:
for (std::size_t left{0}, right{size - 1};
left < right;
++left, --right) {
// 两端向中间移动
}参数列表里的逗号、声明多个变量时的逗号是分隔符,并不是这里的逗号运算符:
print(x, y); // 分隔两个实参
int a{1}, b{2}; // 分隔两个声明项下面的程序把三种选择规则放在一起:
#include <iostream>
int calls{0};
int probe(int value) {
++calls;
std::cout << "probe " << value << '\n';
return value;
}
int main() {
bool ready{false};
int selected = ready ?
输出:
probe 20
probe 1
probe 2
selected = 20
allowed = false
last = 2
calls = 3probe(10) 没有被条件表达式选中,probe(30) 被逻辑与短路,逗号表达式则按顺序执行了 probe(1) 和 probe(2)。
提交代码前,可以沿着下面的判断顺序检查每个非平凡表达式:
if。下面的实验室把“子表达式先算出什么类型”与“哪些分支真正执行”放在同一组执行轨迹中。先比较三种 11 / 4 的转换位置,再查看乘法范围风险和短路日志。