C语言中的序点和副作用

本文为综合排版文章(所参考文献见文末),出于方便阅读、记录学习历程之目的,对原文加以重新排版,略有增删,补充了一些例子。

在C99标准文件5.1.2.3讲到了序点。这里给出一些例子和说明。

【定义】序列点(序点,Sequence Point)是一个执行程序中的位置点,在这个点之前语句产生的所有副作用都将生效,而之后语句的副作用则还没有发生。

【规定】两个序列点之间,语句执行顺序是任意的,且一个对象所保存的值最多只能被修改一次

因此,在序点位置,所有事物的状态都是确定的;而在序点间的其他位置,则不能肯定某一个变量的值已经稳定。

如何理解呢?先讲一下什么是副作用。

【定义】有些表达式会有除了得到表达式值外的其他功能,称为该表达式的副作用(Side Effects)。

一个表达式必然有一个值。有些表达式没有副作用;有些表达式既会产生一个值,也会产生副作用(如i++这个表达式既会产生一个值(i自增前的值),也会使得i的值本身被+1)。我们在写出一个表达式的时候,有可能只是想要取得这个表达式的值,也有可能要利用表达式的副作用来工作。

举个栗子:

int a = 10;
int b = a;

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


定义序点是为了尽量消除编译器解释表达式时的歧义。如果序点还是不能解决某些歧义,那么标准允许编译器的实现自由选择解释方式。理解顺序点还是要从定义它的目的来下手。

举个例子:

y = x++, x+1;

已知这个语句执行前x=2,问y的值是多少?

标准规定:逗号运算符是序点。因此该表达式的值就是确定的,是4。

因为按照序点的定义,在对x+1求值前,序点,前的表达式x++求值的全部副作用都已发生完毕,即在计算x+1x=3。这个例子中,序点成功地消除了歧义。

这个歧义是怎样消除的?因为中间的顺序点使相邻顺序点间对象的值只更改一次的条件得到满足。


再比如:

/* 执行前 x = 2 */
y = (x++) * (x++);

此时y的值是多少呢?答案是:因为这个表达式本身不包含顺序点,顺序点未能消除歧义,编译器生成的代码使y取4, 6(以及更多的一些可能值)都是符合标准定义的,程序员自己应为这个不符合顺序点定义的表达式造成的后果负责。


在一个序列点之间,连续两次改变,并且访问该变量,会带来问题。比如如下经典例子:

int i = 1;
a = i++;

第2行代码在一个序列点之间改变了i的值,并且访问了i的值,它的作用是什么呢?是a[1] = 1;还是a[2] = 2呢?不确定,因此这种代码没有价值。

老板肯定不会赏识你写出这么精简的代码,你会被开除的。


再比如更经典的例子:

int i = 1;
printf("%d, %d, %d\n", i++, i++, i++);
i = 1;
printf("%d\n", i++ + i++ + i++);
i = 1;
printf("%d\n", ++i + ++i + ++i);

很多大学的C语言老师都会讲解这个问题,但其实这是一个不值得讲解的问题,只是在跟编译器较劲,不同的编译器可能会得出不同的结果(但是平常的编译器可能会得出相同的结果,让程序员私下总结错误的经验),这种根据不同的实现而得出不同的结果的代码没什么用。

第4行中,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。

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


【重要的序点位置总结】

  • 在完整表达式的结尾【最常见】。所谓完整表达式就是指不是一个更大的表达式的子表达式的表达式。具体的完整表达式的种类,可以查阅相关资料,C99的标准文档是一个不错的选择。
    int i = 1;
    i++;     /* i++是一个完整表达式 */
    i++ + 1; /* i++就不是一个完整的表达式,因为它是i++ + 1这个完整表达式的一部分 */
    
  • 逗号表达式。注意参数分隔符,不是序点。逗号表达式会严格的按照顺序来执行并且在被逗号分隔开的表达式之间有一个序列点。所以,前一个逗号表达式如果是i++,则后面的表达式可以肯定现在的值是原来的值加1(如果有溢出则另当别论)。如:
    int i = 1;
    i++, i++, i++;
    printf("%d\n", i); /* 输出的i值肯定是4 */
    
  • &&||运算符。比如,有一种短路算法来解决除法中的除0情况:
    int a = 10;
    int b = 0;
    if (b && a/b)  /* 因为b的值为0,在求b的值的时候会短路,即a/b不会执行 */
    { /* some code here */ }
    
    这样就能放心地使用除法了。这两个运算符在使用的时候都可以当成一个序列点,如果前一个表达式的值已经可以认定这整个表达式的值为真或者为假,则后面的表达式没有必要再求值,是多余的。即如上面的a/b是多余的,不能求值,求值也会出错。它们之间的求值顺序是确定的。
  • 条件运算符A ? B : C。在问号?处也存在一个序列点。比如:
    int a = 5;
    int b = a++ > 5? 0 : a;
    
    此时,b的值是什么?因为?处有序列点,其左边的表达式必须先求值完毕。 a++ > 5在和5比较时,a并没有自增,所以表达式求值为false。 因为?处的序列点,其左边表达式的副作用也要立即生效,即a自增1,变为6。 因为?左边的表达式求值为false,所以三元操作符?:返回:右边的值a。 此时a的值是6,所以b的值是6。
  • 其他的序列点
    • 自动对象的初值计算结束处;
    • 表达式语句末尾的分号处;
    • do/while/if/switch/for语句的控制条件的右括号处;
    • for语句控制条件中的两个分号处;
    • return语句返回值计算结束(末尾的分号)处。

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

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

C标准没有规定这三个函数谁会先执行,如果对顺序有要求,可以拆分成多个语句来执行。


【参考文献】

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值