你知道为什么 i = i++ + 2
在 C++17 前行为未定义吗?
你知道为什么 func(a(), b())
中,a()
与 b()
的执行顺序为什么不是确定的吗?
这篇文章可以解答你的疑惑。
注:内容中包含作者自行理解的表述,请谨慎参考。
表达式求值
每个表达式的求值包括:
- 值计算:计算表达式所返回的值。
- 引发副作用:访问(读或写)
volatile
泛左值所指代的对象,修改(写入)对象,调用库 I/O 函数,或调用任何做出这些操作的函数。
表达式 | 副作用 |
---|---|
访问 volatile 对象 | 可能将寄存器中的该变量值更新为内存中的值 |
修改对象 | 除了对象被修改,程序的状态也有略微改变 |
I/O | 对外部环境产生影响 |
包括上述所有表达式的函数 | 如上 |
例如,表达式 a++
,值计算指这个表达式的返回值是 a
,引发副作用指 a
的值加了 1
。但在这个例子中,副作用是已定义的,是用户期望的。
顺序
按顺序早于(sequenced before)
- A 按顺序早于 B(等价地,B 按顺序晚于 A),则 A 的求值会在 B 的求值开始前完成。
- A 不按顺序早于 B 而 B 不按顺序早于 A:
- 无顺序(unsequenced),则它们以任何顺序进行,并且同线程内,编译器可以将两者 CPU 指令交错。
- 顺序不确定(indeterminately sequenced),它们以任何顺序进行,但不可重叠。下次求值顺序可以相反。
注意
无顺序
和顺序不确定
的区别。由此推出,顺序不确定
和早于
都属于已定义的顺序。只有无顺序
是未定义的。
求值顺序规则
将在另一篇文章中说明以下相关知识点:完整表达式、弃值表达式、成分表达式(子表达式)、指名函数表达式、成员指针表达式、复合赋值表达式。
编号 | 表达式类型 | 操作(早于) | 对象 |
---|---|---|---|
1 | 完整表达式 | 值计算、副作用 | 下一个完整表达式 |
2 | 运算符的操作数 | 值计算 | 运算符结果 |
3 | 函数实参 | 值计算、副作用 | 函数体内任何语句 |
4 | (内建)后自增、后自减 | 值计算 | 副作用 |
5 | (内建)前自增、前自减 | 副作用 | 值计算 |
6 | && || , (逗号)的左操作数 | 值计算、副作用 | 右操作数 |
7 | 与条件运算符(?: )第一表达式 | 值计算、副作用 | 第二、三表达式 |
8 | (内建)赋值运算符、复合赋值运算符的左右操作数 | 值计算 | 副作用(修改左操作数) |
8.1 | (内建)赋值运算符、复合赋值运算符 | 副作用(修改左操作数) | 值计算(返回引用之前) |
9 | 列表初始化的子句 | 值计算、副作用 | 其后的子句 |
10 | 函数调用表达式中,指名函数表达式 | 值计算、副作用 C++17起 | 实参、默认形参 |
11 | 下标表达式 E1[E2] 中,E1 | 值计算、副作用 C++17起 | E2 |
12 | 成员指针表达式 E1.*E2 或 E1->*E2 中,E1 | 值计算、副作用 C++17起 | E2 |
13 | 移位运算符表达式 E1 << E2 或 E1 >> E2 中,E1 | 值计算、副作用 C++17起 | E2 |
14 | 简单赋值表达式 E1 = E2 或复合赋值表达式 E1 @= E2 中,E2 | 值计算、副作用 C++17起 | E1 |
- 如果一个函数调用和一个表达式(可以是另一个函数调用)没有明确顺序,二者的求值顺序不确定。(程序必须表现为如同一次函数调用的 CPU 指令,不会与其它表达式求值指令交错。
C++17起
,std::execution::par_unseq
例外) new()
的调用相对于new 表达式
中各实参的求值,是顺序不确定的(C++17前
);顺序早于它(C++17起
)。- 函数返回值的复制按顺序早于
return 语句
末尾处对所有临时量的销毁。后者又早于该块中所有局部变量的销毁。int add(int a, int b) {return a + b; }int main() {int c = add(1, 2); }`add()` 的结果复制到 `c` 早于 `a+b` 结果临时量的销毁 后者又早于局部变量 `a,b` 的销毁
C++17起
函数调用中,每个形参的值计算和副作用(二者作为一个整体)与其它任何形参相比是顺序不确定的。C++17起
重载的运算符遵循它所重载的内建运算符的定序规则。C++17起
列表初始化逗号分隔的每个表达式,如同函数调用一般求值(顺序不确定)。
未定义行为
注:切记
未定义顺序
和顺序不确定
的区别。
- 如果某个内存位置上的一项副作用相对于同一个内存位置上的另一副作用是无顺序的,那么它的行为未定义。
例 1:
i = ++i + 2; // OK
++i
副作用早于求值(第 5 条),等号右边的值计算早于左边副作用(赋值)(第 8 条)。执行顺序为:
++i
副作用 >++i
求值 >++i + 2
求值 >i = ++i + 2
副作用 >i = ++i + 2
求值。
例 2:
i = i++ + 2; // C++17 前行为未定义
C++17前,i++
求值早于副作用(第 6 条),两边的值计算早于 i = i++ + 2
的副作用(第 8 条),而没有定义两个副作用的顺序,故而 i
值不确定,行为未定义。
C++17起,i++
的副作用早于 i = i++ +2
的副作用(第 14 条)。因此可能(“可能”是因为i++
与 2
的求值顺序是不确定的)的执行顺序为:
i++
求值 >i++ + 2
求值 >i++
副作用 >i = i++ + 2
副作用 >i = i++ + 2
求值
例 3:
f(i = -2, i = -2); // C++17 前行为未定义
C++17新增了一项规则(第 18 条),它规定了形参求值和副作用是顺序不确定的,此前未定义。此前两个 i = -2
不仅顺序不一定,甚至 CPU 指令可能交错。此后尽管顺序不确定,但 CPU 指令不会交错。因此执行顺序总是:
i = -2
副作用 >i = -2
求值 >i = -2
副作用 >i = -2
求值 >f(i = -2, i = -2)
副作用 >f(i = -2, i = -2)
求值
例 4:
f(++i, ++i); // C++17 前行为未定义,C++17 起未指明
同上,C++17前,++i
和 ++i
的副作用顺序是未定义的。C++17后(第 18 条),不管编译器优先执行哪个 ++i
都是符合规则的。但由于两个 ++i
顺序不确定,所以该表达式的值计算和副作用未明确。
例 5:
i = ++i + i++; // 行为未定义
同例 2,i++
与 i = ++i + i ++
的副作用顺序未定义。
序列点规则(C++11前)
C++11 前没有 C++11起一般完备的规则,表达式求值的顺序规定依靠序列点定义。
C++11前的定义
序列点 (sequence point)是执行序列中的点,在该点所有来自序列中先前求值的副作用都已经完成,而后继求值的副作用都尚未开始。
C++11前的规则
-
每个完整表达式结尾(典型地在分号处)有一个序列点。
-
调用函数时(无论该函数是否内联,无论是否使用函数调用语法),所有函数实参的求值(若存在)之后有一个序列点,它发生于函数体内的任何表达式或语句的执行之前。
-
在从函数返回时,在从函数调用结果的复制初始化之后,和 return 语句的 表达式 末尾的临时对象析构(若存在)前,有一个序列点。
-
对函数的返回值进行复制之后,并在函数外任何表达式的执行之前有一个序列点。
-
一旦函数执行开始,则在被调用函数的执行完成前,不求值调用方函数的任何表达式(函数不能交错执行)。
-
每个使用内建(非重载)运算符的下列四种表达式的求值中,表达式 a 的求值后有一个序列点。
a && b
a || b
a ? b : c
a , b
C++11前的未定义行为
- 前后序列点间,至多可以修改在同一个内存位置中的任何对象的存储值一次,否则行为未定义。
i = ++i + i++; // 未定义行为
i = i++ + 1; // 未定义行为
i = ++i + 1; // 未定义行为
++ ++i; // 未定义行为
f(++i, ++i); // 未定义行为
f(i = -1, i = -1); // 未定义行为
- 前后序列点间,访问表达式求值所修改的在同一个内存位置中的任何对象的先前值,必须只为确定要存储的值。如果以其他任何方式访问,那么行为未定义。
cout << i << i++; // 未定义行为
a[i] = i++; // 未定义行为