C/C++ 副作用和序列点

来源:互联网 发布:muse是什么软件 编辑:程序博客网 时间:2024/05/22 05:18
http://allchange.blog.sohu.com/156456875.html

C/C++ SIDE EFFECT(副作用)和序列点(sequence point) 分享

当执行序列运行到某些特定的被称为顺序点的地方,应该完成此前计算的所有副作用同时不能发生任何子序列计算产生的副作用。
 
表达式的副作用,举个简单的例子就是:int  b,a=5;b=++a*--a;b的结果有可能为25,因为此表达是使a先自加再自减a等于5,最后结果是5*5等于25;有可能为30,因为a先自加等于6再自减等于5最后结果是6*5等于30。总之结果不一定。因为C标准里没有规定a到底取哪一个时刻的值。但有一点是可以肯定的,*在++,--之后运算。


函数的副作用:通过函数调用,使得实在参数的值得以改变,或产生特定的操作,称为   函数的副作用。作为语句的函数调用的功能是通过函数的副作用来体现的,因而把一个无副作用的函数作为语句来调用是毫无意义的.

 

 

C 语言中,术语副作用(side effect)是指对数据对象或者文件的修改。例如,以下语句        var = 99;
的副作用是把 var 的值修改成 99。对表达式求值也可能产生副作用,例如:
        se = 100
对这个表达式求值所产生的副作用就是 se 的值被修改成 100。
   序列点(sequence point)是指程序运行中的一个特殊的时间点,在该点之前的所有副作用已经结束,并且后续的副作用还没发生。
   C 语句结束标志——分号(;)是序列点。也就是说,C语句中由赋值、自增或者自减等引起的副作用在分号之前必须结束。我们以后会说到一些包含序列点的运算符。任何完整表达式(fullexpression)运算结束的那个时间点也是序列点。所谓完整表达式,就是说这个表达式不是子表达式。而所谓的子表达式,则是指表达式中的表达式。例如:
        f = ++e % 3
这整个表达式就是一个完整表达式。这个表达式中的 ++e、3 和 ++e % 3 都是它的子表达式。
    有了序列点的概念,我们下面来分析一下一个很常见的错误:
        int x = 1, y;
        y = x++ + x++;
这里 y = x++ + x++ 是完整表达式,而 x++ 是它的子表达式。这个完整表达式运算结束的那一点是一个序列点,int x = 1,y; 中的 ; 也是一个序列点。也就是说,x++ + x++位于两个序列点之间。标准规定,在两个序列点之间,一个对象所保存的值最多只能被修改一次。但是我们清楚可以看到,上面这个例子中,x的值在两个序列点之间被修改了两次。这显然是错误的!这段代码在不同的编译器上编译可能会导致 y 的值有所不同。比较常见的结果是 y的值最后被修改为 2 或者3。在此,我不打算就这个问题作更深入的分析,各位只要记住这是错误的,别这么用就可以了。有兴趣的话,可以看看以下列出的相关资料。
C 语言标准对副作用和序列点的定义如下:
   Accessing a volatile object, modifying an object, modifying a file, orcalling a function that does any of those operations are all sideeffects, which are changes in the state of the execution environment.Evaluation of an expression may produce side effects. At certainspecified points in the execution sequence called sequence points, allside effects of previous evaluations shall be complete and no sideeffects of subsequent evaluations shall have taken place.
翻译如下:
    访问易变对象,修改对象或文件,或者调用包含这些操作的函数都是副作用,它们都会改变执行环境的状态。计算表达式也会引起副作用。执行序列中某些特定的点被称为序列点。在序列点上,该点之前所有运算的副作用都应该结束,并且后继运算的副作用还没发生。

----------------------------------------------------------------------------------------------
让我们来看看下面的代码:
int i=7; printf(”%d\n”, i++ * i++);
你认为会返回什么?56?no。正确答案是返回 49?很多人会问为什么?难道不该打印出56吗?在ccfaq中有非常详尽的解释,根本原因在于c中的序列点。
请注意,尽管后缀自加和后缀自减操作符 ++ 和 —在输出其旧值之后才会执行运算,但这里的“之后”常常被误解。没有任何保证确保自增或自减会在输出变量原值之后和对表达式的其它部分进行计算之前立即进行。也不能保证变量的更新会在表达式 “完成” (按照 ANSI C 的术语, 在下一个”序列点”之前) 之前的某个时刻进行。本例中,编译器选择使用变量的旧值相乘以后再对二者进行自增运算。只有到达一个序列点之后,自增运算才能保证真正被执行。
包含多个不确定的副作用的代码的行为总是被认为未定义。(简单而言,“多个不确定副作用”是指在同一个表达式中使用导致同一对象修改两次或修改以后又被引用的自增,自减和赋值操作符的任何组合。这是一个粗略的定义。)甚至都不要试图探究这些东西在你的编译器中是如何实现的 (这与许多 C 教科书上的弱智练习正好相反);正如 K&R明智地指出,”如果你不知道它们在不同的机器上如何实现, 这样的无知可能恰恰会有助于保护你”。
那么,所谓的序列点是什么意思呢?
序列点是一个时间点(在整个表达式全部计算完毕之后或在 ||、 &&、 ? : 或逗号 运算符处, 或在函数调用之前), 此刻尘埃落定,所有的副作用都已确保结束。 ANSI/ISO C 标准这样描述:
在上一个和下一个序列点之间,一个对象所保存的值至多只能被表达式的计算修改一次。而且前一个值只能用于决定将要保存的值。
第二句话比较费解。它说在一个表达式中如果某个对象需要写入, 则在同一表达式中对该对象的访问应该只局限于直接用于计算将要写入的值。这条规则有效地限制了只有能确保在修改之前才访问变量的表达式为合法。
例如 i = i+1 合法,而 a[i] = i++ 则非法。为什么这样的代码:a[i] = i++; 不能工作?子表达式 i++ 有一个副作用— 它会改变 i 的值 — 由于 i 在同一表达式的其它地方被引用,这会导致无定义的结果,无从判断该引用(左边的 a[i]中)是旧值还是新值。那么,对于 a[i] = i++; 我们不知道 a[] 的哪一个分量会被改写,但 i 的确会增加 1,对吗?
不一定!如果一个表达式和程序变得未定义,则它的所有方面都会变成未定义。
为什么&& 和 || 运算符可以产生序列点呢?这些运算符在此处有一个特殊的例外:如果左边的子表达式决定最终结果 (即,真对于|| 和假对于 && ) ,则右边的子表达式不会计算。因此,从左至右的计算可以确保,对逗号表达式也是如此。而且,所有这些运算符(包括 ? : ) 都会引入一个额外的内部序列点。

 

 

 

 

C/C++中的序列点


0. 什么是副作用(side effects)

C99定义如下
Accessing a volatile object, modifying an object, modifying a file, or
calling a function that does any of those operations are all side effects,
which are changes in the state of the execution environment.

C++2003定义如下
Accessing an object designated by a volatile lvalue, modifying an object,
calling a library I/O function, or calling a function that does any of
those operations are all side effects, which are changes in the state of
the execution environment.

可以看出C99和C++2003对副作用的定义基本类似,一个程序可以看作一个状态机,在
任意一个时刻程序的状态包含了它的所有对象内容以及它的所有文件内容(标准输入
输出也是文件),副作用会导致状态的跳转

一个变量一旦被声明为volatile-qualified类型,则表示该变量的值可能会被程序之
外的事件改变,每次读取出来的值只在读取那一刻有效,之后如果再用到该变量的值
必须重新读取,不能沿用上一次的值,因此读取volatile-qualified类型的变量也被
认为是有副作用,而不仅仅是改写

注,一般不认为程序的状态包含了CPU寄存器的内容,除非该寄存器代表了一个变量,
例如
void foo() {
  register int i = 0;  // 变量i被直接放入寄存器中,本文中被称为寄存器变量
                       // 注,register只是一个建议,不一定确实放入寄存器中
                       // 而且没有register关键字的auto变量也可能放入寄存器
                       // 这里只是用来示例,假设i确实放入了寄存器中
  i = 1;  // 寄存器内容改变,对应了程序状态的改变,该语句有副作用
  i + 1;  // 编译时该语句一般有警告:“warning: expression has no effect”
          // CPU如果执行这个语句,也肯定会改变某个寄存器的值,但是程序状态
          // 并未改变,除了代表i的寄存器,程序状态不包含其他寄存器的内容,
          // 因此该语句没有任何副作用
}
特别的,C99和C++2003都指出,no effect的expression允许不被执行
An actual implementation need not evaluate part of an expression if it
can deduce that its value is not used and that no needed side effects
are produced (including any caused by calling a function or accessing
a volatile object).


1. 什么是序列点(sequence points)

C99和C++2003对序列点的定义相同
At certain specified points in the execution sequence called sequence
points, all side effects of previous evaluations shall be complete and
no side effects of subsequent evaluations shall have taken place.

中文表述为,序列点是一些被特别规定的位置,要求在该位置前的evaluations所
包含的一切副作用在此处均已完成,而在该位置之后的evaluations所包含的任何
副作用都还没有开始

例如C/C++都规定完整表达式(full-expression)后有一个序列点
extern int i, j;
i = 0;
j = i;
上面的代码中i = 0以及j = i都是一个完整表达式,;说明了表达式的结束,因此
在;处有一个序列点,按照序列点的定义,要求在i = 0之后j = i之前的那个序列
点上对i = 0的求值以及副作用全部结束(0被写入i中),而j = i的任何副作用都
还没有开始。由于j = i的副作用是把i的值赋给j,而i = 0的副作用是把i赋值为
0,如果i = 0的副作用发生在j = i之后,就会导致赋值后j的值是i的旧值,这显
然是不对的

由序列点以及副作用的定义很容易看出,在一个序列点上,所有可能影响程序状态
的动作均已完成,那这样能否推断出在一个序列点上一个程序的状态应该是确定的
呢?!答案是不一定,这取决于我们代码的写法。但是,如果在一个序列点上程序
的状态不能被确定,那么标准规定这样的程序是undefined behavior,稍后会解释
这个问题


2. 表达式求值(evaluation of expressions)与副作用发生的相互顺序

C99和C++2003都规定
Except where noted, the order of evaluation of operands of individual
operators and subexpressions of individual expressions, and the order
in which side effects take place, is unspecified.

也就是说,C/C++都指出一般情况下在表达式求值过程中的操作数求值顺序以及副
作用发生顺序是未说明的(unspecified)。为什么C/C++不详细定义这些顺序呢?
原因是因为C/C++都是极端追求效率的语言,不规定这些顺序,是为了允许编译器
有更大的优化余地,例如
extern int *p;
extern int i;
*p = i++;  // (1)
根据前述规定,在表达式(1)中到底是*p先被求值还是i++先被求值是由编译器决定
的;两次副作用(对*p赋值以及i++)发生的顺序是由编译器决定的;甚至连子表
达式i++的求值(就是初始时i的值)以及副作用(将i增加1)都不需要同步发生,
编译器可以先用初始时i的值(即子表达式i++的值)对*p赋值,然后再将i增加1,
这样就把子表达式i++的整个计算过程分成了两个不相邻的步骤。而且通常编译器
都是这么实现的,原因在于i++的求值过程同*p = i++是有区别的,对于单独的表
达式i++,执行顺序一般是(假设不考虑inc指令):先将i加载到某个寄存器A(如
果i是寄存器变量则此步骤可以跳过)、将寄存器A的值加1、将寄存器A的新值写回
i的地址;对于*p = i++,如果要先完整的计算子表达式i++,由于i++表达式的值
是i的旧值,因此还需要一个额外的寄存器B以及一条额外的指令来辅助*p = i++的
执行,但是如果我们先将加载到A的值写回到*p,然后再执行对i增加1的指令,则
只需要一个寄存器即可,这种做法在很多平台都有重要意义,因为寄存器的数目往
往是有限的,特别是假如有人写出如下的语句
extern int i, j, k, x;
x = (i++) + (j++) + (k++);
编译器可以先计算(i++) + (j++) + (k++)的值,然后再对i、j、k各自加1,最后
将i、j、k、x写回内存,这比每次完整的执行完++语义效率要高


3. 序列点对副作用的限制

C99和C++2003都有类似的如下规定
Between the previous and next sequence point a scalar object shall
have its stored value modified at most once by the evaluation of an
expression. Furthermore, the prior value shall be accessed only to
determine the value to be stored. The requirements of this paragraph
shall be met for each allowable ordering of the subexpressions of a
full expression; otherwise the behavior is undefined.

也就是说,在相邻的两个序列点之间,一个对象只允许被修改一次,而且如果一个
对象被修改则在这两个序列点之间对该变量的读取的唯一目的只能是为了确定该对
象的新值(例如i++,需要先读取i的值以确定i的新值是旧值+1)。特别的,标准
要求任意可能的执行顺序都必须满足该条件,否则代码将是undefined behavior

之所以序列点会对副作用有如此的限制,就是因为C/C++标准没有规定子表达式求
值以及副作用发生之间的顺序,例如
extern int i, a[];
extern int foo(int, int);
i = ++i + 1;  // 该表达式对i所做的两次修改都需要写回对象,i的最终值取决
              // 于到底哪次写回最后发生,如果赋值动作最后写回,则i的值
              // 是i的旧值加2,如果++i动作最后写回,则i的值是旧值加1,
              // 因此该表达式的行为是undefined
a[i++] = i;  // 如果=左边的表达式先求值并且i++的副作用被完成,则右边的
             // 值是i的旧值加1,如果i++的副作用最后完成,则右边的值是i
             // 的旧值,这也导致了不确定的结果,因此该表达式的行为将是
             // undefined
foo(foo(0, i++), i++);  // 对于函数调用而言,标准没有规定函数参数的求值
                        // 顺序,但是标准规定所有参数求值完毕进入函数体
                        // 执行之前有一个序列点,因此这个表达式有两种执
                        // 行方式,一种是先求值外层foo调用的i++然后求值
                        // foo(0, i++),然后进入到foo(0, i++)执行,这之
                        // 前有个序列点,这种执行方式还是在两个相邻序列
                        // 点之间修改了i两次,undefined
                        // 另一种执行方式是先求值foo(0, i++),由于这里
                        // 有一个序列点,随后的第二个i++求值是在新序列
                        // 点之后,因此不算是两个相邻的序列点之间修改i
                        // 两次
                        // 但是,前面已经指出标准规定任意可能的执行路径
                        // 都必须满足条件才是定义好的行为,这种代码仍然
                        // 是undefined

前面我提到在一个序列点上程序的状态不一定是确定的,原因就在于相邻的两个序
列点之间可能会发生多个副作用,这些副作用的发生顺序是未指定的,如果多于一
个的副作用用于修改同一个对象,例如示例代码i = ++i + 1;,则程序的结果是依
赖于副作用发生顺序的;另外,如果某个表达式既修改了某个对象又需要读取该对
象的值,且读取对象的值并不用于确定对象新值,则读取和修改两个动作的先后顺
序也会导致程序的状态不能唯一确定
所幸的是,“在相邻的两个序列点之间,一个对象只允许被修改一次,而且如果一
个对象被修改则在这两个序列点之间只能为了确定该对象的新值而读一次”这一强
制规定保证了符合要求的程序在任何一个序列点位置上其状态都可以确定下来

注,由于对于UDT类型存在operator重载,函数语义会提供新的序列点,因此某些
对于built-in类型是undefined behavior的表达式对于UDT确可能是良好定义的,
例如
i = i++;  // 如果i是built-in类型对象,则该表达式在两个相邻的序列点之间对
          // i修改了两次,undefined
          // 如果i是UDT类型该表达式也许是i.operator=(i.operator++(int)),
          // 函数参数求值完毕后会有一个序列点,因此该表达式并没有在两个
          // 相邻的序列点之间修改i两次,OK

由此可见,常见的问题如printf("%d, %d", i++, i++)这种写法是错误的,这类问
题作为笔试题或者面试题是没有任何意义的
类似的问题同样发生在cout << i++ << i++这种写法上,如果overload resolution
选择成员函数operator<<,则等价于(cout.operator<<(i++)).operator<<(i++),
否则等价于operator<<(operator<<(cout, i++), i++),如果i是built-in类型对
象,这种写法跟foo(foo(0, i++), i++)的问题一致,都是未定义行为,因为存在
某条执行路径使得i会在两个相邻的序列点之间被修改两次;如果i是UDT则该写法
是良好定义的,跟i = i++一样,但是这种写法也是不推荐的,因为标准对于函数
参数的求值顺序是unspecified,因此哪个i++先计算是不能预计的,这仍旧会带来
移植性的问题,这种写法应该避免


4. 编译器的跨序列点优化

根据前述讨论可知,在同一个表达式内对于同一个变量i,允许的行为是
A. 不读取,改写一次,例如
     i = 0;
B. 读取一次或者多次,改写一次,但所有读取仅仅用于决定改写后的新值,例如
     i = i + 1;  // 读取一次,改写一次
     i = i & (i - 1);  // 读取两次,改写一次,感谢puke给出的例子
C. 不改写,读取一次或者多次,例如
     j = i & (i - 1);

对于情况B和C,编译器是有一定的优化权利的,它可以只读取一次变量的值然后
直接使用该值多次

但是,当该变量是volatile-qualified类型时编译器允许的行为究竟如何目前还
没有找到明确的答案,ctrlz认为如果在两个相邻序列点之间读取同一个volatile-
qualified类型对象多次仍旧是undefined behavior,原因在于该读取动作有副作
用且该副作用等价于修改该对象,RoachCock的意见是两个相邻的序列点之间读取
同一个volatile-qualified类型应该是合法的,但是不能被优化成只读一次。一
段在嵌入式开发中很常见的代码示例如下
extern volatile int i;
if (i != i) {  // 探测很短的时间内i是否发生了变化
  // ...
}
如果i != i被优化为只读一次,则结果恒为false,故RoachCock认为编译器不能
够对volatile-qualified类型的变量做出只读一次的优化。ctrlz则认为这段代码
本身是不正确的,应该改写成
int j = i;
if (j != i) {  // 将对volatile-qualified类型变量的多次读取用序列点隔开
  // ...
}

虽然尚不能确定volatile-qualified类型的变量在相邻两个序列点之间读取多次
行为是否合法以及将如何优化(不管怎么样,对于volatile-qualified类型这种
代码应该尽量避免),但是可以肯定的是,对于volatile-qualified类型的变量
在跨序列点之后必须要重新读取,volatile就是用来阻止编译器做出跨序列点的
过激优化的,而对于non-volatile-qualified类型的跨序列点多次读取则可能被
优化成只读一次(直到某个语句或者函数对该变量发生了修改,在此之前编译器
可以假定non-volatile-qualified类型的变量是不会变化的,因为目前的C/C++
抽象机器模型是单线程的),例如
bool flag = true;
void foo() {
  while (flag) {  // (2)
    // ...
  }
}
如果编译器探测到foo()没有任何语句(包括foo()调用过的函数)对flag有过修
改,则也许会把(2)优化成只在进入foo()的时候读一次flag的值而不是每次循环
都读一次,这种跨序列点的优化很有可能导致死循环。但是这种代码在多线程编
程中很常见,虽然foo()没有修改过flag,也许在另一个线程的某个函数调用中
会修改flag以终止循环,为了避免这种跨序列点优化带来到错误,应该把flag声
明为volatile bool,C++2003对volatile的说明如下
[Note: volatile is a hint to the implementation to avoid aggressive
optimization involving the object because the value of the object
might be changed by means undetectable by an implementation. See 1.9
for detailed semantics. In general, the semantics of volatile are
intended to be the same in C++ as they are in C. ]


5. C99定义的序列点列表

— The call to a function, after the arguments have been evaluated.
— The end of the first operand of the following operators:
     logical AND && ;
     logical OR || ;
     conditional ? ;
     comma , .
— The end of a full declarator:
     declarators;
— The end of a full expression:
     an initializer;
     the expression in an expression statement;
     the controlling expression of a selection statement (if or switch);
     the controlling expression of a while or do statement;
     each of the expressions of a for statement;
     the expression in a return statement.
— Immediately before a library function returns.
— After the actions associated with each formatted input/output function
   conversion specifier.
— Immediately before and immediately after each call to a comparison
   function, and also between any call to a comparison function and any
   movement of the objects passed as arguments to that call.


6. C++2003定义的序列点列表

所有C99定义的序列点同样是C++2003所定义的序列点
此外,C99只是规定库函数返回之后有一个序列点,并没有规定普通函数返回之后
有一个序列点,而C++2003则特别指出,进入函数(function-entry)和退出函数
(function-exit)各有一个序列点,即拷贝一个函数的返回值之后同样存在一个
序列点

需要特别说明的是,由于operator||、operator&&以及operator,可以重载,当它
们使用函数语义的时候并不提供built-in operators所规定的那几个序列点,而
仅仅只是在函数的所有参数求值后有一个序列点,此外函数语义也不支持||、&&
的短路语义,这些变化很有可能会导致难以发觉的错误,因此一般不建议重载这
几个运算符


7. C++2003中两处关于lvalue的修改对序列点的影响

在C语言中,assignment operators的结果是non-lvalue,C++2003则将assignment
operators的结果改成了lvalue,目前尚不清楚这一改动对于built-in类型有何意
义,但是它却导致了很多在合法的C代码在目前的C++中是undefined behavior,例

extern int i;
extern int j;
i = j = 1;
由于(j = 1)的结果是lvalue,该结果作为给i赋值的右操作数,需要一个lvalue-
to-rvalue conversion,这个conversion代表了一个读取语义,因此i = j = 1就
是先将1赋值给j,然后读取j的值赋值给i,这个行为是undefined,因为标准规定
两个相邻序列点之间的读取只能用于决定修改对象的新值,而不能发生在修改之后
再读取
由于C++2003规定assignment operators的结果是lvalue,因此下列在C99中非法的
代码在C++2003中却是可以通过编译的
extern int i;
(i += 1) += 2;
显然按照C++2003的规定这个代码的行为是undefined,它在两个相邻的序列点之间
修改了i两次

类似的问题同样发生在built-in类型的前缀++/--operators上,C++2003将前缀++/--
的结果从rvalue修改为lvalue,这甚至导致了下列代码也是undefined behavior
extern int i;
extern int j;
i = ++j;
同样是因为lvalue作为assignment operator的右操作数需要一个左值转换,该转
换导致了一个读取动作且这个读取动作发生在修改对象之后

C++的这一改动显然是考虑不周的,导致了很多C语言的习惯写法都成了undefined
behavior,因此Andrew Koenig在1999年的时候就向C++标准委员会提交了一个建
议要求为assignment operators增加新的序列点,但是到目前为止C++标准委员会
都还没有就该问题达成一致意见,我将Andrew Koenig的提议附后,如果哪位有时
间有兴趣,可以看看,不过不看也不会有任何损失 :-)
<script>window._bd_share_config={"common":{"bdSnsKey":{},"bdText":"","bdMini":"2","bdMiniList":false,"bdPic":"","bdStyle":"0","bdSize":"16"},"share":{}};with(document)0[(getElementsByTagName('head')[0]||body).appendChild(createElement('script')).src='http://bdimg.share.baidu.com/static/api/js/share.js?v=89860593.js?cdnversion='+~(-new Date()/36e5)];</script>
阅读(847) | 评论(0) | 转发(0) |
0

上一篇:代表建议全国两会安排代表委员瞻仰毛主席遗容

下一篇:Kernel command using Linux system calls

相关热门文章
  • test123
  • 编写安全代码——小心有符号数...
  • 使用openssl api进行加密解密...
  • 一段自己打印自己的c程序...
  • sql relay的c++接口
  • linux dhcp peizhi roc
  • 关于Unix文件的软链接
  • 求教这个命令什么意思,我是新...
  • sed -e "/grep/d" 是什么意思...
  • 谁能够帮我解决LINUX 2.6 10...
给主人留下些什么吧!~~
原创粉丝点击