运算符优先级和结合性
章节介绍
本章建立在字面量和运算符简介的概念之上。快速回顾如下:
操作(operation) 是一个涉及零个或多个输入值(称为操作数(operands))的数学过程,它产生一个新值(称为输出值(output value))。要执行的具体操作由一个称为运算符(operator) 的构造(通常是一个符号或一对符号)表示。
例如,我们小时候都学过 2 + 3 等于 5。在这种情况下,字面量 2 和 3 是操作数,符号 + 是运算符,它告诉我们对操作数应用数学加法以产生新值 5。因为这里只使用了一个运算符,所以这很直接。
在本章中,我们将讨论与运算符相关的主题,并探讨 C++ 支持的许多常见运算符。
复合表达式的求值
现在,让我们考虑一个复合表达式,例如 4 + 2 * 3。这应该被分组为 (4 + 2) * 3(其计算结果为 18),还是 4 + (2 * 3)(其计算结果为 10)?使用正常的数学优先级规则(规定乘法在加法之前解析),我们知道上面的表达式应该被分组为 4 + (2 * 3) 以产生值 10。但编译器是如何知道的呢?
为了计算一个表达式,编译器必须做两件事:
- 在编译时,编译器必须解析表达式并确定操作数如何与运算符分组。这是通过优先级(precedence) 和结合性(associativity) 规则完成的,我们稍后将讨论。
- 在编译时或运行时,操作数被求值,操作被执行以产生结果。
运算符优先级
为了帮助解析复合表达式,所有运算符都被分配了一个优先级级别。具有较高优先级的运算符首先与操作数分组。
您可以在下表中看到,乘法和除法(优先级级别 5)的优先级级别高于加法和减法(优先级级别 6)。因此,乘法和除法将在加法和减法之前与操作数分组。换句话说,4 + 2 * 3 将被分组为 4 + (2 * 3)。
运算符结合性
考虑一个复合表达式,如 7 - 4 - 1。这应该被分组为 (7 - 4) - 1(其计算结果为 2),还是 7 - (4 - 1)(其计算结果为 4)?由于两个减法运算符具有相同的优先级级别,编译器不能仅使用优先级来确定应如何分组。
如果两个具有相同优先级的运算符在表达式中彼此相邻,则运算符的结合性(associativity) 告诉编译器是从左到右还是从右到左计算运算符(而不是操作数!)。减法的优先级是 6 级,并且优先级 6 级的运算符具有从左到右(left-to-right) 的结合性。所以这个表达式是从左到右分组的:(7 - 4) - 1。
运算符优先级和结合性表
下表主要是一个参考图表,您可以在将来遇到任何优先级或结合性问题时回过头来查阅。
注意:
- 优先级级别 1 是最高优先级,级别 17 是最低优先级。优先级较高的运算符的操作数首先被分组。
- L->R表示从左到右(left-to-right) 的结合性。
- R->L表示从右到左(right-to-left) 的结合性。
| 优先级/结合性 | 运算符 | 描述 | 模式 | 
|---|---|---|---|
| 1 L->R | :: | 全局作用域(一元) 命名空间作用域(二元) | ::nameclass_name::member_name | 
| 2 L->R | ()()type()type{}[].->++––typeidconst_castdynamic_castreinterpret_caststatic_castsizeof…noexceptalignof | 圆括号 函数调用 函数式转型 列表初始化临时对象 (C++11) 数组下标 对象成员访问 对象指针成员访问 后置递增 后置递减 运行时类型信息 去除 const 转型 运行时类型检查转型 类型强制转型 编译时类型检查转型 获取参数包大小 编译时异常检查 获取类型对齐要求 | (expression)function_name(arguments)type(expression)type{expression}pointer[expression]object.member_nameobject_pointer->member_namelvalue++lvalue––typeid(type)或typeid(expression)const_cast<type>(expression)dynamic_cast<type>(expression)reinterpret_cast<type>(expression)static_cast<type>(expression)sizeof…(expression)noexcept(expression)alignof(type) | 
| 3 R->L | +-++––!not~(type)sizeofco_await&*newnew[]deletedelete[] | 一元加 一元减 前置递增 前置递减 逻辑非 逻辑非 按位取反 C 风格转型 字节大小 等待异步调用 取地址 解引用 动态内存分配 动态数组分配 动态内存删除 动态数组删除 | +expression-expression++lvalue––lvalue!expressionnot expression~expression(new_type)expressionsizeof(type)或sizeof(expression)co_await expression(C++20)&lvalue*expressionnew typenew type[expression]delete pointerdelete[] pointer | 
| 4 L->R | ->*.* | 成员指针选择器 成员对象选择器 | object_pointer->*pointer_to_memberobject.*pointer_to_member | 
| 5 L->R | */% | 乘法 除法 取模(余数) | expression * expressionexpression / expressionexpression % expression | 
| 6 L->R | +- | 加法 减法 | expression + expressionexpression - expression | 
| 7 L->R | <<>> | 按位左移 / 插入操作 按位右移 / 提取操作 | expression << expressionexpression >> expression | 
| 8 L->R | <=> | 三向比较 (C++20) | expression <=> expression | 
| 9 L->R | <<=>>= | 小于比较 小于或等于比较 大于比较 大于或等于比较 | expression < expressionexpression <= expressionexpression > expressionexpression >= expression | 
| 10 L->R | ==!= | 相等 不等 | expression == expressionexpression != expression | 
| 11 L->R | & | 按位与 | expression & expression | 
| 12 L->R | ^ | 按位异或 | expression ^ expression | 
| 13 L->R | ` | ` | 按位或 | 
| 14 L->R | &&and | 逻辑与 逻辑与 | expression && expressionexpression and expression | 
| 15 L->R | ` | <br>or` | |
| 16 R->L | throwco_yield?:=*=/=%=+=-=<<=>>=&=` | = <br>^=` | 抛出表达式 产出表达式 (C++20) 条件运算符 赋值 乘后赋值 除后赋值 取模后赋值 加后赋值 减后赋值 按位左移后赋值 按位右移后赋值 按位与后赋值 按位或后赋值 按位异或后赋值 | 
| 17 L->R | , | 逗号运算符 | expression, expression | 
您现在可能已经认识其中一些运算符,例如 +、-、*、/、() 和 sizeof。然而,除非您有其他编程语言的经验,否则目前该表中的大多数运算符对您来说可能都是难以理解的。这在现阶段是意料之中的。我们将在本章中介绍其中的许多运算符,其余的将在需要时引入。
问:指数运算符在哪里?
C++ 不包含执行幂运算的运算符(operator^ 在 C++ 中有不同的功能)。我们将在(取模和幂运算)中进一步讨论幂运算。
请注意,operator<< 同时处理按位左移和插入操作,operator>> 同时处理按位右移和提取操作。编译器可以根据操作数的类型确定要执行的操作。
加括号
由于优先级规则,4 + 2 * 3 将被分组为 4 + (2 * 3)。但是,如果我们实际意思是 (4 + 2) * 3 呢?就像在普通数学中一样,在 C++ 中我们可以显式使用圆括号来设置我们所需的操作数分组方式。这是因为圆括号具有最高的优先级级别之一,因此圆括号通常在其内部内容之前进行求值。
使用圆括号使复合表达式更易于理解
现在考虑像 x && y || z 这样的表达式。这是应该计算为 (x && y) || z 还是 x && (y || z)?您可以查表看到 && 的优先级高于 ||。但是运算符和优先级级别太多了,很难全部记住。而且您不想总是为了理解复合表达式如何求值而去查运算符。
为了减少错误并使您的代码无需参考优先级表也能易于理解,最好对任何非平凡的复合表达式加括号(parenthesize),以明确您的意图。
最佳实践
使用圆括号来明确非平凡的复合表达式应如何求值(即使它们在技术上是不必要的)。
一个好的经验法则是:除了加法、减法、乘法和除法之外,其他所有表达式都加上括号。
上述最佳实践有一个额外的例外:包含单个赋值运算符(且没有逗号运算符)的表达式不需要将赋值的右操作数包裹在括号中。
例如:
x = (y + z + w);   // 不要这样写
x = y + z + w;     // 这样写是可以的
x = ((y || z) && w); // 不要这样写
x = (y || z) && w;   // 这样写是可以的
x = (y *= z); // 包含多个赋值的表达式仍然受益于括号
赋值运算符的优先级是第二低的(只有逗号运算符更低,而且很少使用)。因此,只要只有一个赋值(且没有逗号),我们就知道右操作数将在赋值之前完全求值。
最佳实践
包含单个赋值运算符的表达式不需要将赋值的右操作数包裹在括号中。
操作的值计算(Value computation)
C++ 标准使用术语值计算(value computation) 来表示表达式中运算符的执行以产生一个值。优先级和结合性规则决定了值计算发生的顺序。
例如,给定表达式 4 + 2 * 3,根据优先级规则,它被分组为 4 + (2 * 3)。(2 * 3) 的值计算必须先发生,然后才能完成 4 + 6 的值计算。
操作数的求值(Evaluation)
C++ 标准(大部分)使用术语求值(evaluation) 来指代操作数的求值(不是运算符或表达式的求值!)。例如,给定表达式 a + b,a 将被求值以产生某个值,b 将被求值以产生某个值。然后这些值可以用作 operator+ 的操作数进行值计算。
术语(Nomenclature)
非正式地,我们通常使用术语"求值(evaluates)“来表示整个表达式的求值(值计算),而不仅仅是表达式的操作数。
操作数(包括函数参数)的求值顺序大部分是未指定的(Unspecified)
在大多数情况下,操作数和函数参数的求值顺序是未指定的(unspecified),这意味着它们可以以任何顺序求值。
考虑以下表达式:
a * b + c * d
根据上面的优先级和结合性规则,我们知道这个表达式将被分组,就像我们输入了:
(a * b) + (c * d)
如果 a 是 1,b 是 2,c 是 3,d 是 4,这个表达式将始终计算出值 14。
然而,优先级和结合性规则只告诉我们操作数和运算符如何分组以及值计算发生的顺序。它们并不告诉我们操作数或子表达式被求值的顺序。编译器可以自由地以任何顺序求值操作数 a、b、c 或 d。编译器也可以先计算 a * b 或 c * d。
对于大多数表达式来说,这无关紧要。在我们上面的示例表达式中,变量 a、b、c 或 d 被求值以获取其值的顺序并不重要:计算出的值将始终是 14。这里没有歧义。
但是,编写出求值顺序确实很重要的表达式是可能的。考虑这个程序,它包含一个 C++ 新手常犯的错误:
#include <iostream>
int getValue()
{
    std::cout << "输入一个整数: "; // Enter an integer:
    int x{};
    std::cin >> x;
    return x;
}
void printCalculation(int x, int y, int z)
{
    std::cout << x + (y * z);
}
int main()
{
    printCalculation(getValue(), getValue(), getValue()); // 此行是歧义的
    return 0;
}
如果您运行此程序并输入 1、2 和 3,您可能会认为该程序将计算 1 + (2 * 3) 并打印 7。但这是假设 printCalculation() 的参数将按从左到右的顺序求值(因此参数 x 获得值 1,y 获得值 2,z 获得值 3)。如果参数按从右到左的顺序求值(因此参数 z 获得值 1,y 获得值 2,x 获得值 3),那么程序将打印 5。
提示
Clang 编译器按从左到右的顺序求值函数参数。GCC 编译器按从右到左的顺序求值函数参数。
如果您想亲自观察此行为,可以在 Wandbox 上进行。粘贴上面的程序,在 Stdin 选项卡中输入 1 2 3,选择 GCC 或 Clang,然后编译程序。输出将出现在页面底部(您可能需要向下滚动才能看到)。您会注意到 GCC 和 Clang 的输出不同!
可以通过将对 getValue() 的每个函数调用放在单独的语句中来消除上述程序的歧义:
#include <iostream>
int getValue()
{
    std::cout << "输入一个整数: "; // Enter an integer:
    int x{};
    std::cin >> x;
    return x;
}
void printCalculation(int x, int y, int z)
{
    std::cout << x + (y * z);
}
int main()
{
    int a{ getValue() }; // 将首先执行
    int b{ getValue() }; // 将第二个执行
    int c{ getValue() }; // 将第三个执行
    printCalculation(a, b, c); // 此行现在无歧义
    return 0;
}
在这个版本中,a 将始终具有值 1,b 将具有值 2,c 将具有值 3。当 printCalculation() 的参数被求值时,参数求值的顺序无关紧要——参数 x 将始终获得值 1,y 将获得值 2,z 将获得值 3。这个版本将确定性地打印 7。
关键洞察
操作数、函数参数和子表达式可以以任何顺序求值。
一个常见的错误是认为运算符优先级和结合性会影响求值顺序。优先级和结合性仅用于确定操作数如何与运算符分组,以及值计算的顺序。
警告
确保您编写的表达式(或函数调用)不依赖于操作数(或参数)的求值顺序。
相关内容
具有副作用(side effects) 的运算符也可能导致意外的求值结果。我们将在(递增/递减运算符和副作用)中介绍这一点。
测验时间
问题 #1
您从日常数学中知道,括号内的表达式首先被求值。例如,在表达式 (2 + 3) * 4 中,(2 + 3) 部分首先被求值。
在本练习中,您将获得一组没有括号的表达式。使用上表中的运算符优先级和结合性规则,为每个表达式添加括号,以明确编译器将如何求值该表达式。
显示提示
示例问题:x = 2 + 3 % 4
二元运算符 % 的优先级高于运算符 + 或运算符 =,因此它首先被求值:
x = 2 + (3 % 4)
二元运算符 + 的优先级高于运算符 =,因此接下来求值它:
最终答案:x = (2 + (3 % 4))
我们现在不再需要上表来理解这个表达式将如何求值。
a) x = 3 + 4 + 5;
b) x = y = z;
c) z *= ++y + 5;
d) a || b && c || d;
