细说C/C++中的表达式运算顺序与求值顺序

来源:互联网 发布:java linux服务器 下载 编辑:程序博客网 时间:2024/05/16 06:40
转载地址:http://www.thinkingl.com/log-32.html

1          案例描述

在解析码流拆分H264视频帧的时候,为了验证数据的正确性,要打印出码流缓冲区中的前几个值。随手写代码如下:
 
  1. int k=0; 
  2. printf( "data : %02X %02X %02X %02X %02X %02X \n"
  3. dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++] ); 
这段代码的优点就是绝大部分代码都可以拷贝粘贴出来,而不用逐个敲。一运行才发现它唯一的缺点是打印出的结果非常诡异。打印出的数据全部都是0。
data : 00 00 00 00 00 00
当时就觉得非常奇怪,因为码流中不可能存在6个连续的0,用调试器查看缓冲区数据,应该是:
00 00 00 01 21 E6
为什么打印出来全是0呢?编译器的BUG?调试器的故障?灵异事件?盯着这行代码,愣了半分钟,才想起求值顺序这回事来。

2          运算顺序( Order Of Operations 

2.1        定义

我们小学时学习四则运算,就要背“先乘除,后加减,有括号先算括号里面的。”的口诀。对于基础运算,所有的项都是常量,表达式求值顺序只被运算优先级影响,同级的运算都是从左到右结合,但速算的时候经常使用结合率改变运算顺序。
所谓运算顺序,就是运算符进行运算的顺序,受运算符的优先级和结合方式的影响。

2.2        C/C++中的运算顺序

我们大学里学C语言,第一章就会学到运算符看到下面这张表,其实只是对小学时的加减乘除进行了扩充,本质还是一样的。

C/C++中的运算顺序C语言运算符优先级表(由上至下,优先级依次递减):

运算符
解释
结合方式
() [] -> .
括号(函数等),数组,两种结构成员访问
由左向右
! ~ ++ -- + - 
* & (类型) sizeof
否定,按位否定,增量,减量,正负号,
间接,取地址,类型转换,求大小
由右向左
* / %
乘,除,取模
由左向右
+ -
加,减
由左向右
<< >>
左移,右移
由左向右
< <= >= >
小于,小于等于,大于等于,大于
由左向右
== !=
等于,不等于
由左向右
&
按位与
由左向右
^
按位异或
由左向右
|
按位或
由左向右
&&
逻辑与
由左向右
||
逻辑或
由左向右
? :
条件
由右向左
= += -= *= /= 
&= ^= |= <<= >>=
各种赋值
由右向左
,
逗号(顺序)
由左向右
所有的计算机语言都会有这么一张类似的运算符表,而且在优先级和结合顺序上大家都不会有什么出入,最多也就是把名字改一改。
C/C++的运算顺序完全由这张表决定。但为了写出高质量的代码,最好不要记住这张表,最好的办法就是加括号。

3          求值顺序( Evaluation Order 

3.1        求值顺序 VS运算顺序

求值顺序就是程序求取子表达式的值的顺序,因为编程语言中除了常量外,变量,函数等都会参与运算,而这些子表达式的值需要先确定了之后才能进行运算,所以就多出了求值顺序的问题。很多人都容易把这两个概念搞混,包括许多出笔试题的人(不排除故意设陷阱的可能)。所以当初找工作的时候经常会遇到类似的题目:
  1. main() 
  2.  
  3. int a=3,b=5,c; 
  4. c=a*b+++b; 
  5. printf ( “c=%d”, c); 
  6.  
这种题目的期望答案往往依照上面的运算符表来胡乱解释一通,实际上这种题目运算顺序虽然是确定的,但求值顺序却是不能确定的(Undefined),不同的语言不同的编译器不同的条件下会有不同的理解和解释,所以最终的正确答案应该是没有答案。
首先按照运算顺序来分析,上面的等式等价于:
c = ( a * ( b++ ) ) + b ;
对照上面的优先级表,看起来似乎很清楚:第一步算b++ = 5,且b自增为6,第二步算a*(b++) = a*5=15,第三步 15+b = 21。结果c应该是21。
但gcc version 3.4.4 在win32下的输出却明明白白是20。
因为从求值顺序来分析,此行代码只有最后的结束才有一个序列点(Sequence Point),所以表达式中的子表达式的求值顺序都是不定的。此表达式可以拆分为3个子表达式:

f(x) = a;  g(x) = b++;  h(x) = b;   即 c = f(x) * g(x) + h(x);

因为缺少序列点这三个表达式的求值顺序在C/C++标准中是没有定义的(实际上这样的代码是被C/C++标准明令禁止的)。所以到底是g(x)先对 b进行自增1,还是h(x)先取出b的值,都交给编译器去自由发挥了。(下面反汇编可以知道,VC选择了先取b的值计算出c,然后b++)。

3.2        C/C++中的求值顺序

3.2.1          C/C++标准
C99和C++2003都规定

Except where noted, the order of evaluation of operands of individual operators and sub-expressions of individual expressions, and the order in whichside effects take place, is unspecified.

除非是已经说明的情况,表达式求值过程中的操作数和子表达式求值顺序以及副作用发生的顺序都是未说明的。
而唯一被指明的求值次序是:

At certain specified points in the execution sequence calledsequence points, all side effects of previous evaluations shall be complete and no side effects of subsequent evaluations shall have taken place.

有一些特别规定的位置叫做序列点,在序列点前的所有求值的副作用都必须完成,之后子表达式求值的副作用还没有发生。
因为只有一个序列点前后的代码才能确定求值顺序,两个序列点之间的求值顺序是不定的,所以标准中对我们写的代码有以下类似的限定 (6.5 Expressions 第2条):

Between the previous and nextsequence 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 sub-expressions of a full expression; otherwise the behavior is undefined.

在两个相邻的序列点之间,一个对象最多只允许它储存的值被修改一次。并且访问这个对象的初始值的唯一目的只能是确定新值。子表达式的任意执行顺序都必须满足这个要求,否则代码的行为将是未定义的。

我们再看看之前的代码,“c = ( a * ( b++ ) ) + b ;” 。根据C/C++规定的少得可怜的几个序列点标准来看,整行代码只有最后的“;”结束是一个序列点。前一个序列点是上一行的分号,整行代码都处于两个序列点之间。其中的变量b在b++的时候被修改一次,为求取b++的值,我们要访问b一次,符合标准的限定。后面 +b 的时候又访问一次,便不符合标准的限定了,因此这段代码的行为是未定义的。

 

3.2.2          副作用(Side Effect)

上面多次提到了这个词,标准里是这么解释的:

C99 ( WG14 N1124 5.1.2.3 ):

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.

它们的定义都一样,只是表达措辞不同,程序可以被看作一个状态机,程序的执行对应着状态机的变化,在任何一个时刻,程序的状态包括所有对象内容和IO/文件的内容。另外读取一个声明为volatile的变量也会引起程序状态变化。副作用就是程序状态的改变。
例如:
  1. int k = 12 + 1;             // L1 
  2.  
  3. volatile int i = 1; 
  4. k + 2;                   // L2 
  5.  
  6. while(k){ break; };        // L3 
  7. while( i ) {};         // L4 
L1改变了变量k的值,有副作用。L2和L3没有改变任何对象的值,没有副作用。L4虽然只是读取,但i被声明为volatile(暗示变量可能在程序其它地方或被系统修改),所以L4也存在副作用。
 

3.2.3          序列点(Sequence Point)

序列点在标准里的定义上面已经说过了,序列点是C/C++对表达式求值顺序唯一的约束。可能大家都以为会有许多的序列点以保证我们的代码严格的正确的执行,实际上,序列点少得可怜。
C99定义的序列点列表:

The following are the sequence points described in 5.1.2.3:

— The call to a function, after the arguments have been evaluated (6.5.2.2).

— The end of the first operand of the following operators: logical AND && (6.5.13);

logical OR || (6.5.14); conditional ? (6.5.15); comma , (6.5.17).

— The end of a full declarator: declarators (6.7.5);

— The end of a full expression: an initializer (6.7.8); the expression in an expression statement (6.8.3); the controlling expression of a selection statement (if or switch) (6.8.4); the controlling expression of a while or do statement (6.8.5); each of the expressions of a for statement (6.8.5.3); the expression in a return statement (6.8.6.4).

— Immediately before a library function returns (7.1.4).

— After the actions associated with each formatted input/output function conversion specifier (7.19.6, 7.24.2).

— 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 (7.20.5)..

C的所有序列点都是C++中的序列点,但C99中只规定C库函数返回时有一个序列点而没有规定普通函数,而C++2003中规定任何函数返回(function-exit)时都有一个序列点。

3.3        为什么

C中为什么要搞这么一个复杂的序列点机制,让许多看起来很简单的代码变成未定义呢?为什么不详细的定义求值顺序呢?因为C是极端追求效率的语言,它诞生的年代计算机硬件都慢的可怜且贵的离谱,没有规定求值顺序就是要给编译器更大的余地去做优化。C++继承了C的绝大部分特性,也就一并继承了这些。
实际上java[1],C#[2]等高级语言全都详细指明了求值顺序。所以很多C/C++中很多这种没有意义的代码在它们中是有意义的。
下面有测试代码:
C++:
  1. int a=3,b=5; 
  2.  
  3.       int c,d,e; 
  4.  
  5.       c=a*b+++b; 
  6.       printf("c=%d\n", c); 
  7.  
  8.  
  9.       b=5; 
  10.       d=a*b+(++b); 
  11.       printf( "d=%d\n", d ); 
  12.  
  13.  
  14.       b=5; 
  15.       e=a*++b+b; 
  16.       printf( "e=%d\n", e ); 
C#:
  1. int a = 3, b = 5; 
  2.  
  3.  int c, d, e; 
  4.  
  5. c = a * b++ + b; 
  6.  System.Console.WriteLine("c=" + c); 
  7.  
  8.  
  9.  b = 5; 
  10.  d = a * b + (++b); 
  11.  
  12.  System.Console.WriteLine("d=" + d); 
  13.  
  14.  
  15.  b = 5; 
  16.  e = a * ++b + b; 
  17.  
  18.  System.Console.WriteLine("e=" + e ); 
下面是一张输出结果对比表:
 

VC9 WinCE

GCC 3.4.4 Win32

C# Win32

输出
c=20
d=24
e=24
c=20
d=21
e=24
c=21
d=21
e=24
C中的求值顺序不确定,所以两个编译器有不同的结果输出。C#的求值顺序明确规定为从左向右,大家有兴趣可以验证一下。
 

[1] Java的求值顺序参见http://java.sun.com/docs/books/jls/second_edition/html/expressions.doc.html#4779 15.7 Evaluation Order

[1] C#的求值顺序定义参加http://msdn.microsoft.com/en-us/library/aa691322.aspx的“7.2 Operators

3.4        编译器都做了什么

针对c=a*b+++b;,我们将VC-Wince程序反汇编可以得到:
  1.  int c,d,e; 
  2.  
  3.        c=a*b+++b; 
  4. 00013144 ldr         r1, [sp] 
  5. 00013148 ldr         r3, b, #4 
  6. 0001314C mul         r2, r1, r3 
  7. 00013150 ldr         r3, b, #4 
  8. 00013154 add         r3, r2, r3 
  9. 00013158 str         r3, c, #0xC 
  10. 0001315C ldr         r3, b, #4 
  11. 00013160 add         r3, r3, #1 
  12. 00013164 str         r3, b, #4 
  13.        printf("c=%d\n", c); 
可见,编译器先用b的旧值计算出了c的值,最后才将b自增。
而C#代码反汇编为:
  1.  int c, d, e; 
  2.  
  3.     c = a * b++ + b; 
  4.  
  5. 00000063 mov         eax,dword ptr [ebp-44h] 
  6.  
  7. 00000066 mov         dword ptr [ebp-54h],eax 
  8.  
  9. 00000069 inc         dword ptr [ebp-44h] 
  10. 0000006c mov         eax,dword ptr [ebp-54h] 
  11. 0000006f imul        eax,dword ptr [ebp-40h] 
  12. 00000073 add         eax,dword ptr [ebp-44h] 
  13.  
  14. 00000076 mov         dword ptr [ebp-48h],eax 
  15.  
  16.     System.Console.WriteLine("c=" + c); 

C#先对b(ptr [ebp-44h])进行自增操作,并且提前缓存了b的旧值(在ptr [ebp-54h])用以计算a*b++的值。对比C与C#的实现可以看到,C的实现不需要缓存b的旧值,3个变量各自在3个寄存器中完成运算,这样就节约了寄存器或内存,且少了缓存b的旧值时需要的读写操作,保证了最高的效率。当然这同时导致了C和C#得到的结果不一样,虽然它们都是符合各自标准的。当然,C编译器也可以像C#这样实现,得到和C#一样的结果,同样也是符合C标准的,不符合标准的是这段代码


1          打印问题的分析

回头看看开始那段我随手写出的诡异代码:

  1. int k=0; 
  2.  
  3. printf( "data : %02X %02X %02X %02X %02X %02X \n",  
  4.          dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++] ); 

首先,C/C++标准中指出函数入口处有一个序列点,但它只是说函数调用(汇编中的call指令)执行前完成所有求值和副作用,但作为函数参数的表达式的求值顺序是未定义的。实际上,看汇编代码我们发现,对于这种“问题代码”编译器的处理并不符合标准,k的自增运算是函数调用完成之后才执行的。只能说编译器默认我们写出的是符合标准的代码,所以把我们的代码当作“合标代码”来进行了优化处理。如果将k的定义改一下,改成静态变量 “static int k=0; ”,那么编译器就会改在函数调用之前对k做自增处理,因为静态变量的有效区域是整个程序,编译器不能保证调用的函数里面(跨cpp的时候,函数实现对编译器来说是未知的)没有用到这个k。

还有就是这里大家不要把求值顺序和压栈顺序搞混,压栈顺序是可以确定的,默认的__cdecl调用方式参数是自右向左压栈的,但这并不代表作为参数的表达式是自右向左求值的,它们求值顺序仍然只由序列点决定。

最后,作为运算符的逗号“,“处存在一个序列点,但这里“,”是用于分隔函数参数的分隔符。

综上可知,这行打印代码有两个序列点,一个在printf函数调用,一个在此行结尾。在printf调用之前的各个参数表达式的求值顺序是完全任由编译器随意发挥的。反汇编后我们可以知道VC是先取值压栈然后对k做6次自增,最后调用printf函数的。

如下:

  1. 14:       printf( "data : %02X %02X %02X %02X %02X %02X \n"
  2. dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++], dataBuf[k++] ); 
  3.  
  4. 0040F36B   mov         eax,dword ptr [ebp-0Ch] 
  5.  
  6. 0040F36E   movsx       ecx,byte ptr [ebp+eax-8] 
  7.  
  8. 0040F373   mov         dword ptr [ebp-10h],ecx 
  9.  
  10. 0040F376   mov         edx,dword ptr [ebp-10h] 
  11.  
  12. 0040F379   push        edx 
  13.  
  14. 0040F37A   mov         eax,dword ptr [ebp-0Ch] 
  15.  
  16. 0040F37D   movsx       ecx,byte ptr [ebp+eax-8] 
  17.  
  18. 0040F382   mov         dword ptr [ebp-14h],ecx 
  19.  
  20. 0040F385   mov         edx,dword ptr [ebp-14h] 
  21.  
  22. 0040F388   push        edx 
  23.  
  24. 0040F389   mov         eax,dword ptr [ebp-0Ch] 
  25.  
  26. 0040F38C   movsx       ecx,byte ptr [ebp+eax-8] 
  27.  
  28. 0040F391   mov         dword ptr [ebp-18h],ecx 
  29.  
  30. 0040F394   mov         edx,dword ptr [ebp-18h] 
  31.  
  32. 0040F397   push        edx 
  33.  
  34. 0040F398   mov         eax,dword ptr [ebp-0Ch] 
  35.  
  36. 0040F39B   movsx       ecx,byte ptr [ebp+eax-8] 
  37.  
  38. 0040F3A0   mov         dword ptr [ebp-1Ch],ecx 
  39.  
  40. 0040F3A3   mov         edx,dword ptr [ebp-1Ch] 
  41.  
  42. 0040F3A6   push        edx 
  43.  
  44. 0040F3A7   mov         eax,dword ptr [ebp-0Ch] 
  45.  
  46. 0040F3AA   movsx       ecx,byte ptr [ebp+eax-8] 
  47.  
  48. 0040F3AF   mov         dword ptr [ebp-20h],ecx 
  49.  
  50. 0040F3B2   mov         edx,dword ptr [ebp-20h] 
  51.  
  52. 0040F3B5   push        edx 
  53.  
  54. 0040F3B6   mov         eax,dword ptr [ebp-0Ch] 
  55.  
  56. 0040F3B9   movsx       ecx,byte ptr [ebp+eax-8] 
  57.  
  58. 0040F3BE   mov         dword ptr [ebp-24h],ecx 
  59.  
  60. 0040F3C1   mov         edx,dword ptr [ebp-24h] 
  61.  
  62. 0040F3C4   push        edx 
  63.  
  64. 0040F3C5   push        offset string "data : %02X %02X %02X %02X %02X "... (00422fac) 
  65.  
  66. 0040F3CA   mov         eax,dword ptr [ebp-0Ch] 
  67.  
  68. 0040F3CD   add         eax,1 
  69.  
  70. 0040F3D0   mov         dword ptr [ebp-0Ch],eax 
  71.  
  72. 0040F3D3   mov         ecx,dword ptr [ebp-0Ch] 
  73.  
  74. 0040F3D6   add         ecx,1 
  75.  
  76. 0040F3D9   mov         dword ptr [ebp-0Ch],ecx 
  77.  
  78. 0040F3DC   mov         edx,dword ptr [ebp-0Ch] 
  79.  
  80. 0040F3DF   add         edx,1 
  81.  
  82. 0040F3E2   mov         dword ptr [ebp-0Ch],edx 
  83.  
  84. 0040F3E5   mov         eax,dword ptr [ebp-0Ch] 
  85.  
  86. 0040F3E8   add         eax,1 
  87.  
  88. 0040F3EB   mov         dword ptr [ebp-0Ch],eax 
  89.  
  90. 0040F3EE   mov         ecx,dword ptr [ebp-0Ch] 
  91.  
  92. 0040F3F1   add         ecx,1 
  93.  
  94. 0040F3F4   mov         dword ptr [ebp-0Ch],ecx 
  95.  
  96. 0040F3F7   mov         edx,dword ptr [ebp-0Ch] 
  97.  
  98. 0040F3FA   add         edx,1 
  99.  
  100. 0040F3FD   mov         dword ptr [ebp-0Ch],edx 
  101.  
  102. 0040F400   call        printf (00401060) 
  103.  
  104. 0040F405   add         esp,1Ch 

如果将“k++”换成“++k”在vc6上编译的结果更诡异,Debug和Release的结果是不同的。大家有兴趣可以试一下。

2          今天你PC-lint了没有

不管是VC6,VC9,GCC等都不会对这样的代码做任何警告或提示,它们都高高兴兴的编译通过,默默无闻的为我们做优化,而且因为狗屎运的存在,很多时候程序执行结果和我们期望的一样,只不过这些问题代码就像埋下了一颗颗地雷,当换了编译器,或改了编译选项就可能不幸引爆,出现诡异的bug,所谓“Shit happens”。到时候又会有人大喊:“怎么回事?怎么换了版本就不行了?”;“我发誓,这程序我一行代码也没动过!”。

好消息是PC-lint可以检查出这些错误,上面的示例代码都会被PC-lint给以错误警告:

    printf( "data : %02X %02X %02X %02X %02X %02X \n", dataBuf[++k], dataBuf[++k], dataBuf[++k], dataBuf[++k], dataBuf[++k], dataBuf[++k] );

E:\study\testvc6\testvc6.cpp(14): error 564: (Warning -- variable 'k' depends on order of evaluation)

E:\study\testvc6\testvc6.cpp(14): error 564: (Warning -- variable 'k' depends on order of evaluation)

E:\study\testvc6\testvc6.cpp(14): error 564: (Warning -- variable 'k' depends on order of evaluation)

E:\study\testvc6\testvc6.cpp(14): error 564: (Warning -- variable 'k' depends on order of evaluation)

E:\study\testvc6\testvc6.cpp(14): error 564: (Warning -- variable 'k' depends on order of evaluation)

    c=a*b+++b;

E:\study\testvc6\testvc6.cpp(17): error 564: (Warning -- variable 'b' depends on order of evaluation)


0 0
原创粉丝点击