C++应用程序性能优化(三)——C++语言特性性能分析(1)

C++应用程序性能优化(三)——C++语言特性性能分析

一、C++语言特性性能分析简介

通常大多数开发人员认为,汇编语言和C语言比较适合编写对性能要求非常高的程序,C++语言主要适用于编写复杂度非常高但性能要求并不是很高的程序。因为大多数开发人员认为,C++语言设计时因为考虑到支持多种编程模式(如面向对象编程和范型编程)以及异常处理等,从而引入了太多新的语言特性。新的语言特性往往使得C++编译器在编译程序时插入了很多额外的代码,会导致最终生成的二进制代码体积膨胀,而且执行速度下降。

但事实并非如此,通常一个程序的速度在框架设计完成时大致已经确定,而并非因为采用C++语言才导致速度没有达到预期目标。因此,当一个程序的性能需要提高时,首先需要做的是用性能检测工具对其运行的时间分布进行一个准确的测量,找出关键路径和真正的性能瓶颈所在,然后针对性能瓶颈进行分析和优化,而不是主观地将性能问题归咎于程序所采用的语言。工程实践表明,如果框架设计不做修改,即使使用C语言或汇编语言重新改写,也并不能保证提高总体性能。

因此,遇到性能问题时,首先应检查和反思程序的总体架构,然后使用性能检测工具对其实际运行做准确的测量,再针对性能瓶颈进行分析和优化。

如果有想学习c++的程序员,可来我们的C/C++学习扣qun:589348389,
免费送C++的视频教程噢!
我每晚上8点还会在群内直播讲解C/C++知识,欢迎大家前来学习哦。
 

但C++语言中确实有一些操作、特性比其它因素更容易成为程序的性能瓶颈,常见因素如下:

(1)缺页

缺页通常意味着要访问外部存储,因为外部存储访问相对于访问内存或代码执行,有数量级的差别。因此,只要有可能,应该尽量想办法减少缺页。

(2)从堆中动态申请和释放内存

C语言中的malloc/free和C++语言中的new/delete操作时非常耗时的,因此要尽可能优先考虑从线程栈中获取内存。优先考虑栈而减少从动态堆中申请内存,不仅因为在堆中分配内存比在栈中要慢很多,而且还与尽量减少缺页有关。当程序执行时,当前栈帧空间所在的内存页肯定在物理内存中,因此程序代码对其中变量的存取不会引起缺页;如果从堆空间生成对象,只有指向对象的指针在栈上,对象本身则存储在堆空间中。堆一般不可能都在物理内存中,而且由于堆分配内存的特性,即使两个相邻生成的对象,也很有可能在堆内存位置上相距很远。因此,当访问两个对象时,虽然分别指向两个对象的指针都在栈上,但通过两个指针引用对象时很有可能会引起两次缺页。

(3)复杂对象的创建和销毁

复杂对象的创建和销毁会比较耗时,因此对于层次较深的递归调用需要重点关注递归内部的对象创建。其次,编译器生成的临时对象因为在程序的源码中看不到,更不容易察觉,因此需要重点关注。

(4)函数调用

由于函数调用有固定的额外开销,因此当函数体的代码量相对较少,并且函数被非常频繁调用时,函数调用时的固定开销容易成为不必要的开销。C语言的宏和C++语言的内联函数都是为了在保持函数调用的模块化特征基础上消除函数调用的固定额外开销而引入的。由于C语言的宏在×××能优势的同时也给开发和调试带来不便,因此C++语言中推荐使用内联函数。

二、构造函数与析构函数

1、构造函数与析构函数简介

构造函数和析构函数的特点是当创建对象时自动执行构造函数;当销毁对象时,析构函数自动被执行。构造函数是一个对象最先被执行的函数,在创建对象时调用,用于初始化对象的初始状态和取得对象被使用前需要的一些资源,如文件、网络连接等;析构函数是一个对象最后被执行的函数,用于释放对象拥有的资源。在对象的生命周期内,构造函数和析构函数都只会执行一次。

创建一个对象有两种方式,一种是从线程运行栈中创建,称为局部对象。销毁局部对象并不需要程序显示地调用析构函数,而是当程序运行出对象所属的作用域时自动调用对象的析构函数。

创建对象的另一种方式是从全局堆中动态分配,通常使用new或malloc分配堆空间。

Obejct* p = new Object();//1
// do something //2
delete p;//3
p = NULL;//4

执行语句1时,指针p所指向对象的内存从全局堆空间中获得,并将地址赋值给p,p本身是一个局部变量,需要从线程栈中分配,p所指向对象从全局堆中分配内存存放。从全局堆中创建的对象需要显示调用delete进行销毁,delete会调用指针p指向对象的析构函数,并将对象所占的全局堆内存空间返回给全局堆。执行语句3后,指针p指向的对象被销毁,但指针p还存在于栈中,直到程序退出其所在作用域。将p指针所指向对象销毁后,p指针仍指向被销毁对象的全局堆空间位置,此时指针p变成一个悬空指针,此时使用指针p是危险的,通常推荐将p赋值NULL。

在Win32平台,访问销毁对象的全局堆空间内存会导致三种情况:

(1)被销毁对象所在的内存页没有任何对象,堆管理器已经将所占堆空间进一步回收给操作系统,此时通过指针访问会引起访问违例,即访问了不合法内存,引起进程崩溃。

(2)被销毁对象所在的内存页存在其它对象,并且被销毁对象曾经占用的全局堆空间被回收后尚未分配给其它对象,此时通过指针p访问取得的值是无意义的,虽然不会立刻引起进程崩溃,但针对指针p的后续操作行为是不可预测的。

(3)被销毁对象所在的内存页存在其它对象,并且被销毁对象曾经占用的全局堆空间被回收后已经分配给其它对象,此时通过指针p取得的值是其它对象,虽然对指针p的访问不会引起进程崩溃,但极有可能引起对象状态的改变。

2、对象的构造过程

创建一个对象分为两个步骤,即首先取得对象所需的内存(从线程栈或全局堆),然后在内存空间上执行构造函数。在构造函数构建对象时,构造函数也分为两个步骤。第一步执行初始化(通过初始化参数列表),第二步执行构造函数的函数体。

class Derived : public Base
{
public:
    Derived(): id(1), name("UnNamed")   // 1
    {
        // do something     // 2
    }
private:
    int id;
    string name;
};

语句1中冒号后的代码即为初始化列表,每个初始化单元都是变量名(值)的模式,不同单元之间使用逗号分隔。构造函数首先根据初始化列表执行初始化,然后执行构造函数的函数体(语句2)。初始化操作的注意事项如下:

(1)构造函数其实是一个递归操作,在每层递归内部的操作遵循严格的次序。递归模式会首先执行父类的构造函数(父类的构造函数操作也相应包含执行初始化和执行构造函数函数体两个部分),父类构造函数返回后构造类自己的成员变量。构造类自己的成员变量时,一是严格按照成员变量在类中的声明顺序进行,与成员变量在初始化列表中出现的顺序完全无关;二是当有些成员变量或父类对象没有在初始化列表出现时,仍然在初始化操作中对其进行初始化,內建类型成员变量被赋值给一个初值,父类对象和类成员变量对象被调用其默认构造函数初始化,然后父类的构造函数和子成员变量对象在构造函数执行过程中也遵循上述递归操作,直到类的继承体系中所有父类和父类所含的成员变量都被构造完成,类的初始化操作才完成。

(2)父类对象和一些成员变量没有出现在初始化列表中时,其仍然会被执行默认构造函数。因此,相应对象所属类必须提供可以调用的默认构造函数,为此要求相应的类必须显式提供默认构造函数,要么不能阻止编译器隐式生成默认构造函数,定义除默认构造函数外的其它类型的构造函数将会阻止编译器生成默认构造函数。如果编译器在编译时,发现没有可供调用的默认构造函数,并且编译器也无法生成默认构造函数,则编译无法通过。

(3)对两类成员变量,需要强调指出(即常量型和引用型)。由于所有成员变量在执行函数体前已经被构造,即已经拥有初始值,因此,对于常量型和引用型变量必须在初始化列表中正确初始化,而不能将其初始化放在构造函数体内。

(4)初始化列表可能没有完全列出其子成员或父类对象成员,或者顺序与其在类中的声明顺序不同,仍然会保证严格被全部并且严格按照顺序被构建。即程序在进入构造函数体前,类的父类对象和所有子成员变量对象已经被生成和构造。如果在构造函数体内为其执行赋值操作,显然属于浪费。如果在构造函数时已经知道如何为类的子成员变量初始化,则应该将初始化信息通过构造函数的初始化列表赋予子成员变量,而不是在构造函数体内进行初始化,因为进入构造函数时,子成员变量已经初始化一次。

3、对象的析构过程

析构函数和构造函数一样,是递归的过程,但存在不同。一是析构函数不存在初始化操作部分,析构函数的主要工作就是执行析构函数的函数体;二是析构函数执行的递归与构造函数相反,在每一层递归中,成员变量对象的析构顺序也与构造函数相反。

析构函数只能选择类的成员变量在类中声明的顺序作为析构的顺序参考(正序或逆序)。因为构造函数选择了正序,而析构函数的工作与构造函数相反,因此析构函数选择逆序。又因为析构函数只能使用成员变量在类中的声明顺序作为析构顺序的依据(正序或逆序),因此构造函数也只能选择成员变量在类中的声明顺序作为构造的顺序依据,而不能采用初始化列表的顺序作为顺序依据。

如果操作的对象属于一个复杂继承体系的末端节点,其析构过程也将十分耗时。

在C++程序中,创建和销毁对象是影响性能的一个非常突出的操作。首先,如果是从全局堆空间中生成对象,则需要先进行动态内存分配操作,而动态内存的分配与回收是非常耗时的操作,因为涉及到寻找匹配大小的内存块,找到后可能还需要截断处理,然后还需要修改维护全局堆内存使用情况信息的链表。频繁的内存操作会严重影响性能的下降,使用内存池技术可以减少从全局动态堆空间申请内存的次数,提高程序的总体性能。当取得内存后,如果需要生成的内对象属于复杂继承体系的末端类,则构造函数的调用将会引起一连串的递归构造操作,在大型复杂系统中,大量的此类对象构造将会消耗CPU操作的主要部分。

由于对象的创建和销毁会影响性能,在尽量减少自己代码生成对象的同时,需要关注编译器在编译时临时生成的对象,尽量避免临时对象的生成。

如果在实现构造函数时,在构造函数体中进行了第二次的赋值操作,也会浪费CPU时间。

4、函数参数传递

减少对象创建和销毁的常见方法是在声明中将所有的值传递改为常量引用传递,如:

int func(Object obj);// 1
int func(const Object& obj);// 2

值传递验证示例如下:

#include <iostream>

using namespace std;

class Object
{
public:
    Object(int i = 1)
    {
        n = i;
        cout << "Object(int i = 1): " << endl;
    }
    Object(const Object& another)
    {
        n = another.n;
        cout << "Object(const Object& another): " << endl;
    }
    void increase()
    {
        n++;
    }
    int value()const
    {
        return n;
    }
    ~Object()
    {
        cout << "~Object()" << endl;
    }
private:
    int n;
};
void func(Object obj)
{
    cout << "enter func, before increase(), n = " << obj.value() << endl;
    obj.increase();
    cout << "enter func, after increase(), n = " << obj.value() << endl;
}
int main()
{
    Object a;   // 1
    cout << "before call func, n = " << a.value() << endl;
    func(a);    // 2
    cout << "after call func, n = " << a.value() << endl;// 3

    return 0;
}
// output:
//Object(int i = 1):        // 4
//before call func, n = 1
//Object(const Object& another):    // 5
//enter func, before increase(), n = 1  // 6
//enter func, after increase(), n = 2   // 7
//~Object() // 8
//after call func, n = 1    // 9
//~Object()

语句4的输出为语句1处的对象构造,语句5输出则是语句2处的func(a)函数调用,调用开始时通过拷贝构造函数生成对象a的复制品,紧跟着在函数内检查n的输出值输出语句6,输出值与func函数外部元对象a的值相同,然后复制品调用increase函数将n值加1,此时复制品的n值为2,并输出语句7。func函数执行完毕后销毁复制品,输出语句8。main函数内继续执行,打印原对象a的n值为1,输出语句9。

当函数需要修改传入参数时,应该用引用传入参数;当函数不会修改传入参数时,如果函数声明中传入参数为对象,则函数可以达到设计目的,但会生成不必要的复制品对象,从而引入不必要的构造和析构操作,应该使用常量引用传入参数。

构造函数的重复赋值对性能影响验证示例如下:

#include <iostream>
#include <time.h>

using namespace std;

class DArray
{
public:
    DArray(double v = 1.0)
    {
        for(int i = 0; i < 1000; i++)
        {
            d[i] = v + i;
        }
    }
    void init(double v = 1.0)
    {
        for(int i = 0; i < 1000; i++)
        {
            d[i] = v + i;
        }
    }
private:
    double d[1000];
};

class Object
{
public:
    Object(double v)
    {
        d.init(v);
    }
private:
    DArray d;
};

int main()
{
    clock_t start, finish;
    start = clock();
    for(int i = 0; i < 100000; i++)
    {
        Object obj(2.0 + i);
    }
    finish = clock();
    cout << "Used Time: " << double(finish - start) << "" << endl;

    return 0;
}

耗时为600000单位,如果通过初始化列表对成员变量进行初始化,其代码如下:

#include <iostream>
#include <time.h>

using namespace std;

class DArray
{
public:
    DArray(double v = 1.0)
    {
        for(int i = 0; i < 1000; i++)
        {
            d[i] = v + i;
        }
    }
    void init(double v = 1.0)
    {
        for(int i = 0; i < 1000; i++)
        {
            d[i] = v + i;
        }
    }
private:
    double d[1000];
};

class Object
{
public:
    Object(double v): d(v)
    {
    }
private:
    DArray d;
};

int main()
{
    clock_t start, finish;
    start = clock();
    for(int i = 0; i < 100000; i++)
    {
        Object obj(2.0 + i);
    }
    finish = clock();
    cout << "Used Time: " << double(finish - start) << "" << endl;

    return 0;
}

耗时为300000单位,性能提高约50%。

三、继承与虚函数

1、虚函数与动态绑定机制

虚函数是C++语言引入的一个重要特性,提供了动态绑定机制,动态绑定机制使得类继承的语义变得相对明晰。

(1)基类抽象了通用的数据及操作。对于数据而言,如果数据成员在各个派生类中都需要用到,需要将其声明在基类中;对于操作而语言,如果操作对于各个派生类都有意义,无论其语义是否会被修改和扩展,需要将其声明在基类中。

(2)某些操作,对于各个派生类而言,语义完全保持一致,而无需修改和扩展,则相应操作声明为基类的非虚成员函数。各个派生类在声明为基类的派生类时,默认继承非虚成员函数的声明和实现,如果默认继承基类的数据成员一样,而不必另外做任何声明,构成代码复用。

(3)对于某些操作,虽然对于各个派生类都有意义,但其语义并不相同,则相应的操作应该声明为虚成员函数。各个派生类虽然也继承了虚成员函数的声明和实现,但语义上应该对虚成员函数的实现进行修改或扩展。如果在实现修改、扩展虚成员函数的过程中,需要用到额外的派生类独有的数据时,则将相应的数据声明为派生类自己的数据成员。

当更高层次的程序框架(继承体系的使用者)使用此继承体系时,处理的是抽象层次的对象集合,对象集合的成员本质是各种派生类对象,但在处理对象集合的对象时,使用的是抽象层次的操作。高层程序框架并不区分相应操作中哪些操作对于派生类是不变的,哪些操作对于派生类是不同的,当实际执行到各操作时,运行时系统能够识别哪些操作需要用到动态绑定。从而找到对应派生类的修改或扩展的操作版本。即对继承体系的使用者而言,继承体系内部的多样性是透明的,不必关心其继承细节,处理的是一组对使用者而言整体行为一致的对象。即使继承体系内部增加、删除了某个派生类,或某个派生类的虚函数实现发生了改变,使用者的代码也不必做任何修改,使程序的模块化程度得到极大提高,其扩展性、维护性和代码可读性也会提高。对于对象继承体系使用者而言,只看到抽象类型,而不必关心具体是哪种具体类型。

如果有想学习c++的程序员,可来我们的C/C++学习扣qun:589348389,
免费送C++的视频教程噢!
我每晚上8点还会在群内直播讲解C/C++知识,欢迎大家前来学习哦。
 

猜你喜欢

转载自blog.csdn.net/XZQ121963/article/details/91431758