什么是序列点,它们与未定义行为有什么关系?

1069
"sequence points"是什么意思?
未定义行为与序列点之间有什么关系?
我经常使用像a[++i] = i;这样有趣且复杂的表达方式,让自己感觉更好。为什么我应该停止使用它们?
如果你已经阅读了这篇文章,请务必访问后续问题未定义行为和序列点重新加载
(注:这是Stack Overflow的C++ FAQ的一个条目。如果您想批评以这种形式提供FAQ的想法,那么在meta上发布的帖子将是适合这样做的地方。对该问题的回答在C++聊天室中进行监控,FAQ的想法最初就是在那里提出的,所以您的答案很可能会被那些提出这个想法的人看到。)
6个回答

732

C++98和C++03

本答案适用于旧版C ++标准。 C ++ 11和C ++ 14标准不正式包含“序列点”;操作被分别称为“顺序排列前”、“未顺序排列”或“不确定地顺序排列”。 其净效果基本相同,但术语略有不同。


免责声明:好的,这个答案有点长。所以请耐心阅读。如果你已经知道这些内容,再次阅读它们不会让你疯狂。

先决条件:对C++标准有基本的了解。

什么是序列点?
标准规定:
在执行序列中的某些指定点,称为序列点,在这些点之前的所有副作用都必须完成,而在这些点之后的任何副作用都不得发生。(§1.9/7)
副作用是什么?
表达式的求值会产生一些东西,如果还有执行环境状态的更改,则称该表达式(其求值)具有某些副作用。
例如:
int x = y++; //其中y也是int类型
除了初始化操作外,由于++运算符的副作用,y的值也会发生变化。
那么,接下来讲述序列点。comp.lang.c作者Steve Summit给出的序列点的另一种定义:
序列点是时间上的一个点,在这个点上,尘埃已经落定,迄今为止看到的所有副作用都保证完成。
C++标准中列出的常见序列点是什么?
它们是:
在完整表达式的求值结束时(§1.9/16)(完整表达式是不是另一个表达式的子表达式的表达式。)1 例如:
int a = 5; // ; 在此处是一个序列点
在第一个表达式求值之后,对以下每个表达式的求值(§1.9/18)2 a && b(§5.14)
a || b(§5.15)
a ? b : c(§5.16)
a,b(§5.18)(这里的a,b是逗号运算符;在func(a,a++)中,逗号不是逗号运算符,它只是参数a和a++之间的分隔符。因此,在这种情况下行为是未定义的(如果认为a是原始类型))
在函数调用后(无论函数是否内联),在执行函数体中的任何表达式或语句之前,对所有函数参数(如果有)进行求值(§1.9/17)。
注1:完整表达式的求值可以包括不是词法上属于完整表达式的子表达式的求值。例如,涉及评估默认参数表达式(8.3.6)的子表达式被认为是在调用函数的表达式中创建的,而不是在定义默认参数的表达式中创建的。
注2:所示的运算符是内置运算符,如第5条所述。当其中一个运算符在有效上下文中重载(第13条)时,从而指定用户定义的运算符函数时,表达式指定函数调用,操作数形成参数列表,它们之间没有暗示的序列点。
什么是未定义行为?
标准在第§1.3.12节中定义了未定义行为:
行为,例如可能由于使用错误的程序结构或错误的数据而产生,对于这种行为,国际标准不会强制执行任何要求。
未定义行为也可能在本国际标准省略了任何明确行为定义的描述时出现。
注3:允许的未定义行为范围从完全忽略情况并产生不可预测的结果,到在环境特征中文档化地进行翻译或程序执行(无论是否发出诊断消息),到终止翻译或执行(发出诊断消息)。
未定义行为与序列点之间有什么关系?
在我进入这个话题之前,您必须知道Undefined Behaviour、Unspecified Behaviour和Implementation Defined Behaviour之间的区别。
您还必须知道“单个运算符的操作数和单个表达式的子

47
*p++ = 4 不是未定义行为。*p++ 被解释为 *(p++)p++ 返回 p 的副本,并且将值存储在先前的地址中。为什么会引发未定义行为呢?这是完全合理的。 - Prasoon Saurav
7
据我所知,目前没有(合法的)C++标准副本可供链接。 - sbi
12
好的,那么您可以在国际标准化组织相关订单页面上放置链接。无论如何,仔细一想,“C++标准基础知识”这个词组似乎有点自相矛盾,因为如果您正在阅读标准,那就已经超过了初级水平。也许我们可以列出需要了解的语言基础,比如表达式语法、运算顺序,还有可能是运算符重载? - Mike DeSimone
45
我不确定引用标准是教新手的最佳方式。 - Inverse
7
第一个表达式由于在最后的 ++i 和对 i 赋值之间没有序列点而产生了未定义行为。第二个表达式没有产生未定义行为,因为表达式 i 没有改变 i 的值。在第二个示例中,i++ 在赋值运算符被调用之前被一个序列点(,)隔开。 - Kolyunya
显示剩余28条评论

298

这是对我之前回答的跟进,包含与C++11相关的内容。


先决条件:对关系(数学)有基本的了解。


C++11中是否真的没有序列点?

是的! 这是非常真实的。

序列点在C++11中已被先于序列后于序列(以及未确定顺序不可排序relations所取代。


这个"Sequenced before"究竟是什么?

Sequenced Before(§1.9/13) 是一种关系,它是:

在单个线程执行的评估之间,并引起一个严格偏序1

正式地说,这意味着对于任何两个评估(见下文) AB,如果 A顺序上先于 B,则应该在执行 B 之前执行 A。如果 A 不在 B 的顺序之前,且 B不在 A 的顺序之前,则 AB无序的2

AB之前或BA之前时,评估AB不确定顺序的,但未指定哪个先执行3

[注]
1: 严格偏序是一个二元关系"<",作用于集合P上,该关系满足反对称性传递性,即对于P中的所有abc,我们有:
........(i). 如果a < b,则 ¬ (b < a) (反对称性);
........(ii). 如果a < b并且b < c,则a < c (传递性)。
2: 未确定顺序的评估的执行可以重叠
3: 不确定顺序的评估不能重叠,但任何一个都可能首先执行。


在C++11中,“评估”一词的含义是什么?

在C++11中,表达式(或子表达式)的评估通常包括:

现在(§1.9/14)说:

与完整表达式相关联的每个值计算和副作用都会在下一个要评估的完整表达式相关联的每个值计算和副作用之前排序

  • 简单例子:

    int x; x = 10; ++x;

    ++x的值计算和副作用在x = 10;的值计算和副作用之后排序。


那么未定义行为和上述事物之间一定存在某种关系,对吗?

是的!没错。

在(§1.9/15)中提到:

除非另有说明,否则单个运算符的操作数和单个表达式的子表达式的评估是无序的4

例如:

int main()
{
     int num = 19 ;
     num = (num << 3) + (num >> 3);
} 
  1. +操作符的操作数的求值相对于彼此是无序的。
  2. <<>>操作符的操作数的求值相对于彼此是无序的。

4: 在程序执行期间多次评估的表达式中,其子表达式的无序不确定顺序的评估在不同的评估中不需要一致执行。

(§1.9/15) 操作符的操作数的值计算在操作符的结果的值计算之前。

这意味着在x + y中,xy的值计算在(x + y)的值计算之前。

更重要的是

(§1.9/15) 如果标量对象上的副作用相对于以下任一方面是无序的:

(a) 同一标量对象上的另一个副作用

或者

(b) 使用同一标量对象的值计算。

则行为是未定义的

例如:

int i = 5, v[10] = { };
void  f(int,  int);
  1. i = i++ * ++i; // 未定义行为
  2. i = ++i + i++; // 未定义行为
  3. i = ++i + ++i; // 未定义行为
  4. i = v[i++]; // 未定义行为
  5. i = v[++i]: // 行为良好定义
  6. i = i++ + 1; // 未定义行为
  7. i = ++i + 1; // 行为良好定义
  8. ++++i; // 行为良好定义
  9. f(i = -1, i = -1); // 未定义行为(见下文)

在调用函数(无论函数是否内联)时,与任何参数表达式或指定被调用函数的后缀表达式相关的每个值计算和副作用都在执行被调用函数体中的每个表达式或语句之前排序。[注意: 与不同参数表达式相关的值计算和副作用是未排序的。 — ]

表达式(5)(7)(8)不会导致未定义行为。请查看以下答案以获取更详细的解释。


最终注释:

如果您发现文章中有任何缺陷,请留下评论。超级用户(声望>20000)请毫不犹豫地编辑文章以纠正错别字和其他错误。


4
不要使用“非对称”,而是使用“反对称”来表示先后顺序关系。这应该在文本中进行更改,以符合稍后给出的偏序定义(这也与维基百科一致)。 - TemplateRex
1
为什么最后一个例子中的第7个元素是UB?也许应该是f(i = -1, i = 1)吗? - Mikhail
1
我修正了“在序列之前”的关系描述。它是一个严格偏序。显然,一个表达式不能在自己之前排序,因此该关系不能是自反的。因此它是非对称的而不是反对称的。 - ThomasMcLeod
1
++++i如何生成定义,而+++++i则生成UB? - Don Larynx
1
@DonLarynx:你为什么认为 ++++++i 是未定义行为? - MikeMB
显示剩余7条评论

37

C++17 (N4659) 包含了一个提案 Refining Expression Evaluation Order for Idiomatic C++,该提案定义了更严格的表达式求值顺序。

特别地,以下语句

8.18 赋值和复合赋值运算符:
....

在所有情况下,赋值都在右、左操作数的值计算之后、赋值表达式的值计算之前被顺序执行。 右操作数在左操作数之前被顺序执行。

连同以下澄清说明

如果一个表达式X的每个值计算和副作用都在表达式Y的每个值计算和副作用之前被顺序执行,则表达式X在表达式Y之前被顺序执行。

使得先前未定义行为的几种情况变得有效,包括所讨论的这种情况:

a[++i] = i;

然而,几个类似的案例仍然会导致未定义行为。

N4140 中:

i = i++ + 1; // the behavior is undefined

但是在N4659

i = i++ + 1; // the value of i is incremented
i = i++ + i; // the behavior is undefined

当然,使用符合C++17标准的编译器并不意味着应该开始编写这样的表达式。


1
为什么在C++17中i = i++ + 1;是定义行为,我认为即使“右操作数在左操作数之前被排序”,但“i ++”的修改和赋值的副作用是未排序的,请提供更多细节来解释这些。 - xmh0511
@jackX 我扩展了答案 :)。 - AlexD
是的,我认为解释句子“右操作数在左操作数之前排序”更有用。例如,“右操作数在左操作数之前排序”意味着与右操作数相关联的值计算和副作用在左操作数之前进行排序,就像您所做的那样 :-) - xmh0511
@xmh0511 我对 i = i++ + 1; 的理解是有两个机制将 i 的值增加了1。第一个是后置自增运算符,第二个是赋值操作,其值等于 i+1。我的理解是(截至C++17),后置自增在赋值之前被排序。 - Tim Randall
@TimRandall 我的理解是 i++ 的副作用在评估 lhs 的副作用之前被排序,但不一定在赋值运算符的“副作用”之前。标准可能可以写得更清楚些。 - Hans Olsson
据我所知,无论是gcc还是clang,在给定类似于arr[f()] = x;的情况下,都不能可靠地确保对x的评估先于对f()的调用。虽然我不认为标准应该要求对x的评估先于函数调用,但我不明白如何阅读标准以表明如果i==1,则arr[i++] = i;将需要将值1存储到arr[2],而不需要要求arr[f()] = x;存储函数调用前x所持有的值。 - supercat

11
我猜这个变化有一个基本原因,不仅是为了使旧的解释更清晰而做出的表面上的改变:这个原因就是并发。未指定的阐述顺序仅仅是选择几个可能的串行顺序之一,这与之前或之后的顺序完全不同,因为如果没有指定顺序,可以进行并发评估:但旧规则却不能这样做。例如,在以下代码中:
f (a,b)

以前是先执行a再执行b,或者是先执行b再执行a。现在,a和b可以交错执行指令,甚至可以在不同的核心上执行。


5
我认为,如果'a'或'b'中有一个包含函数调用,它们将是不确定顺序而不是未定义顺序,这意味着要求来自其中一个的所有副作用在另一个之前发生,尽管编译器不必一致地确定哪个先执行。如果这不再成立,将破坏很多依赖于操作不重叠的代码(例如,如果'a'和'b'各自设置、使用和拆除共享的静态状态)。 - supercat

2
在C99(ISO/IEC 9899:TC3)中,关于评估顺序做出了以下声明,这似乎在此讨论中尚未提到。
"[...]子表达式的评估顺序和副作用发生的顺序都是未指定的。(第6.5页第67节)"
"操作数的评估顺序是未指定的。如果试图修改赋值运算符的结果或在下一个序列点之后访问它,则行为[有问题]是未定义的。(第6.5.16页第91节)"

3
这个问题标记为C++而不是C,这很好,因为C++17的行为与旧版本的行为非常不同,而且与C11、C99、C90等没有关系,或者关系非常微小。总的来说,我建议删除这个标记。更重要的是,我们需要找到相应的C语言问答,并确保它能够适用(并注明特别是C++17改变了规则——C++11及以前的行为与C11基本相同,尽管C仍然使用“序列点”来描述它,而C++11及以后则不用)。 - Jonathan Leffler

0
标准规定,只有在不明显影响任何已定义程序的行为时,才可以执行优化转换。序列点规则允许以不跨越序列点的方式重新排序操作,即使这种重新排序的效果可能是可观察的,通过将任何可能导致可观察到允许转换的效果的操作分类为未定义行为。
这种规则制定方法的一个不幸后果是,它使得程序即使在不重要的情况下也需要明确强制操作的顺序。例如,Java可以在不使用任何内存屏障的情况下缓存字符串的哈希码;缺乏内存屏障可能导致线程感知到哈希码没有被缓存,即使另一个线程实际上已经缓存了它,并因此执行了冗余的哈希值计算,但偶尔额外计算的成本通常远低于在每次访问时添加内存屏障的成本。然而,在C语言中,即使在只有读取尝试的可能效果是产生旧值(表示哈希码未被缓存)或最后写入的值(始终是正确的哈希码)的平台上,尝试读取已缓存的哈希码字段时也会产生未定义行为,这将导致未定义行为。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接