C++应用程序性能优化学习笔记:C++语言特性的性能分析

一、构造函数与析构函数

需要注意以下几点:

①构造函数首先根据初始化列表执行初始化,顺序严格按照成员变量在类中的声明顺序进行,而与其在初始化列表中出现的顺序完全无关,然后执行构造函数的函数体,所有的成员变量在执行函数体之前就已经被构造

②如果在构造函数时已经知道如何为类的子成员变量初始化,那么应该将这些初始化信息通过构造函数的初始化列表赋予子成员变量,而不是在构造函数体中进行初始化。因为进入构造函数体时,这些子成员变量已经初始化了一次。

③子类在构造时总是在其构造函数的“初始化”操作的最开始构造其父类对象,而忽略其父类构造函数是够显式地列在初始化列表中

④C++程序中,创建/销毁对象是影响性能的一个突出操作,减少对象创建/销毁的一个很简单的方法就是在函数声明中将所有的值传递改为常量引用传递,比如

int foo(Qbject a); 改为 int foo(const Object& a);

二、继承与虚拟函数

虚拟函数的“动态绑定”特性虽然好,但是也有内在的空间和时间开销。每个支持虚拟函数的类(基类或派生类)都会有一个包含其所有支持的虚拟函数的虚拟函数表,另外每个该类生成的对象都会隐含一个“虚拟函数指针”,此指针指向其所属类的虚拟函数表。当通过基类或派生类的指针或引用调用某个虚拟函数时,系统需要首先定位这个指针或引用真正对应的“对象”所隐含的虚拟函数指针,虚拟函数指针再根据这个虚拟函数的名称,对虚拟函数指针指向的虚拟函数表进行一个偏移定位,最后调用这个偏移定位处的函数指针对应的虚拟函数。由此得出虚拟函数的开销:

空间开销:

每个支持虚拟函数的类都有一个虚拟函数表,这个表的大小跟类所拥有的虚拟函数的多少成正比,此虚拟函数表对于一个类来说,整个程序只有一个,而无论该类生成的对象在程序运行时会生成多少个

通过支持虚拟函数的类生成的每个对象都有一个指向该类对应的虚拟函数表的虚拟函数指针,无论该类的虚拟函数有多少个,都只有一个函数指针,但是因为与对象绑定,因此程序运行时因为虚拟函数指针引起的空间开销跟生成的对象个数成正比

时间开销:当生成支持虚拟函数的类的每个对象时,在构造函数中会调用编译器在构造函数内部插入初始化代码,来初始化其虚拟函数指针,使其指向正确的虚拟函数表。当通过指针或引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作

三、临时对象

一般有两种场合产生临时对象:

当实际调用函数时传入的参数与函数定义中声明的变量类型不匹配

class Rational{
public:
	Rational(int a=0,int b=1):m(a),n(b){}

	int m;
	int n;
};

void main(void){
	Rational r;
	r=100;//因为Rational类并没有重载operator=(int i),所以此处编译器会合成一个operator=(const Rational& r)。编译器通过Rational::Rational(100,1)生成一个临时对象,再用编译器合成的逐位拷贝形式的赋值符
	while(1);
}
解决办法:

class Rational{
public:
	<strong>explicit</strong> Rational(int a=0,int b=1):m(a),n(b){}//explicit的含义是只能显示地根据这个构造函数的定义调用,而不允许编译器利用其来进行隐式的类型转换
	<strong>Rational& operator=(int a){m=a;n=1;return *this;}</strong>
	int m;
	int n;
};

当函数返回一个对象时

情况1

class Rational{
	friend const Rational operator+(const Rational& a,const Rational& b);
public:
	Rational (int a=0,int b=1):m(a),n(b){}
	Rational (const Rational& r):m(r.m),n(r.n){}
	Rational& operator=(const Rational& r){...}
private:
	int m;
	int n;
};

const Rational operator+(const Rational& a,const Rational& b){Rational temp;...;return temp;}

void main(void){
	Rational r,a(10,11),b(5,8);
	r=a+b;//返回被销毁的temp为参数,调用拷贝构造函数在main函数栈中开辟的空间中生成一个Rational对象,再赋值给r
	while(1);
}
解决办法:

const Rational operator+(const Rational& a,const Rational& b){...return Rational(a.m+b.m,a.n+b.n);}

void main(void){
	Rational a(10,11),b(5,8);
	Rational r=a+b;//这里为初始化,不是赋值。编译器进入operator+(const Rational& a,const Rational& b)并传入参数时,也传入了在main函数栈中为对象r预留的空间地址。此处的构造函数就是在r对象所处的空间内进行的,省去了用来临时计算和存放结果的temp对象
	while(1);
}
情况2

对于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+,如下:

const Rational operator(const Rational& a,const Rational& b)
{
   return Rational(a)+=b;
}
同理,这个规律可以扩展到-=、*=、/=等
情况3

const Rational& operator++()//前置++
{
   ++m;
   return (*this);
}

const Rational operator++(int)//后置++
{
   Rational tmp(*this);
   ++(*this);
   return tmp;
}

函数返回值为一个对象时,往往意味着需要生成一个临时对象用来存放返回值。因此如果调用后置++,意味着需要多生成两个对象,一个局部变量用于保留变量原来的值,一个临时变量用于存放返回值。所以应该尽量使用前置++。此规律同样适用于前置--和后置--
情况4

string a("hehe"),b("haha");
const char* str;
str=(a+b).c_str();
if (strlen(str=(a+b).c_str())>5)//存放a+b值的临时对象的生命在包含其创建的最长语句结束后也相应结束了。一定要使用strcpy()函数等来操作方法c_str()返回的指针,因为c_str()返回的指针是临时指针
{
   printf("%s\n",str);//此时str所指向的是一段已经被回收的内存,所以不能正确输出
}
当用一个临时对象来初始化一个 常量引用时,该临时对象的生命会持续到与绑定到其上的常用引用销毁时,如

string a("hehe"),b("haha");
if (1)
{
   const string& c=a+b;//常量c绑定存放a+b结果的临时对象,所以a+b的临时对象保持与c一样的生命周期
   printf("%s\n",c);
}

四、内联函数

class Student
{
   public:
        String GetName() {return name;}//内联函数使用方法一:在类的定义体内声明该成员函数并提供实现体
        int GetAge();
        void SetAge(int ag);
        .....
   private:
        String name;
        int age;
        ......
}

inline int GetAge()//内联函数使用方法二:在类的定义体外,需要在该成员函数的定义前面加“inline”关键字,显示地告诉编译器该函数在调用时需要“内联”处理
{
   return age;
}

inline void SetAge(int ag)
{
   age=ag;
}

inline int DoSomeMagic(int a,int b)//内联函数使用方法三:当普通函数(非类成员函数)需要被内联时,则只需要在函数的定义前面加上“inline”关键字
{
   return a*13+b%4+3;
}
以foo函数为例,内联过程如下:
内联前:

void foo()
{
   ...
   Student abc;
   abc.SetAge(12);
   cout<<abc.GetAge();
   ...
}
内联后大致如下:
void foo()
{
   ...
   Student abc;
   {
        abc.age=12;
   }
   int tmp=abc.age;
   cout<<temp;
   ...
}
编译器进一步优化后大致如下:

void foo()
{
   ...
   cout<<12;
   ...
}
如果没有内联,编译器在编译这个单元时不知道这两个函数的代码也就无法做出最后的优化。由此内联函数的优点有:减少函数调用开销、内联后编译器在处理调用内联函数的函数时,可供分析的代码更多因而能做出更深入的优化

内联效果分析:如果调用函数a的准备工作和善后工作所需的空间为SS,执行这些代码所需的时间为TS,函数a的体代码大小为AS,假设a函数在整个程序中被调用了n次

空间上:不采用内联时,与a函数相关的代码大小为:n*SS+AS。采用内联后,与a函数相关的代码大小为:n*AS。由此得出结论,n比较大时,可简化成SS和AS的大小比较(这里没有考虑内联的后续情况,即编译器可能因为获得的信息更多,从而对调用函数的优化做得更深入和彻底致使最终的代码量变得更小)

时间上:一般来说内联后的执行时间比没有内联要少。但是当AS远大于SS且n非常大时,最终程序的大小会比没有内存时要大很多,代码量大意味着用来存放代码的内存页更多,因此执行代码而引起的“缺页”也会相应增加,最终程序的执行时间可能会因为大量的“缺页”而变得更多,程序变慢。所以很多编译器对于函数体代码很多的代码,会拒绝内联请求

内联注意事项:

①在一个大型程序中,一旦一个通用的内联函数修改了,那么所有用到这个内联函数的编译单元都必须重新编译,这将耗费大量时间。如果没有内联,则只需修改实现,就可以不必重新都编译即可利用新的版本。所以内联最好在开发的后期引入

②内联的条件是编译器能够知道该处函数调用的函数体。比如要对递归函数进行内联,如果编译器不知道递归具体次数就会拒绝内联。比如要对虚拟函数进行内联,如果编译器不知道对象的确切类型也会拒绝内联

③与宏的区别:内联是编译期行为,宏是预处理期行为i,其替代展开由预处理器来做。预处理器不会也不能对宏的参数进行类型检查,而内联因为是编译器处理的,因此会对内联函数的参数进行类型检查。宏肯定会被展开,而用inline关键字修饰的函数不一定会被内联展开

④一个程序的唯一入口main()函数肯定不会被内联化

⑤编译器合成的默认构造函数、拷贝构造函数、析构函数,以及赋值运算符一般都会被内联化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值