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

欢迎您发表留言

(须填写)
(须填写,不公开)

请注意:这里输入的HTML代码会被屏蔽,如果需要讨论复杂的具体代码问题,请到我们的论坛发贴,谢谢!

14条留言