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)》,很多很多你不理解的问题都可以在里面找到答案。
4,328


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