深入解析序列点

int i = 3;

i = i++;

cout << i;

结果是什么?有人可能会说是3,也有人可能会说是4,更多的人在骂出题的人白痴,但这语句究竟有何问题呢?未必每个人都清楚。

有些人也许马上会说,这是“未定义行为”。没错,这是一个典型的未定义行为。i = i++这个表达式合乎C++语法,能够顺利编译通过,但是执行的结果,标准说“未定义”。为什么是“未定义”,深究起来,要从序列点说起。

序列点是程序中这样的一些点:通俗地说,执行至此,之前的语句都已经彻底执行干净执行完了,之后的语句还完全没开始执行;更常见、更严谨但略晦涩的说法是,之前的语句对现场环境的改变已经全部完成,之后的语句对现场环境的改变还没有开始。啥是现场环境呢?就是程序执行到某一点的那个状态,包括变量的内容、文件的内容等。

这跟最开始那个例子有什么关系呢?关键的问题来了:标准规定,两个序列点之间,程序执行的顺序可以是任意的。没错,正如你猜的那样,C++标准规定一个完整的表达式结束之后有一个序列点,而例子中i = i++是位于两个序列点之间的。编译器可以先算完i++,再写结果给i,也可以先将i = i,再令i++.按前面的方法算,i先自增变为4,然后i++返回3,于是i被赋值为3;按后一种方法算,i先被赋值为3,随后自增变成4.标准说了,这两种处理方法,编译器你爱选那种就选哪种,随便。如果谁写的程序像这样依赖执行的顺序,让他自己哭去!

等等,有人要问了,++的优先级难倒不是高于=吗?显然应该先执行++啊。这里有个概念的问题,前一段说的编译器先算i = i,绝不是说令=的优先级比++还高了。如果那样的话,表达式将变成 (i = i)++,也就是i.operator = (i)。 operator ++,执行++的主体变成i = i这个表达式的返回值了。上一段所说的先计算i = i,实际上还是先计算i++,只不过是先返回了i的值,然后推迟了将i自增1的操作先去干别的(i = i)去了,回头再来给i自增1.

——“什么,你说先干别的就先干别的,凭什么!”

嗯,我再重复一遍,标准规定,两个序列点之间,程序执行的顺序可以是任意的。

——“不是吃饱了撑的嘛,标准搞这个干啥?严格按照顺序执行不就完了嘛”。

C++标准弄这么复杂自然是有道理的。C++是极为重视执行效率的语言,这样做给了编译器优化的空间。比如考虑

int j = i++;

如果非得把i++执行干净了再干别的,那就不得不 temp = i; i += 1; j = i; .如果允许编译器打乱顺序执行呢,直接 j = i; i +=1; 就好了,省了一个temp倒一次的过程。

多说一句,一些更高层的语言,不是像C++这种极为重视效率的,比如Java,上面的例子就完全没有问题。Java完全不允许你编译器乱搞,上面那个例子,在Java中一定是先把i++彻底执行干净了返回3,再进行赋值,赋值完之后不会再有别的操作了,所以结果一定是3.

如何避免由序列点造成的这种未定义行为,有一句经典但有点晦涩的编程规则:“在相邻的两个序列点之间,一个对象只允许被修改一次,而且如果一个对象被修改则在这两个序列点之间只能为了确定该对象的新值而读一次”。其实明白了序列点具体是怎么回事,这个规则应该就很容易明白了。由于序列点之间程序执行顺序不确定,一个对象被修改多次的话最后留下的是哪次的结果就不确定。另外如果一个对象同时存在读取和修改,只有根据读取的结果来修改才是合法的,否则就会出现是先改完再读还是先读完再改的混乱。

最后再说一下最新的C++2003标准中定义的序列点(详细说明请参考标准)

·完整声明之后

·完整表达式之后

·进入函数时与退出函数时

·|| && ?: , 四个操作符的第一个操作数之后

最后一个似乎有点奇怪,为啥 + – 操作符之前就没有序列点,|| &&之前就有呢?a+b之间没有序列点而a||b之间就有,不公平啊。

嗯,你猜的没错,是为了短路。

不过要是手建重载了默认的||和&&,他们可就视同普通函数,不会在第一个操作数之后有序列点了,切记。

本文引自 huiguixian 的博客

序列点

这篇文章大概会用到的术语有,序列点(sequence points),副作用(side effects)。

在C99标准文件5.1.2.3讲到了序列点问题,序列点的定义是一个程序执行中的点,这个点的特殊性在于,在这个点之前语句产生的所有副作用都将生效,而后面语句的副作用还没有发生。在这一点,所有的事都是肯定的,而在序列点间,不能肯定某一个变量的值已经稳定,所以总体说来C语言的序列点只是用来说明这一点的值是肯定的。如何理解呢?先讲一下什么是副作用。

一个表达式有一个值,而在写出这个表达式的时候可能只是想要取得这个表达式的值。但有些表达式会有副作用。而有些表达式没有副作用,有时候我们正是要利用表达式的副作用来工作。比如:

int a = 10;
int b = a; /* a这个表达式在这里没有副作用,这里只是想要取得 */
/* a这个变量的值10,而b = a这个表达式有副作用,它的 */
/* 副作用是使b的值改变成a的值。 */

这就是所谓的一个表达式的副作用。正是因为有了副作用,很多功能才得以完成。有些表达式既会产生一个值,也会产生副作用。如i++这个表达式既会产生一个值(它是i自增以前的值),也会产生副作用。

在一个序列点之间,连续两次改变,并且访问该变量,会带来问题,比如经典的:
int i = 1;
i = i++;

在一个序列点之间,改变了i的值,并且访问了i的值,它的作用是什么呢?是a[1] = 1;还是a[2] = 2呢?不确定,这种代码没有价值,并且老板肯定不会赏识你写出这么精简的代码,你会被开除的。

再比如更经典的:

int i = 1;
printf(“%d, %d, %dn”, i++, i++, i++);

i = 1;
printf(“%dn”, i++ + i++ + i++);

i = 1;
printf(“%dn”, ++i + ++i + ++i);

很多大学的C语言老师都会讲解这个问题,包括我的老师,在讲的时候笔者就没有弄明白,其实,这是一个不值得讲解的问题,这是在跟编译器较劲,不同的编译器可能会得出不同的结果(但是平常的编译器可能会得出相同的结果,让程序员私下总结错误的经验。),这种根据不同的实现而得出不同的结果的代码没什么用。i++ + i++ + i++只是一个表达式,在这个表达式的内多次访问了变量i,结果不确定。并且这又会引发另外一个有趣的问题,可能有人会认为在这条语句执行完成以后i自加了3次,那i肯定是4?这也不确定,可能很多编译器做得确实是4,但是,在C标准中有这样一条:当一个表达式的值取决于编译器实现而不是C语言标准的时候,其中所做的任何处理都会不确定。即,如果有一个编译器在i++ + i++ + i++这个表达式中只读取一次i的值,并且一直记住这个值,那么算第一个i++,因为i的值是1所以算出后i的值为2,再算第二个因为假设的是只读取一次i的值,那此时i的值还是1并且被加到2(因为没有经过序列点,所以i的值不能肯定为2),于是经过三次从1加到2的过程以后,最后i的值是2而不是期望的4,呵呵。

其实这要看编译器如何实现了,不过既然得看编译器如何实现,那这种代码也得被炒鱿鱼。

既然序列点这么重要,那现在就得讲讲一些重要的序列点了,这些重要的序列点要程序员自己平时总结。

1). 一个重要的序列点在完整表达式的结尾,所谓完整表达式就是指不是一个更大的表达式的子表达式的表达式,仔细理解。

int i = 1;
i++; /* i++是一个完整表达式 */
i++ + 1; /* i++就不是一个完整的表达式,因为它是i++ + 1这个完整表达式的一部分 */

具体的完整表达式的种类,可以查阅相关资料,C99的标准文档是一个不错的选择。

2). 逗号表达式。逗号表达式会严格的按照顺序来执行并且在被逗号分隔开的表达式之间有一个序列点,所以,前一个逗号表达式如果是i++,则后面的表达式可以肯定现在的值是原来的值加1(如果有溢出则另当别论)。如:

int i = 1;
i++, i++, i++;
printf(“%dn”, i);

现在的i肯定是4;

3). &&和||运算符。有一种短路算法来解决除法中的除0情况。如下

int a = 10;
int b = 0;

if (b && a/b) {
/* some code here */
}

其中在求b的值的时候会短路,即,a/b不会执行。因为b的值为0,这样可以放心的使用除法了。这两个运算符在使用的时候都可以当成一个序列点,如果前一个表达式的值已经可以认定这整个表达式的值为真或者为假,则后面的表达式没有必要再求值,是多余的。即如上面的a/b是多余的,不能求值,求值也会出错。它们之间的求值顺序是肯定的。

4). 条件运算符?:。在问号的地方也存在一个序列点,也没什么可讲。反正就是问号前后可以访问和改变同一个变量,并且这种访问是安全的。

最后,在一个表达式内的求值顺序没有固定顺序,还有一个表现是,如下:

funa() + funb() + func();

C语言标准没有规定这三个函数谁会先执行,如果对顺序有要求,可以用临时变量来缓解。

本文引自 东东的小窝