表达式的副作用
运算符的优先级与结合性规定了表达式中相邻两个运算符的运算次序,但对于双目
运算的操作数,C++没有规定他们的计算次序。例如,对于表达式:
exp1 + exp2;
先计算 exp1 还是 exp2?不同的编译器有不同的做法。
在数学上,对于双目运算符,不论先计算哪一个操作数,要求最终计算结果一样。
在 C++中,在计算一个操作数时,该计算会改变(影响)另一个操作数,从而导致因操
作数的不同计算次序产生不同的最终计算结果。对于因操作数计算的次序不同产生不同
结果的表达式为带副作用的表达式。在计算时会影响其他操作数的值,引起副作用的运
算符为带副作用的运算符。如++、– –以及各种赋值运算符为带副作用的运算符。例如:
x=1,(x+2)*(++x)
先计算 x+2 表达式的值为 6。
若先计算++x,由于修改了 x+2 中 x 的值,计算结果为8。
在 C++中规定,先计算逻辑与(&&)和逻辑或(||)的第一个操作数,再计算第二
个操作数,以便进行短路求值。条件(?:)、逗号(,)运算符也规定了操作数的计算次序,
除此以外,其他运算符没有规定操作数的计算次序,计算次序由具体的编译器决定。因
此在含这些运算符的表达式中,避免在操作数中引入带副作用的运算符。
i = ++i + 1; // The behavior is unspecified
在介绍概念之前,我们先解释一下它的结果。这个表达式( expression )包含3个子表达式( subexpression ):
e1 = ++i
e2 = e1 + 1
i = e2
这三个子表达式都没有顺序点( sequence point ),而 ++ i 和 i = e3 都是有副作用( side effect )的表达式。由于没有顺序点,语言不保证这两个副作用的顺序。
更加可怕的是,如果i 是一个内建类型,并在下一个顺序点之前被改写超过一次,那么结果是未定义(undefined)的!比如本例中如果有:
int i = 0x1000fffe;
i = ++i + 1; // The result is undefined!!
你也许会认为他的结果是加1 或者加2,其实更糟糕 —— 结果可能是 0x1001ffff 。他的高字节接受了一个副作用的内容,而低字节则接受了另一个副作用的内容! 如果i 是指针,那么将很容易造成程序崩溃。
为什么要这么做呢?因为对于编译器提供商来说,未确定的顺序对优化有相当重要的作用。比如,一个常见的优化策略是“减少寄存器占用和临时对象”。编译器可以重新组织表达式的求值,以便尽量不使用额外的寄存器以及临时变量。 更加严格的说,即使是编译器提供商也无法完全彻底序列化指令(比如无法严格规定读和写的顺序),因为CPU本身有权利修改指令顺序,以便达到更高的速度。
下面的术语以 ISO C99 和 C++03为准。译名为参考并附带原术语对照,如有解释不当或者错误望指正。
表达式有两种功能。每个表达式都产生一个值( value ),同时可能包含副作用( side effect ),比如:他可能修改某些值。
规则的核心在于 顺序点( sequence point ) [ C99 6.5 Expressions 条款2 ] [ C++03 5 Expressions 概述 条款4 ]。 这是一个结算点,语言要求这一侧的求值和副作用(除了临时对象的销毁以外)全部完成,才能进入下面的部分。 C/C++中大部分表达式都没有顺序点,只有下面五种表达式有:
1 函数。函数调用之前有一个求值顺序点。
2 && || 和 ?: 这三个包含逻辑的表达式。其左侧逻辑完成后有一个求值顺序点。
3 逗号表达式。逗号左侧有一个求值顺序点。
注意,他们都只有一个求值顺序点,2和3的右侧运算结束后并没有求值顺序点。
在两个顺序点之间,子表达式求值和副作用的顺序是不确定的。假如代码的结果与求值和副作用发生顺序相关,我们称这样的代码有不确定的行为(unspecified behavior)。 而且,假如期间对一个内建类型执行一次以上的写操作,则是未定义行为(undefined behavior)——我们知道,未定义行为带来最好的后果是让你的程序立即崩掉。
n = n++; // 两个副作用,对于内建对象产生是未定义行为
几乎所有表达式,求值顺序都不确定。比如,下面的加法, f1 f2 f3的调用顺序是任意的:
n = f1() + f2() + f3(); // f1 f2 f3 调用顺序任意
而函数也只在实际调用前有一个求值顺序点。所以,常见于早期 C 语言教材的这类题目,是错题:
printf("%d",--a+b,--b+a); // --a + b 和 --b + a 这两个子表达式,求值顺序不确定
天啊,甚至可能出现未定义行为?那么坚决不写与实现相关的代码是最好的对策。即使是不确定行为(比如函数调用时) 只要没有顺序点编译器怎么做方便就怎么做。 有些人认为函数调用参数求值与入栈顺序相关,这是一种误导。这个东西要解释,无异于事后诸葛亮:
void f( int i1, int i2, int i3, int i4 ){
cout<< i1 << ' ' << i2 << ' ' << i3 << ' ' << i4 << endl;
}int main(){
int i = 0;
f( i++, i++, i++, i++ );
}
这个有四个表达式求值,同时每个表达式都有负作用。这八个操作顺序是任意的,那么结果如何?未定义。
请用 VC7.1 Debug和 Release 分别测试这同一份代码,结果是不同的:
0 0 0 0 [release]
3 2 1 0 [debug]
事实上,鉴于前面的讨论,如果换一些其他初始值,这里甚至会出现错位而得到千奇百怪的诡异结果。
再看看C/C++标准中的其他经典例子:
[C99] 6.5.2.2 Function call
条款12 EXAMPLE 在下面的函数调用中:
(*pf[f1()]) ( f2(), f3() + f4() )
函数 f1 f2 f3 和f4 可能以任何顺序被调用。 但是,所有副作用都必须在那个 pf[ f1() ] 返回的函数指针产生的调用前完成。
[C++03] 5 Expressions 概论4
i = v[i++]; // the behavior is unspecified
i = 7, i++, i++; // i becomes 9 ( 译注: 赋值表达式比逗号表达式优先级高 )
i = ++i + 1; // the behavior is unspecified
i = i + 1; // the value of i is incremented
More Effective C++ 告诫我们, 千万不要重载 &&, || 和, 操作符[ MEC ,条款7 ]。为什么?
以逗号操作符为例,每个逗号左侧有一个求值顺序点。假如ar是一个普通的对象,下面的做法是无歧义的:
ar[ i ], ++i ;
但是,如果ar[ i ] 返回一个 class A 对象或引用,而它重载了 operator, 那么结果不妙了。那么,上面的语句实际上是一个函数调用:
ar[ i ].operator, ( ++i );
C/C++ 中,函数只在调用前有一个求值顺序点。所以 ar[i] 和 ++i 的求值、以及 ++i 副作用的顺序是任意的。这会引起混乱。
更可怕的是,重载 && 和 || 。 大家已经习惯了其速死算法: 如果左侧求值已经决定了最终结果,则右侧不会被求值。而且大家很依赖这个行为,比如是C风格字符串拷贝常常这样写:
while( p && *p )
*pd++ = *p++;
假如p 为 0, 那么 *p 的行为是未定义的,可能令程序崩溃。 而 && 的求值顺序避免了这一点。 但是,如果我们重载 && 就等于下面的做法:
exp1 .operator && ( exp2 )
现在不仅仅是求值混乱了。无论exp1是什么结果,exp2 必然会被求值。
最后这里有篇更深入的帖子:C++中的求值|副作用|序列点所导致的模糊语义,推荐看看。
作者在google讨论组上发了篇帖子,看了简直会让你对求值顺序和副作用发疯。比如,下面这个表达式结果居然是不确定的?
cout << "x = " << itoa(42,buf,10) << ", y = " << itoa(43,buf,10);
大家要小心别中招阿... 另外有一个更加难以察觉的例子:
void f ( auto_ptr
);
void f_2( auto_ptr, int );
f( auto_ptr( new int ) ); // exception save
f( auto_ptr( new int ), g() ); // !! not exception save
第一个例子是异常安全的。new int 返回一个指针 —— 如果他失败那么内存根本不会分配。 auto_ptr<> 如果失败(如果的话),那么它有能力删除这个指针。
第二个例子则不然,如果g()抛出异常,那么他有可能导致 memleak —— 你看,若求值碰巧以这个顺序进行:
1 int* temp = new int
2 g()
3 auto_ptr( temp )
那么当 g() 抛出异常的时候,temp 是一个裸指针,不会有任何机制帮助它释放其内存!
<< BACK
之所以想到这个话题,是前段时间一次讨论中提到的new[]一个简单类型的数组,能否用delete而不是delete[]释放的问题。因为明确说了不是类对象,所以这里可以不考虑数组对象析构时的错误,那么这样做可不可以呢?
一种声音是:严格遵守C++标准,保持好习惯;另一种声音:不要生搬经典,要自己实践才知道。
为了弄清这个问题,不得不引入一个词“未定义行为”(undefined behavior)。
In computer science, undefined behavior is a feature of some programming languages — most famously C. In these languages, to simplify the specification and allow some flexibility in implementation, the specification leaves the results of certain operations specifically undefined.
——Wikipedia (http://en.wikipedia.org/wiki/Undefined_behavior)
对于未定义行为,C++标准没有明确规定编译器们应该怎么做,那么执行的结果就是不可预料的。对于上面那个new[]完用delete释放的问题,编译器会做什么呢?不知道,因为C++标准没规定他做什么。夸张点说,即使是编译出来的程序把硬盘格式化了,也不能说这个编译器是个不符合C++标准的——它依然可能是一个完全符合C++标准的优秀编译器。
下面就用实际例子来试试吧。
int main(int argc, char* argv[])
{
while (1)
{
int* p=new int[1024*1024];
delete p; //没有用delete[] p;
}
return 0;
}
上面这段程序在VS下编译,执行了很久,从任务管理器里没发现有什么异常。不过这样就算是没问题了么?
先换个问题看看,很多C语言启蒙教材里的程序里main()的返回值是void型的:
void main()
{
}
这样的程序,大家很习惯了,因为在微软的编译器里,这样是完全可以的。不过有没有试过在LINUX下GCC编译器或者更多其他编译器里试过编译一下呢?很多是会报错的!其实,ANSI C明确规定了main()就是返回int的,void main()的用法是错误的。如此来说,在VS下能编译并正常运行的,就一定是正确的么?
上面只是个不符合标准的例子,而更多问题,则出现在 undefined behavior 上。正是因为C++标准中没有定义它,编译器没有责任说必须怎么做,[不同编译器] 或者 [同一编译器的不同版本] 或者 [同一编辑器同一版本在使用不同编译选项时] 都可能会有不同执行结果。不相信么?那么事实说话,看个程序吧。
#include
using namespace std;
int main(int argc, char* argv[])
{
int x=0;
x=(x++)+(++x)+(x++)+(++x);
cout<
return 0;
}
VC 6.0下编译,Debug版本输出7,release版本呢?却是10!为什么呢?其他编译器也许会有更多不同答案。
另一个程序:
#include
using namespace std;
int main(int argc, char* argv[])
{
int x=1;
x=(x++ * ++x + x-- * --x);
cout<
return 0;
}
VC 6.0下编译Debug版本输出5,VS 2003下Debug版本输出2。
同时微软的编译器出来的结果都各种各样,就更不用说其他公司更多的编译器了。肯定还有更多答案,不过结果本身没有意义,C++标准没有定义这种行为,对于这种undefined behavior,编译器爱怎么做都行,而我们能做的,是避免这种情况出现。“有实力的C++程序员能以最佳状态避开未定义行为。”
回到最初new[]与delete配对的问题。C++标准里规定了new/delete要配对、new[]/delete[]要配对,并没有说new[]与delete配对之后会做些什么,也就是一种未定义行为,会发生什么无法知道。即使当前某个编译器编译是正确的,至于以后的某个版本是否仍然正确也无法知道。何况C++中可以对new/delete、new[]/delete[]重新定义,谁能保证做了些什么,会有什么不同呢?
所以最保险、最规范的做法还是,严格遵守C++标准,坚决避免一切undefined behavior。
序列点
序列点(sequence points)是C++程序中的稳定点(islands of stability),在那里可以确定某些动作已经完成,且其他动作尚未开始。所有计算机语言都隐式地拥有这样的点,但在C++ 中(与在C中一样)它们由标准显式地提供。良好的编程习惯要求要么严格坚持一套方针,要么透彻理解在序列点之间发生的事情。
大多数程序都由求解表达式构成。表达式的求解会得出一个值。另外,许多表达式还具有副作用。较为显著的副作用包括诸如打开和关闭文件、从文件中提取数据、将数据插入文件中,以及其他形式的输入和输出等。
一个较为阴险的副作用是改变程序自身的状态。我指的是对程序的内存写入某些东西。最明显的例子是使用赋值来存储结果。许多程序员对此感到吃惊:这种存储结果的过程远非好事。那些拥有函数式语言(例如Haskell)编程经验的人甚至可能学过:赋值是一种危险且高度可疑的操作。
像
这样的C++语句由两个独立的元素构成。首先是求解表达式i = j * k。我们几乎总是丢弃结果,虽然如此,存在一个可以在某些环境中使用的结果。举个例子,
以上代码将返回表达式result = lhs * rhs的值。一般而言,我们通常对如下事实更感兴趣:求解赋值表达式会导致将一个值存储在由赋值操作符之左操作数指定的对象中。函数foo()完成了两件事情。它返回一个值,但也有副作用,即将计算lhs * rhs得到的值存储在由result指定的对象中。注意它与下面这个函数有何不同:
函数bar()没有副作用;它计算出并且返回lhs * rhs的结果。以计算机科学的术语表达,bar()是一个纯函数(pure function),因为对它的调用在外部对象(例如打印机)或程序的内部资源(例如内存)上,都没有永久的作用。
伴随副作用的一个问题是确定它们将在何时发生。在大多数机器上,将结果存入内存是一个相对较慢的过程。另外,一些硬件在内存写入之后、程序再次访问该块内存之前要求一段稳定时间(stabilization time)。C++使用的解决方案(继承自C)指定了称为序列点(sequence point)的东西,如果有必要,程序必须等待内存稳定。这是序列点概念的动机之一。作为程序员,我们更关心它在实践中的意义。
在两个序列点之间,我们随时可以自由地读取任何表示对象的内存,前提是我们没有改写那片内存。然而,如果在序列点之间改写了一片内存,则必须只能改写一次。此外,我们只能将对那片内存的读取,作为“决定程序将要向那片内存写入某些东西的过程”的一部分。违反这些规则中任意之一,都将导致未定义行为。
大多数程序员对规则的第一部分感到愉快:对于序列点之间的一片内存,只能写入一次。很多人不理解第二条限制。那条限制确保了这一点:如果在序列点之间内存既被读取又被写入,那么读取将在写入开始之前完成。在C++未指定子表达式求值顺序的上下文中,这是确保安全的唯一准则。
现在你应该明白为何“知道源代码中的序列点在哪里”很重要了。下面是一个完整的清单:
完全表达式(full exdivssion):在完全表达式求值的结尾存在一个序列点。完全表达式的值不被直接作为求解某个其他表达式的一部分使用。举个例子,在上面的函数bar()中,lhs * rhs是一个完全表达式。然而,在函数foo()中,lhs * rhs不是一个完全表达式,因为在计算对result的赋值中使用了它的结果。
注意,一条语句可以包含不止一个完全表达式。例如,语句
包含两个完全表达式:a < b和i++。
函数调用(function call):两个序列点保护一个函数调用。在所有实参赋值之后立即有一个序列点,从而函数体可以在“所有初始化形参的副作用已经完成”的假定之上继续执行。第二个序列点位于返回点,它确保任何提供返回值的副作用在调用该函数的代码恢复执行前已经完成。
很少有程序员编写的代码存在返回序列点的问题,不过入口的序列点有时会被误解。比方说,
可能直到你更仔细地检查对bar()的调用之前,这段代码看上去都很健康。为了调用bar(),必须计算两个表达式i和i++。它们不是完全表达式,因为结果将被作为实参用于初始化bar()的形参。这意味着在i和i++的计算之间不存在序列点。然而,对第一个实参的求解要求读取存储于由i指定的对象中的值,但我们无法确定什么东西将被写入i(考虑求解第二个实参期间递增i 的副作用),换句话说,我们破坏了关于在序列点之间读写同一个对象的规则。这就意味着我们身处未定义行为的境地,任何事情都有可能发生。在实践中,这个问题通常表现为两种求值顺序导致第一个实参具有不同的值。这种行为不应该麻痹你的警惕心,因为这并不是未指定的行为,而是未定义的行为。请弄清楚这两种行为的区别,早晚有一天会派上用场。
逗号操作符(comma operator):在某些上下文中,逗号(,)仅仅是分隔一串项目(items)的标点。在其他上下文中,则是C++序列操作符(sequence operator)。知道它到底在发挥什么作用,很大程度上是经验问题。不幸的是,这对你的程序可能有“深远的影响”。当逗号是序列操作符时,它会向代码中注入一个序列点,意味着逗号左边的表达式是完全求值的(fully evaluated),在触及右边的表达式之前所有副作用均已完成。
更糟糕的是,如果逗号操作符的至少一个操作数是用户自定义类型,则C++允许程序员重新定义它。在那些环境中,它不再是序列操作符,左右操作数(表达式)可以按任意顺序求解。因此,或许最好假定逗号不是序列操作符,除非你确切地知道它确实是。
条件操作符(conditional operator):在条件操作符的左操作数的求值,和其他两个操作数中被选中的那一个的求值之间,存在一个序列点。因此
就第一条语句来说,这段代码没问题。我无法想像怎么可能写出这样的语句,但其中不存在未定义行为。由于在? 处的序列点的保护,第一次对value的读取不会受到稍后对同一存储区的写入的影响。
|| 和&& 操作符:对于这些操作符的内建版本而言,左操作数(表达式)的求值后面都存在一个序列点。这意味着左操作数是完全求解的,所有结果的副作用在计算右操作数之前已经完成。注意,只在有必要确定其结果的时候才会计算右操作数。这意味着求解右操作数的任何副作用依赖于左操作数的值。
多序列点
一个表达式常常包含多个序列点,程序员必须注意不要假定“序列点强制求值的顺序”。序列(逗号)操作符、条件操作符,以及|| 和&& 操作符可以强制操作数的求值顺序,但这只是就事论事而已。如果你编写
“在expr2之前完全求解(包含副作用)expr1”,和“在expr4之前完全求解expr3”的任何求值序列,都在规则允许之内。比方说,不存在必须在expr4之前求解expr1的要求。