Web开发杂谈(8) —— 一个很基础的问题(表达式求值顺序)
前几天一位读者在这里留言问了一个程序设计中很基础,其实也很经典、而且历史悠久的问题,我简单回答了一下,后来发现还有不少读者对此有兴趣,因此就展开来说一说吧。
他的问题是这样的,对于下面这段的代码,执行完成后,变量m的值应该是是多少?
1 2 3 4 5 6 | main() { int i=2,m=2 ; m+=(i++) + (++i) + (i++) ; printf( "%d" , m); } |
这位读者在TC(我猜想用的是Turbo C 2.0 ?)中得到的结果是 11 。具体读者讨论可以看这里。
当时我给出了一个很简略地回答:
这个问题其实挺复杂的,这个表达式在不同的语言中的结果是不一样的,比如在 C、C#、Perl、Java中的结果是不一样的,每种语言对这个求值得逻辑有各自不同的定义。
所以最好不要写这样的表达式。也没有必要,要计算什么就要最明确的方式写出来。
不过后来有几位读者仍然围绕着为什么等于11讨论了几个帖子,这几位读者似乎并没有注意俺当时的回复。在上面简单回答中,并没有非常深入,只提到了不同语言对此有不同,其实即使同一种语言,也有更为复杂的情况。因此这里就展开谈一谈。
问题化简
实际上,这种类似的题目在20年前的C语言教科书或者考试经常出现,但是现在如果再出这种题目,那么要么是对学生是很不负责任的老师,要么是非常负责任的老师。
我们先不管上面问题中的代码,而是先看一个更简单的表达式求值问题:
1 2 | int x=1; x = x++; |
请读者考虑一下,对于C(C++)语言,上面代码执行完成后,x的值应该是多少?
思路一: 先计算表达式 x++ ,此时其值为1,然后将 x++的值将其赋给x,因此x的值等于1。
思路二:先计算表达式 x++ ,此时其值为1,然后将其赋给x,此时x的值等于1,然后执行x加1的运算,因此最终x的值等于2。
那么上面两种思路,哪个是正确的呢?
正确的答案是,都不对,而正确的回答是:“这个值是不确定的”。
“语言”与”实现”
首先要了解一个“语言”(比如C语言、C++语言),和一个语言的“实现”(比如Turbo C 2.0 和 Visual C++ 6.0)之间的区别。这个类似于“CSS规范”与“浏览器”之间的关系,尽管CSS的标准是统一的,但是各个浏览器厂商有各自对CSS的实现方式,并且存在着差异。
同样,一个语言仅仅是一个规范,它规定了一个语言的语义,而各个编译器厂商对一个语言有各自的实现。和浏览器实现CSS之间的差异不同,在这里,并不是由于各编译器厂商对语言本身的理解不同导致的差异,而是在规范中就给出了明确的自由空间给编译器厂商。例如在linux下用gcc编译出来的程序和用turbo C编译出来的程序, 得到的结果就会不同。
下面具体看一下规范是如何定义的。
A:表达式的值与副作用
表达式有两种功能。每个表达式都产生一个值( value ),同时可能包含副作用( side effect )。副作用是指改变了某些变量的值。
比如:
1: 20 //这个表达式的值是20;它没有副作用,因为即它没有改变任何变量的值。
2: x=5 // 这个表达式的值是5;它有一个副作用,因为它改变了变量x的值。
3: x=y++ // 这个表示有两个副作用,因为改变了两个变量的值。
4: x=x++ // 这个表单时也有两个副作用,因为变量x的值发生了两次改变。
B:求值顺序点
表达式求值规则的核心在于 顺序点( sequence point ) [ C99 6.5 Expressions 条款2 ] [ C++03 5 Expressions 概述 条款4 ]。
顺序点的意思是在一系列步骤中的一个“结算”的点,语言要求这一时刻的求值和副作用全部完成,才能进入下面的部分。 C/C++中大部分表达式都没有顺序点,只有下面五种表达式有:
1: 函数。函数调用之前有一个求值顺序点。
2 :&& || 和 ?: 这三个包含逻辑的表达式。其左侧逻辑完成后有一个求值顺序点。
3 :逗号表达式。逗号左侧有一个求值顺序点。
注意,他们都只有一个求值顺序点,2和3的右侧运算结束后并没有求值顺序点。
C: 最重要的一点
C1:对于C和C++语言:
在两个顺序点之间,子表达式求值和副作用的顺序是不同步的。如果代码的结果与求值和副作用发生顺序相关,称这样的代码有不确定的行为(unspecified behavior)。 而且,假如期间对一个内建类型执行一次以上的写操作,则是未定义行为(undefined behavior)。
举例来说,对于表达式 x=x++ ,副作用发生的顺序,也就是赋值和自增1运算在何时执行,C/C++语言本身没有规定,而将其交给编译器厂商来自行决定。
C2:对于C#和Java语言,对于表达式的求值顺序和副作用发生顺序都有严格的定义,这样任何符合C#(或者Java)规范的编译器,对于相同的表达,求出来的值都是相同的。
D:为什么要这么做呢?
对于C/C++语言:
因为对于编译器提供商来说,未确定的顺序对优化有相当重要的作用。编译器可以重新组织表达式的求值,以便尽量不使用额外的寄存器以及临时变量。更加严格的说,即使是编译器提供商也无法完全彻底序列化指令(比如无法严格规定读和写的顺序),因为CPU本身有权利修改指令顺序,以便达到更高的速度。
对于C#和Java语言:
在规范中严格定义副作用的发生顺需,可以保证相同的代码产生相同的结果,这样对于程序的维护、升级都会方便得多。
E:重要结论
在写程序的时候,不要写依赖于“实现”的代码。因为代码在未来有可能要移植、要升级,也就是相同的源代码可能会到不同的“实现”中进行编译,从而带来潜在的问题,有可能这些问题在未来某个未知的时间才会发作,到那个时候,再想找到某个深藏着的问题,就会非常困难了。
再回到我们的例子中
对于这段简单的代码:
1 2 | int i=1; i = i++; |
这里仅给出来Visual studi 2008中,的C++语言 和 C#语言中的不同表现,并进行一些说明。
如果使用 C++,运算完成后, i 的值等于2;而如果使用C#,运算完成后,i的值等于1。
这里可以分别看一下,在C++和C#中,上面这两行代码分别对应的汇编代码,就可以非常清楚了。在Visual Studio中可以很方便地查看语句对应的汇编代码。
在C++中得到的汇编代码是:
1 2 3 | // i = i++ ; nop inc dword ptr [ebp-4] |
ebp-4 就是变量i的地址, [ebp-4] 表示的就是变量i的值很简单,可以看到,这里的处理方式是直接把变量i的值加1。
而在C#中得到的汇编代码是:
1 2 3 4 5 6 | // i = i++; mov eax,dword ptr [ebp-3Ch] mov dword ptr [ebp-40h],eax inc dword ptr [ebp-3Ch] mov eax,dword ptr [ebp-40h] mov dword ptr [ebp-3Ch],eax |
ebp-3Ch 就是变量i的地址, [ebp-3Ch] 表示的就是变量i的值,上述代码的执行过程是:
1:首先把变量i的值复制到寄存器EAX中,
2:把寄存器EAX中值复制到一个临时地址中,
3:把变量i的值加1,
4:把第2步中的临时变量的值复制回EAX中,
5:把EAX中的值复制回变量i中。
由此可见,自增运算的实际上并没有产生实际的作用。
现在再看一下最开始的问题,m+=(i++) + (++i) + (i++) 是如何等于11的呢?还是来看一下汇编代码,这是最准确的答案了。在Visual Studio中,这个语句对应的汇编代码是:
1 2 3 4 5 6 7 8 9 | //m+=(i++)+(++i)+(i++); inc dword ptr [ebp-4] mov eax,dword ptr [ebp-8] add eax,dword ptr [ebp-4] add eax,dword ptr [ebp-4] add eax,dword ptr [ebp-4] mov dword ptr [ebp-8],eax inc dword ptr [ebp-4] inc dword ptr [ebp-4] |
从上面的汇编代码,就很清楚11如何计算出来的了,首先把变量i的值加了1,这时i就等于3了,然后累加了3次,即 m = 2+3+3+3 ,从而 m = 11 。上面汇编代码中最后两行又把i两次加1,但是和m的值已经无关了。
因此,用清晰的写法,m+=(i++) + (++i) + (i++) 在C++中等价于:
1 2 3 4 5 6 7 | //int i=2; //int m=2; i=i+1; // m=2, i=3 m=m+i+i+i; // m=11,i=3 i=i+1; // m=11,i=4 i=i+1; // m=11,i=5 |
那么再看看在C#语言中,同样的语句,产生的汇编代码又是如何的呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // m += (i++) + (++i) + (i++); mov eax,dword ptr [ebp-3Ch] mov dword ptr [ebp-44h],eax inc dword ptr [ebp-3Ch] inc dword ptr [ebp-3Ch] mov eax,dword ptr [ebp-44h] add eax,dword ptr [ebp-3Ch] mov dword ptr [ebp-48h],eax mov eax,dword ptr [ebp-3Ch] mov dword ptr [ebp-4Ch],eax inc dword ptr [ebp-3Ch] mov eax,dword ptr [ebp-40h] add eax,dword ptr [ebp-48h] add eax,dword ptr [ebp-4Ch] mov dword ptr [ebp-40h],eax |
通过上面的汇编代码,可以看到在C#中,m+=(i++) + (++i) + (i++) 这个表达式等价于下面的程序段:
1 2 3 4 5 6 7 8 9 10 11 | //int i=2; //int m=2; int t1=i; // m=2, i=2, t1=2 i= i+1; // m=2, i=3, t1=2 i= i+1; // m=2, i=4, t1=2 int t2= t1 + i // m=2, i=4, t1=2,t2=6 int t3= i // m=2, i=4, t1=2,t2=6,t3=4 i= i+1 // m=2, i=5, t1=2,t2=6,t3=4 m=m+t2 // m=8, i=5, t1=2,t2=6,t3=4 m=m+t3 //m=12, i=5, t1=2,t2=6,t3=4 |
可以看到,里面使用了3个临时变量(即内存单元),而且步骤也比在C++中复杂。但是可以看到,在C#中,++运算符的副作用发生时间与求值过程是同步的,更接近与本来的语义。相比在C++中,二者就不是同步的了。
面试指南
前面已经强调,这样写代码是很不好的风格,建议不要写出这样的代码。但是如果你要是参加面试,或者其他考试碰到了这种题目应该怎么做呢?其实很简单。首先确定,如果用的是什么语言:
如果是C#或者Java,那么这就按照求值顺序,同步地对++ (- -)运算符计算相应的副作用发生顺序就可以了。前置或者后置的加减1,都是紧挨着求值进行的,参考上面从汇编代码翻译出来的C#代码。
如果是C/C++语言,就要问一下,使用什么编译器,使用Turbo C,还是VC,还是gcc,这样会显得你很专业,当然我并不知道所有编译器的表现,现在我知道是,Turbo C和VC9(visual Studio 2008,我猜测其他版本的VC应该大致相同,我没有验证过)中,这个求值顺序是这样的:
在开始计算之前,先把所有前置 ++ (- -)运算都计算完成(不考虑位置),比如上面的例子中,只有一个前置 ++,因此先把 i 加1,然后就开始计算表达式的值,而不再计算增减运算,直到下一个顺序点之前,把剩下的后置 ++ (- -)运算统一处理完,比如上面里中,有两个后置++,所以在最后赋值之前,把i变成5。
对其他编译器有兴趣的读者可以自己试试看,效果如何。
不仅如此
通过上面的讲解,似乎我们已经理解了表达式求值的过程,而实际上还有更为有趣的内容在等着你。
对于 m+=(i++) + (++i) + (i++) 这个表达式,在各种语言,比如C、C++、C#里面都是可以通过编译检查,并且能够计算出一个结果的,比如上面的例子,在C++和C#中分别等于11和12,但是实际上,这个写法是错误的——严格地说,是不符合标准的。也就是说,如果按照C/C++语言的标准,这个表达式的写法是错误的。
其原因是,在标准中要求,任何表达式在两个顺序点之间的求值过程中,任何一个变量的值只能修改一次。而m+=(i++) + (++i) + (i++) 这个表达式中,变量 i 被修改了3次,因此这个语句本身就是错误的。
因此,上面给出的简单的例子 x=x++ ,同样是一个错误的写法,因为x被修改了两次。
实际上,在任何一个语言的规范中,相关的内容还有非常非常多,当然对于一般的开发人员,不是打算自己写一个语言的编译器的话,不需要搞得太清楚,知道大致的原理就可以了。实际上,世界上真正写编译器的人并不多,比如在微软,参加编写 .net 框架的程序员有上千人(2002年的数字),而真正设计和编写C#编译器的人不超过5、6个人而已。
总结
1:可以看到,在表达式求值的过程中,具体副作用是什么时候发生的,这一点在不同语言,甚至同一种语言的不同的编译器上,结果都有可能不同。概括来说,就是求值的步骤,和修改变量的步骤并非同步的,由此会导致不同的结果。
因此,在写程序的时候,要尽量避免这种情况发生,写出语义清晰明确的代码,以避免由此带来的不确定性。
2:搞清楚一个问题真正的原因才能真正解决问题,关于表达式求值的问题,实际上是有着非常多问题可以深入的,例如上面提到了“不确定”这个结果,实际上严格来说,还有更为复杂和严谨的内容值得探讨,本文就不再细谈了。
建议有兴趣的读者参考下面两篇文章,上面文章中有部分文字来自于这篇文章:关于C/C++ 表达式求值顺序 , C++中的求值|副作用|序列点所导致的模糊语义 。
3:掌握一些底层的基础知识,比如了解一些简单的汇编语言知识,在需要的时候,就可以用来解决一些问题,因此作为开发人员还是要深入掌握基础才好。
4:如果对某个语言有兴趣的,希望真正掌握好它,可以仔细读一读这个语言的标准,或者说规范,这对于很多概念的理解是非常有帮助的。例如对于C#的规范,安装了Visual Studio之后,在安装的文件夹 Microsoft Visual Studio 9.0\VC#\Specifications\1033 中有一个500多页的Word文档, 标题叫做《C# Language Specification (Version 3.0)》,很多很多你不理解的问题都可以在里面找到答案。
1,416



Visual Studio 9.0\VC#\Specifications\1033,这个Word文档。是个好东西。老师对开发也有很深的造诣。佩服,向老师学习中。
八哥 ,
对啊,这些规范性的文档是很多平常我们不注意的问题的答案,确实应该仔细看一遍。
前辈不喜欢链接是吧?应该屏蔽掉 网址一栏。 程序都研究这么透彻了。
网站优化 ,
您说的是留言者的链接?这个wordpress自己戴上了 nofollow 了,应该对搜索引擎不会有影响吧。我不是很了解这个。
祝老师工作生活愉快!
华林恋,
谢谢您的鼓励!欢迎常来这里交流啊~
越来越发现其实程序和设计也是相通的。
keelii ,
对啊,都应该了解一些,对工作帮助很大。
看来程序要相好学精不仅要有很强的基本功还得注意细节的处理。
keelii ,
对啊,做任何事情皆如此啊。
受教了,谢谢老师的指导。我虽然也知道是编译器造成的,不过没想到去查看汇编代码,结果是蒙的,还蒙错了,呵呵。。想起前段时间看的《The C Programming Language》,里面确实提到了这方面的一些东西,看来我没理解透彻呀。
黄昏の腕轮,
如果能够实实在在啃一些有深度的书,确实对自己的提高有很大帮助的。 加油!
没想到还藏有这么深奥的东西,受益匪浅啊!虽然对于老师的回答还不是全懂。因为刚学C不久,不过继续努力,谢谢老师了。
Sky ,
慢慢来,很多东西是需要慢慢摸索的,加油!