为“a+=a-=a*a”预拟的悼词
赋值表达式也可以包括复合的赋值运算符。例如:
int a=12;
a+=a-=a*a
也是一个赋值表达式。如果a的初值为12,此赋值表达式的求解步骤如下:
①先进行“a-=a*a”的运算,它相当于a=a-a*a,a的值为12-144=-132。
②再进行“a+=-132”的运算,相当于a=a+(-132),a的值为-132-132=-264。
首先需要说明的是,这段文字中的“int a=12;”那行是笔者添加的。因为在不交代“a”的定义(变量还是常量?数据类型?)的前提下,那段讨论本身就是毫无意义的。为了把那段错误的文字提升到值得讨论的水平,增加“int a=12;”这个前提条件是必要的。
这段文字中的错误隐藏的比较深,绝大多数C语言学习者都很难发现其中的问题。为了彻底弄清其中的问题,首先需要建立或澄清几个基本概念。
表达式(Expression)
什么是表达式?
简单地说,表达式就是由运算符(operator)与操作数(operand)构成的一个序列。例如:
2 + 4
当然,有的表达式只有操作数而没有运算符。例如:
6
并非任何由运算符与操作数构成的序列都是合理合法的表达式。例如
(a=3*5)=4*3
就是一个具有明显错误的表达式。
然而运算符与操作数应该按照什么样的规则才能构成一个合理合法的表达式,则不是一两句话能说清的。所以这里暂时就不展开细说了。
某个表达式可能是另一个表达式的一部分,这时称前者是后者的子表达式(subexpression)。
表达式的效应
代码中的表达式最多可以有三种效应:要求计算机计算一个值;指明一个对象或函数;产生副效应(side effect)。
“3 + 5”这个表达式明显是要求计算机求值的。而对于
int i;
来说,在表达式“i = 5”中的“i”这个子表达式是用来指明名字为“i”的那个int类型的数据对象(object)的。
所谓“数据对象”,就是指某个数据的存储区间。换句话说,这里的“i”就是指一个特定的存储数据的空间范围及其内容。
“i = 5”与“3 + 5”这两个表达式有一个显著的不同之处,那就是前者有两种效应:求表达式的值(这个值就是5);副效应——“i”对象被存储了一个值5。
由此可见,C语言中一个表达式可能有多种效应。有的时候我们只用到其中一个效应,也有的时候我们同时用到几个效应,这样可以使代码变得非常简洁。例如在下面的代码段中
int i ;
printf( "%d\n" , i= 5 );
就用到了“i = 5”这个表达式的两种效应:求“i = 5”的值,i被赋值为5。这种写法显然比
int i ;
i = 5 ;
printf( "%d\n" , i);
要简洁漂亮得多。
“a+=a-=a*a”
了解了表达式的各种效应,就可以分析一下“a+=a-=a*a”这个表达式了。
这个表达式中有三个运算符:“+=”、“-=”和“*”。其中“*”的优先级最高,“+=”和“-=”优先级相同且都低于“*”的优先级。
对于存在相同优先级运算符的表达式,要考察这些相同优先级运算符的结合性才能确定表达式想要表达的真正含义。“+=”和“-=”的结合性为从右向左。这样,就可以知道各个运算符的运算对象了。
首先,“*”的优先级最高,因此它的操作数是“a*a”中的“a”和“a”。“a*a”这个子表达式只有一个效应:求值。
其次,“+=”和“-=”的结合性为从右向左,所以要先考虑确定“-=”的操作数。“-=”是一个二元运算符,因此其运算对象是“a-=a*a”中的前一个“a”和子表达式“a*a”。不难看出“-=”这个运算并非只有一个效应,而是有两个:求值,使“a”所代表的内存中的值变成“a*a”的值。
最后,可以确定“+=”的操作数是“a+=a-=a*a”中的最左面的“a”和子表达式“a-=a*a”。其效应也是求值和是使“a”所代表的那块内存中的值变成“a-=a*a”这个表达式的值。
因此,原来的表达式可以等价地写为:
a += ( a -= ( a * a ) )
其中添加的括号更清楚地表明了各个运算符的操作数。这个表达式所要求的全部效应可以归纳如下:
- 求子表达式“a*a”的值;
- 求子表达式“a-=a*a”的值;
- 求子表达式“a+=a-=a*a”的值;
- 使“a”所代表的内存中的值变成子表达式“a*a”的值。
- 使“a”所代表的内存中的值变成子表达式“a-=a*a”的值。
前三项都是求值,后两项是表达式的副效应。
可以确定的是,计算机一定会先求“a*a”的值,否则无法求子表达式“a-=a*a”的值。还可以确定的是,计算机一定会先求“a-=a*a”的值,否则无法求子表达式“a+=a-=a*a”的值。
但是无法确定的是副效应在何时完成——C语言并没有规定后两项副效应发生的时间。C语言只要求这两个副效应在下一个序点(sequence point)之前完成。序点的概念这里暂时不给出精确的定义。简单地说,如果这个表达式构成了一个表达式语句:
a += ( a -= ( a * a ) ) ;
那么“;”就是一个序点。其含义是前面的运算动作(包括副效应)到这个“;”这里必须全部完成。
这样,这两个副效应产生的时间,如果因为不同编译器的不同安排,就会产生无论是整个表达式的值,还是变量“a”最终的值,在不同的编译方式下产生各不相同的互相矛盾的结果。然而每一种编译“安排”,只要是在“;”之前完成了副效应,却都没有违背C语言的要求。
这也就是说,在不违背C语言原则的情况下,表达式“ a += ( a -= ( a * a ) )”可以有多种解释方式,得到并不唯一的结果。这叫做“二义性”或“多义性”。
程序设计语言和程序是不可以有二义性的,这一点和我们平时使用的自然语言截然不同。
为此,C语言特别规定:在两个相临的序点之间,同一个数据对象中保存的值最多只可以通过表达式求值改变一次。表达式“a += ( a -= ( a * a ) )”的内部并没有序点,因此必定在其前面和后面拥有这“两个相临的序点”。然而在“a += ( a -= ( a * a ) )”中,“+=”、“-=”运算的副效应又都是改变同一个数据对象“a”的值,这明显违反了“同一个数据对象中保存的值最多只可以通过表达式求值改变一次”这个C语言对表达式的基本要求,因此这个表达式是一个错误的表达式。
但是这种错误不同于语法错误(违背C语言硬性限制(constraint)的错误),编译器会承认这样的表达式不违背C语言的限制(constraint),因而可以进行编译,并且可能不会把这个错误作为语法错误报告给Coder。最多,好的编译器可能会给出一个“警告”——它嗅出了这里疑似有错。
然而,如果你在代码中写出了“a += ( a -= ( a * a ))”这样的表达式,尽管编译器可以继续编译,但是严重的问题却在于,你自己都不知道你写出的是要达到什么样效果的表达式,C语言和编译器同样也都不知道,编译器会按照它自己的“理解”擅自胡乱编译一通。C语言把这样代码的行为叫做“未定义行为”(undefined behavior)。未定义行为就是一种错误行为。只有初学者才会误以为编译通过就是代码正确。
举个更通俗的例子,“反对误人子弟的书”,这话在语法上是说得通的,但是却有歧义。因为可以把它理解为“反对——误人子弟的书”,也可以理解为“反对误人子弟——的书”。在汉语中这叫病句,在C语言中也有类似的现象。C语言并没有规定“反对误人子弟的书”究竟是“反对——误人子弟的书”的意思还是“反对误人子弟——的书”的意思,而把这种现象归为“未定义行为”。
令人扼腕的是,这样一只巨大无比的BUG竟然以教科书的面目误人子弟已经长达二十年了。今天,也许已经到了必须埋葬它的时候了。
为此,特撰此文,提前致悼。非为悼念,是祈悼忘。