前言
运算符优先级是 C++ 里最容易被"背过就忘"的知识点。多数人记住的是一张从高到低的表,却忽略了两个更重要的区分:优先级只决定表达式如何结合,不决定子表达式何时求值;结合性只决定同级运算符从哪边开始,不决定操作数的计算顺序。把这三件事混为一谈,就会写出"看起来加了括号就对了"却仍然有未定义行为的代码。
一个典型误解是"优先级高的先算"。a + b * c确实先形成b * c这个子表达式,但这说的是语法结构;在运行期,a、b、c的求值顺序可能被编译器任意安排,只要不违反顺序规则。再比如f() + g(),两个函数谁先调用是未指定的,优先级表对此一个字都没说。
本文先给出常用的优先级分层与结合性速查,再集中讲最容易写错的组合,最后说明 C++17 对求值顺序做了哪些明确规定、哪些仍然不能依赖。文中所有结论以标准语义为准,代码不声称经过编译或运行。
一、优先级与结合性到底规定什么
表达式a + b * c的语法树是a + (b * c),因为*的优先级高于+。表达式a - b - c的语法树是(a - b) - c,因为-是左结合。优先级回答"不同级运算符谁先结合",结合性回答"同级运算符从哪边结合",两者合起来唯一确定语法结构。
C++ 的运算符可以粗分为十八层。下面这张表按从高到低排列,只列常见项:
| 层级 | 运算符 | 结合性 | 示例含义 |
|---|
| 1 | :: | 左 | std::vector限定名字 |
| 2 | a++a--a()a[].-> | 左 | p->x、v[i] |
| 3 | ++a--a+a-a!~、类型转换、*a&a、sizeof、new、delete | 右 | *p、-x、new int |
| 4 | .*->* | 左 | 成员指针访问 |
| 5 | */% | 左 | 乘除取余 |
| 6 | +- | 左 | 加减 |
| 7 | <<>> | 左 | 移位、流插入提取 |
| 8 | <=> | 左 | 三路比较(C++20) |
| 9 | <<=>>= | 左 | 关系比较 |
| 10 | ==!= | 左 | 相等比较 |
| 11 | & | 左 | 按位与 |
| 12 | ^ | 左 | 按位异或 |
| 13 | `\ | ` | 左 |
| 14 | && | 左 | 逻辑与(短路) |
| 15 | `\ | \ | ` |
| 16 | ?: | 右 | 条件运算符 |
| 17 | =+=-=*=/=%=<<=>>=&=^=`\ | =` | 右 |
| 18 | , | 左 | 逗号运算符 |
这张表不需要逐行背。真正需要条件反射的是几个"反直觉"的位置:移位低于加减、按位与低于相等比较、赋值低于条件运算符、逗号最低。写表达式时只要对这三四处没把握,直接加括号即可,括号永远比记忆可靠。
还有一条与表无关的规则:重载运算符不改变优先级、结合性或操作数个数。你可以为自定义类型重载+和*,但a + b * c仍然先结合b * c;你也可以重载<<做流输出,但它的优先级仍然低于+、高于关系比较。运算符重载改变的是"这个符号对某类型做什么",不是"这个符号在语法里排第几"。
二、最容易写错的组合
2.1 一元运算符与后缀自增
*p++结合为*(p++),即先取指针旧值解引用,再让指针后移。若想自增所指对象,应写(*p)++;若想先移动指针再解引用,应写*++p。三者语义完全不同,但视觉上只差一个括号。
// 适用于 C++17
int a[3] = {10, 20, 30};
int* p = a;
int x = *p++; // x = 10,p 指向 a[1]
int y = (*p)++; // y = 20,a[1] 变成 21
int z = *++p; // p 先指向 a[2],z = 30按标准语义,这段结束后x、y、z分别为 10、20、30,数组第二个元素为 21。这类代码可读性差,生产代码更推荐拆成多行。
2.2 位运算与比较
x & 1 == 0结合为x & (1 == 0),也就是x & false即x & 0,结果恒为 0,条件恒为假。正确写法是(x & 1) == 0。同样的陷阱出现在x | y == z、x ^ y != 0上,因为按位运算符的优先级都低于相等比较。
C++ 的这条设计与 C 一致,属于历史遗留:早期为了少写括号,把位运算排在了比较之后。现代代码不应利用它,凡是位运算参与条件判断,都显式加括号。
2.3 赋值、条件与逗号
赋值是右结合,所以a = b = c等价于a = (b = c),先把c赋给b,结果再赋给a。这依赖赋值表达式返回左操作数的引用,对内置类型与遵循惯例的类类型都成立。
条件运算符也是右结合,a ? b : c ? d : e等价于a ? b : (c ? d : e)。但条件运算符的优先级高于赋值,所以flag ? x : y = 1会被解析为(flag ? x : y) = 1,只有当两个分支都是可修改左值时才能通过编译,语义也往往不是作者想要的。想表达"条件成立才赋值",应写flag ? (x = 1) : (y = 1),或者直接用if。
逗号运算符优先级最低,a = 1, b = 2是(a = 1), (b = 2),整个表达式的值是b = 2的结果。函数调用里的逗号是参数分隔符,不是逗号运算符,两者不要混淆。
2.4 移位与流
std::cout << a << b是左结合,等价于(std::cout << a) << b,这正是链式输出的基础。但std::cout << a ? b : c会先算(std::cout << a),得到一个流对象,再把它按布尔值转换作为条件——结果几乎总不是作者想要的,某些分支类型组合还会直接编译失败。把条件表达式整体括起来,或改用if,才是清晰写法。
同理,1 << 2 + 3是1 << (2 + 3),因为加法优先级高于移位,结果是 32 而不是 12。涉及移位时,宁可写成1 << (2 + 3)。
2.5new与数组、函数调用
new int[10]与new int(10)的区别不是优先级,而是语法形式:前者分配十个int,后者分配一个初始化为 10 的int。但new与后缀表达式的组合容易出错,例如new int[10][20]是二维数组,而new (int[10])不是合法写法。涉及new时,优先用容器或std::make_unique,从源头绕开这类语法细节。
三、优先级不等于求值顺序
这是全文最需要记住的一节。优先级和结合性在编译期确定语法树;求值顺序描述的是运行期各子表达式按什么次序执行。标准对后者有单独一套规则,而且很多情况是"未指定"甚至"未定义"。
3.1 未定义行为:同一对象的修改之间没有顺序
如果两个副作用修改同一个内存位置,且二者之间没有顺序关系,行为未定义。经典例子是i = i++ + 2:在 C++17 之前,右侧的自增与赋值对i的修改缺乏顺序,属于未定义行为;C++17 规定赋值的右操作数先于左操作数求值,这个具体写法才变成有定义。但i = ++i + i++仍然未定义,因为自增与另一处读取之间没有顺序。
实践结论很简单:不要在同一个表达式里对同一变量既读又改。拆成多条语句,任何标准版本下都不会错。
3.2 未指定顺序:操作数之间
大多数二元运算符的两个操作数之间没有先后保证。f() + g()谁先调用未指定;a[i++] + i中读取i与修改i没有顺序,属于未定义行为。函数实参的求值在 C++17 起是"indeterminately sequenced"——不会交错执行,但先后仍不确定,且不同调用可能不同。
3.3 C++17 明确规定的几处顺序
C++17 细化了求值顺序,常用的有:
| 表达式形式 | C++17 起的顺序保证 |
|---|
E1[E2] | E1先于E2 |
E1 << E2、E1 >> E2 | E1先于E2 |
E1 = E2、E1 @= E2 | E2先于E1 |
E1.*E2、E1->*E2 | E1先于E2 |
E1 && E2、`E1 \ | \ |
cond ? a : b | cond先于被选中的分支 |
| 函数调用 | 函数名先于各实参;实参之间不确定 |
| 重载运算符(运算符写法) | 遵循它所重载的内置运算符规则 |
注意最后一行:std::cout << a << b之所以能按顺序输出,是因为 C++17 规定移位的左操作数先于右操作数,而重载的operator<<在用运算符写法调用时沿用这条规则。若写成函数调用形式operator<<(std::cout, a),实参之间就不再有这条保证。
四、完整示例:重载不改变优先级
下面的例子让自定义类型重载+、*、+=与<<,用来观察语法结构如何决定结果。按标准语义,a + b * c应先算b * c,而(a + b) * c先算括号;1 << 1 + 2应先算加法。
// 适用于 C++17
#include <iostream>
class Score {
public:
explicit Score(int value) : value_(value) {}
Score& operator+=(const Score& rhs) {
value_ += rhs.value_;
return *this;
}
Score& operator*=(const Score& rhs) {
value_ *= rhs.value_;
return *this;
}
int value() const { return value_; }
private:
int value_;
};
Score operator+(Score lhs, const Score& rhs) {
lhs += rhs;
return lhs;
}
Score operator*(Score lhs, const Score& rhs) {
lhs *= rhs;
return lhs;
}
std::ostream& operator<<(std::ostream& os, const Score& s) {
return os << s.value();
}
int main() {
Score a(2);
Score b(3);
Score c(4);
Score r1 = a + b * c; // 等价于 a + (b * c)
Score r2 = (a + b) * c; // 括号改变结合结构
std::cout << r1 << ' ' << r2 << '\n';
int x = 1;
int y = 2;
std::cout << (x << 1 + y) << '\n'; // 1 + y 先算,再左移
return 0;
}按标准语义,r1的值为 14,r2的值为 20,最后一行输出 8(因为1 << 3)。这段代码同时说明了两件事:重载的operator*仍然服从*的优先级;operator<<的链式调用依赖左结合与左操作数先求值。
常见坑点
- ❌ 认为
f() + g()会先调用f()再调用g()。
✅ 两个操作数的求值顺序未指定;有先后依赖就拆成两条语句。
- ❌ 写
if (x & 1 == 0)判断奇偶。
✅ 写成if ((x & 1) == 0),因为==的优先级高于&。
- ❌ 把
*p++当成"自增p指向的值"。
✅ 它是*(p++);自增所指对象要写(*p)++。
- ❌ 在条件里写
if (a = b),把赋值当比较。
✅ 写if (a == b);打开编译器警告,让"赋值出现在条件中"被发现。
- ❌ 写
std::cout << a ? b : c,以为会按条件输出。
✅ 加括号写成std::cout << (a ? b : c),或改用if。
- ❌ 在同一表达式里写
i = i++ + 2,并认为所有编译器结果一致。
✅ C++17 前是未定义行为;即使 C++17 起该写法有定义,也应拆成多条语句。
- ❌ 认为重载
operator+后,自定义类型会"先算加号"。
✅ 重载不改变优先级与结合性,仍由运算符本身决定语法结构。
- ❌ 把
new int[10]与new int(10)当成同一件事。
✅ 前者是数组,后者是单个值初始化;优先用容器与智能指针避免混淆。
总结
| 概念 | 回答的问题 | 何时确定 | 能否依赖 |
|---|
| 优先级 | 不同级运算符谁先结合 | 编译期 | 能,但建议加括号 |
| 结合性 | 同级运算符从左还是从右 | 编译期 | 能,但建议加括号 |
| 求值顺序 | 子表达式按什么次序执行 | 运行期语义 | 仅标准明确规定的部分能依赖 |
| 未定义行为 | 修改同一对象缺乏顺序 | 不保证 | 任何情况下都不能写 |
运算符优先级是一张查表就能解决的事,真正容易出事的是把它当成求值顺序。写表达式时坚持两条:结构有疑问就加括号;同一表达式里不重复修改同一变量。做到这两点,绝大多数"优先级问题"根本不会出现。