选自<<c++应用程序性能与优化>>
从2.1节“构造函数和析构函数”中已经知道,对象的创建与销毁对程序的性能影响很大。尤其当该对象的类处于一个复杂继承体系的末端,或者该对象包含很多成员变量对象(包括其所有父类对象,即直接或者间接父类的所有成员变量对象)时,对程序性能影响尤其显著。因此作为一个对性能敏感的开发人员,应该尽量避免创建不必要的对象,以及随后的销毁。这里“避免创建不必要的对象”,不仅仅意味着在编程时,主要减少显式出现在源码中的对象创建。还有在编译过程中,编译器在某些特殊情况下生成的开发人员看不见的隐式的对象。这些对象的创建并不出现在源码级别,而是由编译器在编译过程中“悄悄”创建(往往为了某些特殊操作),并在适当时销毁,这些就是所谓的“临时对象”。需要注意的是,临时对象与通常意义上的临时变量是完全不同的两个概念,比如下面的代码:
void swap(int *px, int *py)
{
int temp; ①//临时变量,并非临时对象
temp = *px;
*px = *py;
*py = temp;
}
习惯称①句中的temp为临时变量,其目的是为了暂时存放指针px指向的int型值。但是它并不是这里要考察的“临时对象”,不仅仅是因为一般开发人员不习惯称一个内建类型的变量为“对象”(所以不算临时“对象”)。而且因为temp出现在了源码中,这里考察的临时对象并不会出现在源码中。
到底什么才是临时对象?它们在什么时候产生?其生命周期有什么特征?在回答这些问题之前,首先来看下面这段代码:
#include <iostream>
#include <cstring>
using namespace std;
class Matrix
{
public:
Matrix(double d = 1.0)
{
cout << "Matrix::Matrix()" << endl;
for(int i = 0; i < 10; i++)
for(int j = 0; j < 10; j++)
m[i][j] = d;
}
Matrix(const Matrix& mt)
{
cout << "Matrix::Matrix(const Matrix&)" << endl;
memcpy(this, &mt, sizeof(Matrix));
}
Matrix& operator=(const Matrix& mt)//返回引用
{
if(this == &mt)
return *this;
cout << "Matrix::operator=(const Matrix&)" << endl;
memcpy(this, &mt, sizeof(Matrix));
return *this;
}
friend const Matrix operator+(const Matrix&, const Matrix&);//返回临时对象
//...
private:
double m[10][10];
};
const Matrix operator+(const Matrix& arg1, const Matrix& arg2)
{
Matrix sum; ①
for(int i = 0; i < 10; i++)
for(int j = 0; j < 10; j++)
sum.m[i][j] = arg1.m[i][j] + arg2.m[i][j];
return sum; ②//返回时创建临时对象
}
int main()
{
Matrix a(2.0), b(3.0), c; ③
c = a + b; ④
return 0;
}
分析代码,③处生成3个Matrix对象a,b,c,调用3次Matrix构造函数。④处调用operator+(const Matrix&, const Matrix&)执行到①处时生成临时变量(注意此处的sum并不是“临时对象”),调用一次Matrix构造函数。④处c = a + b最后将a + b的结果赋值给c,调用的是赋值操作,而不会生成新的Matrix对象,因此从源码分析,此段代码共生成4个Matrix对象。
但是输出结果:
Matrix::Matrix() ①
Matrix::Matrix() ②
Matrix::Matrix() ③
Matrix::Matrix() ④
Matrix::Matrix(const Matrix&) ⑤
Matrix::operator=(const Matrix&) ⑥
①、②、③3处输出分别对应对象a、b和c的构造,④处输出对应的是operator+(const Matrix&, const Matrix&)中sum的构造,⑥处输出对应的是c = a + b句中最后用a + b的结果向c赋值,那么⑤处输出对应哪个对象?
答案是在这段代码中,编译器生成了一个“临时对象”。
a + b实际上是执行operator+(const Matrix& arg1, const Matrix& arg2),重载的操作符本质上是一个函数,这里a和b就是此函数的两个变量。此函数返回一个Matrix变量,然后进一步将此变量通过Matrix::operator=(const Matrix& mt)对c进行赋值。因为a + b返回时,其中的sum已经结束了其生命周期。即在operator+(const Matrix& arg1, const Matrix& arg2)结束时被销毁,那么其返回的Matrix对象需要在调用a + b函数(这里是main()函数)的栈中开辟空间用来存放此返回值。这个临时的Matrix对象是在a + b返回时通过Matrix拷贝构造函数构造,即⑤处的输出。
既然如上所述,创建和销毁对象经常会成为一个程序的性能瓶颈所在,那么有必要对临时对象产生的原因进行深入探究,并在不损害程序功能的前提下尽可能地规避它。
临时对象在C++语言中的特征是未出现在源代码中,从堆栈中产生的未命名对象。这里需要特别注意的是,临时对象并不出现在源代码中。即开发人员并没有声明要使用它们,没有为其声明变量。它们由编译器根据情况产生,而且开发人员往往都不会意识到它们的产生。
产生临时对象一般来说有如下两种场合。
(1)当实际调用函数时传入的参数与函数定义中声明的变量类型不匹配。
(2)当函数返回一个对象时(这种情形下也有例外,下面会讲到)。
另外,也有很多开发人员认为当函数传入参数为对象,并且实际调用时因为函数体内的该对象实际上并不是传入的对象,而是该传入对象的一份拷贝,所以认为这时函数体内的那个拷贝的对象也应该是一个临时对象。但是严格说来,这个拷贝对象并不符合“未出现在源代码中”这一特征。当然只要能知道并意识到对象参数的工作原理及背后隐含的性能特征,并能在编写代码时尽量规避之,那么也就没有必要在字面上较真了,毕竟最终目的是写出正确和高效的程序。
因为类型不匹配而生成临时对象的情况,可以通过下面这段程序来认识:
class Rational
{
public:
Rational (int a = 0, int b = 1 ) : m(a), n(b) {} ①
private:
int m;
int n;
};
...
void foo()
{
Rational r;
r = 100; ②
...
}
当执行②处代码时,因为Rational类并没有重载operator=(int i),所以此处编译器会合成一个operator=(const Rational& r)。并且执行逐位拷贝(bitwise copy)形式的赋值操作,但是右边的一个整型常量100并不是一个Rational对象,初看此处无法通过编译。但是,需要注意的一点是C++编译器在判定这种语句不能成功编译前,总是尽可能地查找合适的转换路径,以满足编译的需要。这里,编译器发现Rational类有一个如①处所示的Rational(int a=0, int b=1)型的构造函数。因为此构造函数可以接受0、1或2个整数作为参数,这时编译器会“贴心”地首先将②式右边的100通过调用Rational::Rational(100, 1)生成一个临时对象,然后用编译器合成的逐位拷贝形式的赋值符对r对象进行赋值。②处语句执行后,r对象内部的m为100,n为1。
从上面例子中,可以看到C++编译器为了成功编译某些语句,往往会在私底下“悄悄”地生成很多从源代码中不易察觉的辅助函数,甚至对象。比如上段代码中,编译器生成的赋值操作符、类型转换,以及类型转换的中间结果,即一个临时对象。
很多时候,这种编译器提供的自动类型转换确实提高了程序的可读性,也在一定程度上简化了程序的编写,从而提高了开发速度。但是类型转换意味着临时对象的产生,对象的创建和销毁意味着性能的下降,类型转换还意味着编译器还需要生成额外的代码等。因此在设计阶段,预计到不需要编译器提供这种自动类型转换的便利时,可以明确阻止这种自动类型转换的发生,即阻止因此而引起临时对象的产生。这种明确阻止就是通过对类的构造函数增加“explicit”声明,如上例中的代码,可以通过如下声明来阻止:
class Rational
{
public:
explicit Rational (int a = 0, int b = 1 ) : m(a), n(b) {} ①
private:
int m;
int n;
};
...
void foo()
{
Rational r;
r = 100; ②
...
}
此段代码编译时在②处报一个错误,即“binary '=' : no operator defined which takes a right-hand operand of type 'const int' (or there is no acceptable conversion)”,这个错误说明编译器无法将100转换为一个Rational对象。编译器合成的赋值运算符只接受Rational对象,而不能接受整型。编译器要想能成功编译②处语句,要么提供一个重载的“=”运算符,该运算符接受整型作为参数;要么能够将整型转换为一个Rational对象,然后进一步利用编译器合成的赋值运算符。要想将整型转换为一个Rational对象,一个办法就是提供能只传递一个整型作为参数的Rational构造函数(不一定非要求该构造函数只有一个整型参数,因为考虑到默认值的原因。如上面的例子,Rational的构造函数接受两个整型参数。但是因为都有默认值,因此调用该构造函数可以有3种方式,即无参、一个参数和两个参数),这样编译器就可以用该整型数作为参数调用该构造函数生成一个Rational对象(临时对象)。
但是上面没有重载以整型为参数的“=”操作符,虽然提供了一个能只传入一个整型作为参数的构造函数,但是用“explicit”限制了此构造函数。因为explicit的含义是开发人员只能显式地根据这个构造函数的定义调用,而不允许编译器利用其来进行隐式的类型转换。这样编译器无办法利用它来将100转换为一个临时的Rational对象,②处语句也无法编译。
上面提到,可以通过重载以整型为参数的“=”操作符使②处成功编译的目的,看这种方法:
class Rational
{
public:
explicit Rational (int a = 0, int b = 1 ) : m(a), n(b) {} ①
Rational& operator=(int a) {m=a; n=1; return *this; } ③
private:
int m;
int n;
};
...
void foo()
{
Rational r;
r = 100; ②
...
}
如③处所示,重载了“=”操作符。这样当编译②处时,编译器发现右边是一个整型数,它首先寻找是否有与之匹配的重载的“=”操作符。找到③处的声明,及定义。这样它利用③处来调用展开②处为r.Rational::operator=(100),顺利通过编译。
需要指出的是,重载“=”操作符后达到了程序想要的效果,即程序的可读性及代码编写的方便性。同时还有一个更重要的效果(对性能敏感的程序而言),即成功避免了一个临时对象的产生。因为“=”操作符的实现,仅仅是修改了被调用对象的内部成员对象,整个过程中都不需要产生临时对象。但是重载“=”操作符也增加了设计类Rational的成本,如果一个类可能会支持多种其他类型对它的转换,则需要进行多次重载,这无疑会使得这个类变得十分臃肿。同样,如果一个大型程序有很多这样的类,那么因为代码臃肿引起的维护难度也相应会增加。
因此在设计阶段,在兼顾程序的可读性、代码编写时的方便性、性能,以及程序大小和可维护性时,需要仔细分析和斟酌。尤其要对每个类在该应用程序实际运行时的调用次数及是否在性能关键路径上等情况进行预估和试验,然后做到合理的折衷和权衡。
如前所述,还有一种情形往往导致临时对象的产生,即当一个函数返回的是某个非内建类型的对象时。这时因为返回结果(一个对象)必须要有一个地方存放。所以编译器会从调用该函数的函数栈桢中开辟空间,并用返回值作为参数调用该对象所属类型的拷贝构造函数在此空间中生成该对象。在被调用函数结束并返回后,可以继续利用此对象(返回值),如:
#include <iostream>
using namespace std;
class Rational
{
friend const Rational operator+(const Rational& a, const Rational& b);
public:
Rational (int a = 0, int b = 1 ) : m(a), n(b)
{
cout << "Rational::Rational(int,int)" << endl;
}
Rational (const Rational& r) : m(r.m), n(r.n)
{
cout << "Rational::Rational(const Rational& r)" << endl;
}
Rational& operator=(const Rational& r)
{
if(this == &r)
return(*this);
m=r.m;
n=r.n;
cout << "Rational::operator=(const Rational& r)" << endl;
return *this;
}
private:
int m;
int n;
};
const Rational operator+(const Rational& a, const Rational& b)
{
cout << "operator+() begin" << endl;
Rational temp;
temp.m = a.m + b.m;
temp.n = a.n + b.n;
cout << "operator+() end" << endl;
return temp; ②
}
int main()
{
Rational r, a(10,10), b(5,8);
r = a + b; ①
return 0;
}
执行①的处语句时,相当于在main函数中调用operator+(const Rational& a, const Rational& b)函数。在main函数栈中会开辟一块Rational对象大小的空间。在operator+(const Rational& a, const Rational& b)函数的②处,函数返回被销毁的temp对象为参数调用拷贝构造函数在main函数栈中开辟的空间中生成一个Rational对象,然后在r=a+b的“=”部分执行赋值运算符操作,输出如下:
Rational::Rational(int,int)
Rational::Rational(int,int)
Rational::Rational(int,int)
operator+() begin
Rational::Rational(int,int)
operator+() end
Rational::Rational(const Rational& r)
Rational::operator=(const Rational& r)
但r在之前的默认构造后并没有用到,此时可以将其生成延迟,如下所示:
#include <iostream>
using namespace std;
class Rational
{
friend const Rational operator+(const Rational& a, const Rational& b);
public:
Rational (int a = 0, int b = 1 ) : m(a), n(b)
{
cout << "Rational::Rational(int,int)" << endl;
}
Rational (const Rational& r) : m(r.m), n(r.n)
{
cout << "Rational::Rational(const Rational& r)" << endl;
}
Rational& operator=(const Rational& r)
{
if(this == &r)
return(*this);
m=r.m;
n=r.n;
cout << "Rational::operator=(const Rational& r)" << endl;
return *this;
}
private:
int m;
int n;
};
const Rational operator+(const Rational& a, const Rational& b)
{
cout << "operator+() begin" << endl;
Rational temp;
temp.m = a.m + b.m;
temp.n = a.n + b.n;
cout << "operator+() end" << endl;
return temp; ②
}
int main()
{
Rational a(10,10), b(5,8);
Rational r = a + b; ①
return 0;
}
这时输出为:
Rational::Rational(int,int)
Rational::Rational(int,int)
operator+() begin
Rational::Rational(int,int)
operator+() end
Rational::Rational(const Rational& r)
已经发现,经过简单改写,这段程序竟然减少了一次构造函数和一次赋值操作。为什么?原来改写后,在执行①处时的行为发生了很大的变化。编译器对“=”的解释不再是赋值运算符,而是对象r的初始化。在取得a+b的结果值时,也不再需要在main函数栈桢中另外开辟空间。而是直接使用为r对象预留的空间,即编译器在执行②处时直接使用temp作为参数调用了Rational的拷贝构造函数对r对象进行初始化。这样,也消除了临时对象的生成,以及原本发生在①处的赋值运算。
通过这个简单的优化,已经消除了一个临时对象的生成,也减少了一次函数调用(赋值操作符本质上也是一个函数)。这里已经得到一个启示,即对非内建类型的对象,尽量将对象延迟到已经确切知道其有效状态时。这样可以减少临时对象的生成,如上面所示,应写为:
Rational r = a + b。
而不是:
Rational r;
…
r = a + b;
当然这里有一个前提,即在r = a + b调用之前未用到r,因此不必生成。
再进一步,已经看到在operator+(const Rational& a, const Rational& b)实现中用到了一个局部对象temp,改写如下:
#include <iostream>
using namespace std;
class Rational
{
friend const Rational operator+(const Rational& a, const Rational& b);
public:
Rational (int a = 0, int b = 1 ) : m(a), n(b)
{
cout << "Rational::Rational(int,int)" << endl;
}
Rational (const Rational& r) : m(r.m), n(r.n)
{
cout << "Rational::Rational(const Rational& r)" << endl;
}
Rational& operator=(const Rational& r)
{
if(this == &r)
return(*this);
m=r.m;
n=r.n;
cout << "Rational::operator=(const Rational& r)" << endl;
return *this;
}
private:
int m;
int n;
};
const Rational operator+(const Rational& a, const Rational& b)
{
cout << "operator+() begin" << endl;
return Rational(a.m + b.m, a.n + b.n); ②
}
int main()
{
Rational a(10,10), b(5,8);
Rational r = a + b; ①
return 0;
}
这时输出如下:
Rational::Rational(int,int)
Rational::Rational(int,int)
operator+() begin
Rational::Rational(int,int)
如上,确实消除了temp。这时编译器在进入operator+(const Rational& a, const Rational& b)时看到①处是一个初始化,而不是赋值。所以编译器传入参数时,也传入了在main函数栈桢中为对象r预留的空间地址。当执行到②处时,实际上这个构造函数就是在r对象所处的空间内进行的,即构造了r对象,这样省去了用来临时计算和存放结果的temp对象。
需要注意的是,这个做法需要与前一个优化配合才有效。即a+b的结果用来初始化一个对象,而不是对一个已经存在的对象进行赋值操作,如果①处是:
r = a + b;
那么operator+(const Rational& a, const Rational& b)的实现中虽然没有用到temp对象,但是仍然会在调用函数(这里是main函数)的栈桢中生成一个临时对象用来存放计算结果,然后利用这个临时对象对r对象进行赋值操作。
对于operator+(const Rational& a, const Rational& b)函数,常常看到有如下调用习惯:
Rational a, b;
…
a = a + b;
这种写法也经常会用下面这种写法代替:
Rational a, b;
…
a += b;
这两种写法除了个人习惯之外,在性能方面有无区别?回答是有区别。而且有时还会很大,视对象大小而定。因此设计某类时,如果需要重载operator+,最好也重载operator+=,并且考虑到维护性,operator+用operator+=来实现。这样如果这个操作符的语义有所改变需要修改时,只需要修改一处即可。
对Rational类来说,一般operator+=的实现如下:
Rational& operator+=(const Rational& rhs)
{
m += rhs.m;
n += rhs.n;
return (*this);
}
这里可以看到,与operator+不同,operator+=并没有产生临时变量,operator+则只有在返回值被用来初始化一个对象,而不是对一个已经生成的对象进行赋值时才不产生临时对象。而且往往返回值被用来赋值的情况并不少见,甚至比初始化的情况还要多。因此使用operator+=不产生临时对象,性能会比operator+要好,为此尽量使用语句:
a += b;
而避免使用:
a = a + b;
相应地,也应考虑到程序的代码可维护性(易于修改,因为不小心的修改会导致不一致等)。即尽量利用operator+=来实现operator+,如下:
const Rational operator+(const Rational& a, const Rational& b)
{
return Rational(a) += b;
}
同理,这个规律可以扩展到-=、*=和/=等。
操作符中还有两个比较特殊的,即++和--。它们都可以前置或者后置,比如i++和++i。二者的语义是有区别的,前者先将其值返回,然后其值增1;后者则是先将值增1,再返回其值。但当不需要用到其值,即单独使用时,比如:
i++;
++i;
二者的语义则是一样的,都是将原值增1。但是对于一个非内建类型,在重载这两个操作符后,单独使用在性能方面是否有差别?来考察它们的实现。仍以Rational类作为例子,假设++的语义为对分子(即m)增1,分母不变(暂且不考虑这种语义是否符合实际情况),那么两个实现如下:
const Rational& operator++() //prefix
{
++m;
return (*this);
}
const Rational operator++(int) //postfix
{
Rational tmp(*this); ①
++(*this);
return tmp;
}
可以看到,因为考虑到后置++的语义,所以在实现中必须首先保留其原来的值。为此需要一个局部变量,如①处所示。然后值增1后,将保存其原值的局部变量作为返回值返回。相比较而言,前置++的实现不会需要这样一个局部变量。而且不仅如此,前置的++只需要将自身返回即可,因此只需返回一个引用;后置++需要返回一个对象。已经知道,函数返回值为一个对象时,往往意味着需要生成一个临时对象用来存放返回值。因此如果调用后置++,意味着需要多生成两个对象,分别是函数内部的局部变量和存放返回值的临时变量。
有鉴于此,对于非内建类型,在保证程序语义正确的前提下应该多用:
++i;
而避免使用:
i++;
同样的规律也适用于前置--和后置--(与=/+=相同的理由,考虑到维护性,尽量用前置++来实现后置++)。
至此,已经考察了临时对象的含义、产生临时对象的各种场合,以及一些避免临时对象产生的方法。最后来查看临时对象的生命周期。在C++规范中定义一个临时对象的生命周期为从创建时开始,到包含创建它的最长语句执行完毕,比如:
string a, b;
const char* str;
…
if( strlen( str = (a + b).c_str() ) > 5) ①
{
printf(“%s/n”, str); ②
…
}
在①处,首先创建一个临时对象存放a+b的值。然后从这个临时string对象中通过c_str()函数得到其字符串内容,赋给str。如果str的长度大于5,就会进入if内部,执行②处语句。问题是,这时的str还合法否?
答案是否定的,因为存放a+b值的临时对象的生命在包含其创建的最长语句结束后也相应结束了,这里是①处语句。当执行到②处时,该临时对象已经不存在,指向它内部字符串内容的str指向的是一段已经被回收的内存。这时的结果是无法预测的,但肯定不是所期望的。
但这条规范也有一个特例,当用一个临时对象来初始化一个常量引用时,该临时对象的生命会持续到与绑定到其上的常量引用销毁时,如:
string a, b;
…
if( …)
{
const string& c = a + b ①
cout << c << endl; ②
…
}
这时c这个常量string引用在①处绑定在存放a+b结果的临时对象后,可以继续在其使用域(scope)内正常使用,如在②处语句中那样。这是因为c是一个常量引用,因为被它绑定。所以存放a+b的临时对象并不会在①处语句执行后销毁,而是保持与c一样的生命周期。