成都创新互联网站制作重庆分公司

C++应用程序性能优化(二)——C++对象模型

C++应用程序性能优化(二)——C++对象模型

一、C++对象模型与性能优化

对象模型是面向对象程序设计语言的重要方面,会直接影响面向对象语言编写程序的运行机制以及对内存的使用机制,因此了解对象模型是进行程序性能优化的基础。只有深入理解C++对象模型,才能避免程序开发过程中一些不易发现的内存错误,从而改善程序性能,提高程序质量。

创新互联公司是一家专业提供漠河企业网站建设,专注与做网站、成都网站制作H5响应式网站、小程序制作等业务。10年已为漠河众多企业、政府机构等服务。创新互联专业网站建设公司优惠进行中。

二、C++程序的内存分布

1、程序内存分布简介

通常,计算机程序由代码和数据组成,因此代码和数据也是影响程序所需内存的主要因素。代码是程序运行的指令,比如数学运算、比较、跳转以及函数调用,其大小通常由程序的功能和复杂度决定,正确地使用程序编写技巧以及编程语言的特性可以优化所生成的代码的大小;数据是代码要处理的对象。
程序占用的内存区通常分为五种:全局/静态数据区、常量数据区、代码区、栈、堆。
程序的代码存储在代码区中,而程序的数据则根据数据种类的不同存储在不同的内存区中。C++语言中,数据有不同的分类方法,例如常量和变量,全局数据和局部数据,静态数据和非静态数据。此外,程序运行过程中动态产生和释放的数据也要存放在不同的内存区。
不同内存区存储的数据如下:
(1)全局/静态数据区存储全局变量以及静态变量(包括全局静态变量和局部静态变量)。
(2)常量数据区存储程序中的常量字符串等。
(3)栈中存储自动变量或者局部变量,以及传递函数参数等,而堆是用户程序控制的存储区,存储动态产生的数据。
不同类型的数据在内存存储位置的示例如下:

#include 
#include 

using namespace std;

int g_GolbalVariable = 100;

int main()
{
    int localVariable = 1;
    static int staticLocalVariable = 200;
    const int constLocalVariable = 100;
    char* pLocalString1 = "pLocalString1";
    const char* pLocalString2 = "pLocalString2";
    int* pNew = new int[5]; // 16字节对齐
    char* pMalloc = (char*)malloc(1);

    printf( "GolbalVariable: 0x%x\n", &g_GolbalVariable);
    printf( "Static Variable: 0x%x\n", &staticLocalVariable);
    printf( "LocalString1: 0x%x\n", pLocalString1);
    printf( "const LocalString2: 0x%x\n", pLocalString2);
    printf( "const LocalVariable: 0x%x\n", &constLocalVariable);

    printf( "New: 0x%x\n", pNew);
    printf( "Malloc: 0x%x\n", pMalloc);

    printf( "LocalVariable: 0x%x\n", &localVariable);

    return 0;
}

上述代码定义了8个变量,一个全局变量GolbalVariable,一个静态局部变量staticLocalVariable,六个局部变量。在RHEL 7.3系统使用GCC编译器编译运行,程序输出结果如下:

GolbalVariable: 0x60105c
Static Variable: 0x601060
LocalString1: 0x4009a0
const LocalString2: 0x4009ae
const LocalVariable: 0xdbd23ef8
New: 0x182b010
Malloc: 0x182b030
LocalVariable: 0xdbd23efc

全局变量、静态变量和局部静态变量存储在全局/静态数据区。
字符串常量存储在常量数据区,pLocalString1指向的字符串"pLocalString1"的长度是13字节,加上结束符’\0’,共计14个字节,存储在0x4009a0开始的14个字节内存空间;存储pLocalString2的字符串"pLocalString2"时,从0x4009ae地址开始,因此,没有进行内存对齐处理。程序中的其它字符串常量,如printf中的格式化串通常也存储在常量数据区。
通过new、malloc获得的内存是堆的内存。通过new申请5个int所需的内存,但由于内存边界需要字节对齐(堆上分配内存时按16字节对齐),因此申请5个int共计20个字节,但占据32字节的内存。通过malloc申请1个字节的内存,申请1个字节时会从32字节后开始分配。
内存对齐虽然会浪费部分内存,但由于CPU在对齐方式下运行较快,因此内存对齐对于程序性能是有益的。C++语言中struct、union、class在编译时也会对成员变量进行内存对齐处理,开发人员可以使用#progma pack()或者编译器的编译选项来控制对struct、union、class的成员变量按多少字节对齐,或者关闭对齐。

2、全局/静态数据区、常量数据区

全局/静态存储区、常量数据区在程序编译阶段已经分配好,在整个程序运行过程中始终存在,用于存储全局变量、静态变量,以及字符串常量等。其中字符串常量存储的区域是不可修改的内存区域,试图修改字符串常量会导致程序异常退出。

char* pLocalString1 = "hello world";
pLocalString1[0] = 'H';// 试图修改不可修改的内存区

全局/静态数据区除了全局变量,还有静态变量。C语言中可以定义静态变量,静态变量在第一次进入作用域时被初始化,后续再次进入作用域时不必初始化。C++语言中,可以定义静态变量,也可以定义类的静态成员变量,类的静态成员变量用来在类的多个对象间共享数据。类的静态成员变量存储在全局/静态数据区,并且只有一份拷贝,由类的所有对象共享。如果通过全局变量在类的多个对象间共享数据则会破坏类的封装性。

#include 
#include 

class A
{
public:
    int value;
    static int nCounter;
    A()
    {
        nCounter++;
    }
    ~A()
    {
        nCounter--;
    }
};
int A::nCounter = 0;

int main()
{
    A a;
    A b;
    printf("number of A: %d\n", A::nCounter);
    printf("non-static class member: 0x%x\n", &a.value);
    printf("non-static class member: 0x%x\n", &b.value);
    printf("static class member: 0x%x\n", &a.nCounter);
    printf("static class member: 0x%x\n", &b.nCounter);

    return 0;
}

上述代码,类A定义了一个静态成员变量nCounter用于对类A的对象进行计数,类A也定义了一个成员变量value,在RHEL 7.3系统使用GCC编译器编译运行,程序输出结果如下:

number of A: 2
non-static class member: 0x99a457c0
non-static class member: 0x99a457b0
static class member: 0x601048
static class member: 0x601048

对象a和对象b中的value成员变量的地址不同,而静态成员变量nCounter的地址相同。类A的每一个对象会有自己的value存储空间,在栈上分配;类A的所有对象共享一个nCounter的存储空间,在全局/静态数据区分配。

3、堆和栈

在C/C++语言中,当开发人员在函数内部定义一个变量,或者向某个函数传递参数时,变量和参数存储在栈中。当退出变量的作用域时,栈上的存储单元会被自动释放。当开发人员通过malloc申请一块内存或使用new创建一个对象时,申请的内存或对象所占的内存在堆上分配。开发人员需要记录得到的地址,并在不再需要时负责释放内存空间。

#include 
#include 

using namespace std;

int g_GolbalVariable = 100;

int main()
{
    int localVariable = 1;
    static int staticLocalVariable = 200;
    const int constLocalVariable = 100;
    char* pLocalString1 = "pLocalString1";
    const char* pLocalString2 = "pLocalString2";
    int* pNew = new int[5]; // 16字节对齐
    char* pMalloc = (char*)malloc(1);

    printf( "GolbalVariable: 0x%x\n", &g_GolbalVariable);
    printf( "Static Variable: 0x%x\n", &staticLocalVariable);
    printf( "LocalString1: 0x%x\n", pLocalString1);
    printf( "const LocalString2: 0x%x\n", pLocalString2);
    printf( "const LocalVariable: 0x%x\n", &constLocalVariable);

    printf( "New: 0x%x\n", pNew);
    printf( "Malloc: 0x%x\n", pMalloc);

    printf( "LocalVariable: 0x%x\n", &localVariable);

    return 0;
}

上述代码中,通过new在堆上申请5个int的所需的内存空间,将获得的地址记录在栈上的变量pNew中;通过malloc在堆上申请1字节的内存空间,将获得的地址记录在栈上的变量pMalloc中。

int* pNew = new int[5]; // 16字节对齐
char* pMalloc = (char*)malloc(1);

在main函数结束时,pNew和pMalloc自身是栈上的内存单元,会被自动释放,但pNew和pMalloc所指向的内存是堆上的,虽然指向堆空间的pNew和pMalloc指针变量已经不存在,但相应的堆空间内存不会被自动释放,造成内存泄露。通过new申请的堆内存空间需要使用delete进行释放,使用malloc获得的堆空间内存需要使用free进行释放。
既然栈上的内存空间不存内存泄露的问题,而堆上的内存容易引起内存泄露,为什么要使用堆上的内存呢?因为很多应用程序需要动态管理地管理数据。此外,栈的大小有限制,占用内存较多的对象或数据只能分配在堆空间。
栈和堆的区别如下:
(1)大小
通常,程序使用栈的大小是固定的,由编译器决定,开发人员可以通过编译器选项指定栈的大小,但通常栈都不会太大。

#include 
#include 

int main()
{
    char buf[8 * 1024 * 1024];
    printf("%x\n", buf);

    return 0;
}

RHEL 7.3系统中默认的栈大小为8MB,在RHEL 7.3系统使用GCC编译器编译运行,程序会运行时出错,原因是栈溢出。
堆的大小通常只受限于系统有效的虚拟内存的大小,因此可以用来分配创建一些占用内存较大的对象或数据。
(2)效率
栈上的内存是系统自动分配的,压栈和出栈都有相应的指令进行操作,因此效率较高,并且分配的内存空间是连续的,不会产生内存碎片;堆上的内存是由开发人员来动态分配和回收的。当开发人员通过new或malloc申请堆上的内存空间时,系统需要按照一定的算法在堆空间中寻找合适大小的空闲堆,并修改相应的维护堆空闲空间的链表,然后返回地址给程序。因此,效率币栈要低,此外还容易产生内存碎片。
如果程序在堆上申请5个100字节大小的内存块,然后释放其中不连续的两个内存块,此时当需要在堆上申请一个150字节大小的内存块时,则无法充分利用刚刚释放的两个小内存块。由此可见,连续创建和删除占用内存较小的对象或数据时,很容易在堆上造成内存碎片,使得内存的使用效率降低。

4、C++对象创建方式

从C++对象模型角度看,对象就是内存中的一块区域。根据C++标准,一个对象可以通过定义变量创建,或者通过new操作符创建,或者通过实现来创建。如果一个对象通过定义在某个函数内的变量或者需要的临时变量来创建,是栈上的一个对象;如果一个对象是定义在全局范围内的变量,则对象存储在全局/静态数据区;如果一个对象通过new操作符创建,存储在堆空间。
对面向对象的C++程序设计,程序运行过程中的大部分数据应该封装在对象中,而程序的行为也由对象的行为决定。因此,深入理解C++对象的内部结构,从而正确地设计和使用对象,对于设计开发高性能的C++程序很重要。

三、C++对象的生命周期

1、C++对象生命周期简介

对象的生命周期是指对象从创建到销毁的过程,创建对象时要占用一定的内存空间,而对象要销毁后要释放对应的内存空间,因此整个程序占用的内存空间也会随着对象的创建和销毁而动态的发生变化。深入理解对象的生命周期会帮助分析程序对内存的消耗情况,从而找到改进方法。
对象的创建有三种方式,不同方式所创建对象的生命周期各有不同,创建对象的三种方式如下:
(1)通过定义变量创建对象
(2)通过new操作符创建对象
(3)通过实现创建对象

2、通过定义变量创建对象

通过定义变量创建对象时,变量的作用域决定了对象的生命周期。当进入变量的作用域时,对象被创建;退出变量的作用域时,对象被销毁。全局变量的作用域时整个程序,被声明为全局对象的变量在程序调用main函数前被创建,当程序退出main函数后,全局对象才会被销毁。静态变量作用域不是整个程序,但静态变量存储在全局/静态数据区,在程序开始时已经分配好,因此声明为静态变量的对象在第一次进入作用域时会被创建,直到程序退出时被销毁。

#include 
#include 

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

class B
{
public:
    B()
    {
        printf("B Created\n");
    }
    ~B()
    {
        printf("B Destroyed\n");
    }
};

A globalA;

void test()
{
    printf("test()------------------------->\n");
    A localA;
    static B localB;
    printf("test()<-------------------------\n");
}

int main()
{
    printf("main()------------------------->\n");
    test();
    test();
    static B localB;
    printf("main()<-------------------------\n");
    return 0;
}

上述代码中定义了一个A的全局对象globalA,一个A的局部对象localA,一个B的静态局部对象localB,localA和localB的作用域为test函数。
在RHEL 7.3系统使用GCC编译器编译运行结果如下:

A Created
main()------------------------->
test()------------------------->
A Created
B Created
test()<-------------------------
A Destroyed
test()------------------------->
A Created
test()<-------------------------
A Destroyed
B Created
main()<-------------------------
B Destroyed
B Destroyed
A Destroyed

根据程序运行结果,全局对象globalA在main函数开始前被创建,在main函数退出后被销毁;静态对象localB在第一次进入作用域时被创建,在main函数退出后被销毁,如果程序从来没有进入到其作用域,则静态对象不会被创建;局部对象在进入作用域时被创建,在退出作用域时被销毁。

3、通过new操作符创建对象

通过new创建的对象会一直存在,直到被delete销毁。即使指向对象的指针被销毁,但还没有调用delete,对象仍然会一直存在,占据这堆空间,直到程序退出,因此会造成内存泄露。

#include 
#include 

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

A* createA()
{
    return new A();
}

void deleteA(A* p)
{
    delete p;
    p = NULL;
}

int main()
{
    A* pA = createA();
    pA = createA();

    deleteA(pA);
    return 0;
}

上述代码中,createA函数使用new操作符创建了一个A对象,并将返回地址记录在pA指针变量中;然后再次使用createA函数创建了一个A对象,将返回地址记录在pA指针变量中,此时pA指针将指向第二次创建的A对象,第一次创建的A对象已经没有指针指向。使用deleteA销毁对象时,销毁的是第二次创建的A对象,第一次创建的A对象会一直存在,直到程序退出,并且即使在程序退出时,第一次创建的A对象的析构函数仍然不会被调用,最终造成内存泄露。

4、通过实现创建对象

通过实现创建对象通常是指一些隐藏的中间临时变量的创建和销毁。中间临时变量的生命周期很短,不易被开发人员察觉,通常是造成性能下降的瓶颈,特别是占用内存多、创建速度慢的对象。
中间临时对象通常是通过拷贝构造函数创建的。

#include 
#include 

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    A(const A& other)
    {
        printf("A Created with copy\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

A getA(A a)
{
    printf("before\n");
    A b;
    return b;
}

int main()
{
    A a;
    a = getA(a);
    return 0;
}

在RHEL 7.3系统使用GCC编译器编译运行结果如下:

A Created
A Created with copy
before
A Created
A Destroyed
A Destroyed
A Destroyed

getA函数的参数和返回值都是通过值传递的,在调用getA是需要把实参复制一份,压入getA函数的栈中(对于某些C++编译器,getA函数的返回值也要拷贝一份放在栈中,在getA函数调用结束时,参数出栈就会返回给调用者)。因此,在调用getA函数时,需要构造一个a的副本,调用一次拷贝构造函数,创建了一个临时变量。
中间临时对象的创建和销毁是隐式的,因此如果中间临时对象的创建和销毁在循环内或是对象构造需要分配很多资源,会造成资源在短时间内被频繁的分配和释放,甚至可能造成内存泄露。
上述代码getA函数的问题可以通过传递引用的方式解决,即getA(A& a),不用构造参数的临时对象。
实际的C++工程实践中,会有大量其它类型的隐式临时对象存在,如重载+和重载++等操作符,对对象进行算术运算时也会有临时对象,操作符重载本质上也是函数,因此要尽量避免临时对象的出现。
当一个派生类实例化一个对象时,会先构造一个父类对象,同样,在销毁一个派生类对象时也会销毁其父类对象。派生类对象的父类对象是隐含的对象,其生命周期和派生类对象绑定在一起。如果构造父类对象的开销很大,则所有子类的构造都会开销很大。

#include 
#include 

class A
{
public:
    A()
    {
        printf("A Created\n");
    }
    ~A()
    {
        printf("A Destroyed\n");
    }
};

class B : public A
{
public:
    B(): A()
    {
        printf("B Created\n");
    }
    ~B()
    {
        printf("B Destroyed\n");
    }
};

int main()
{
    B b;
    return 0;
}

在RHEL 7.3系统使用GCC编译器编译运行结果如下:

A Created
B Created
B Destroyed
A Destroyed

根据运行结果,创建派生类对象时会先创建隐含的父类对象,销毁派生类对象时会在调用派生类析构函数后调用父类的析构函数。

四、C++对象的内存布局

1、C++对象内部结构简介

C++对象的内部结构及实现和C++编译器紧密相关,不同的编译器可能会有不同的实现方式。

2、C++简单对象

在一个C++对象中包含成员数据和成员函数,成员数据分为静态成员数据和非静态成员数据;成员函数分为静态成员函数、非静态成员函数和虚函数。

#include 
#include 

class SimpleObject
{
public:
    static int nCounter;
    double value;
    char flag;
    SimpleObject()
    {
        printf("SimpleObject Created\n");
    }
    virtual ~SimpleObject()
    {
        printf("SimpleObject Destroyed\n");
    }

    double getValue()
    {
        return value;
    }
    static int getCount()
    {
        return nCounter;
    }
    virtual void test()
    {
        printf("virtual void test()\n");
    }
};

int main()
{
    SimpleObject object;
    printf("Obejct start address: 0x%X\n", &object);
    printf("Value address: 0x%X\n", &object.value);
    printf("flag address: 0x%X\n", &object.flag);
    printf("Object size: %d\n", sizeof(object));

    return 0;
}

在RHEL 7.3系统使用GCC编译器编译运行结果如下:

SimpleObject Created
Obejct start address: 0x5728F3F0
Value address: 0x5728F3F8
flag address: 0x5728F400
Object size: 24
SimpleObject Destroyed

上述代码,静态成员数据nCounter存储在全局/静态数据区,由类的所有对象共享,并不作为对象占据的内存的一部分,因此sizeof返回的SimpleObject大小并不包括nCounter所占据的内存大小。非静态成员数据value和flag存储在对象占用的内存中,不论时全局/静态数据区,还是堆上、栈上。Value是double类型,占据8个字节(64位),flag是char类型,占据1个字节,但由于内存对齐,也会占用8字节。
SimpleObject类对象的数据成员占用了16个字节,剩下的8字节是与虚函数相关的。如果将两个虚函数的virtual关键字去掉,则sizeof(SimpleObject)将得到16。
虚函数用于实现C++语言的动态绑定特性,为了实现动态绑定特性,C++编译器遇到含有虚函数的类时,会分配一个指针指向一个函数地址表,即虚函数表(virtual table),虚函数表指针占据了8个字节,并且占据的是类实例内存布局开始的8个字节。
C++简单对象占用的内存空间如下:
(1)非静态成员数据是影响对象占用内存大小的主要因素,随着对象数目的增加,非静态成员数据占用的内存空间会相应增加。
(2)所有的对象共享一份静态成员数据,因此静态成员数据占用的内存空间大小不会随着对象数目的增加而增加。
(3)静态成员函数和非静态成员函数不会影响对象内存的大小,虽然其实现会占用相应的内存空间,同样不会随着对象数目的增加而增加。
(4)如果类中包含虚函数,类对象会包含一个指向虚函数表的指针,虚函数的地址会放在虚函数表中。
在虚函数表中,不一定完全是指向虚函数实现的指针。当指定编译器打开RTTI开关时,虚函数表中的第一个指针指向的是一个typeinfo的结构,每个类只产生一个typeinfo结构的实例,当程序调用typeid()来获取类的信息时,实际是通过虚函数表中的第一个指针获取typeinfo结构体实例。

3、单继承

C++语言中,继承分为单继承和多继承。

#include 
#include 

class SimpleObject
{
public:
    static int nCounter;
    double value;
    char flag;
    SimpleObject()
    {
        printf("SimpleObject Created\n");
    }
    virtual ~SimpleObject()
    {
        printf("SimpleObject Destroyed\n");
    }

    double getValue()
    {
        return value;
    }
    static int getCount()
    {
        return nCounter;
    }
    virtual void test()
    {
        printf("virtual void SimpleObject::test()\n");
    }
};
int SimpleObject::nCounter = 0;

class DerivedObject : public SimpleObject
{
public:
    double subValue;
    DerivedObject()
    {
        printf("DerivedObject Created\n");
    }
    virtual ~DerivedObject()
    {
        printf("DerivedObject Destroyed\n");
    }
    virtual void test()
    {
        printf("virtual void DerivedObject::test()\n");
    }

};

int main()
{
    DerivedObject object;
    printf("Obejct start address: 0x%X\n", &object);
    printf("Value address: 0x%X\n", &object.value);
    printf("flag address: 0x%X\n", &object.flag);
    printf("subValue address: 0x%X\n", &object.subValue);
    printf("SimpleObject size: %d\n"
           "DerivedObject size: %d\n",
           sizeof(SimpleObject),
           sizeof(DerivedObject));

    return 0;
}

在RHEL 7.3系统使用GCC编译器编译运行结果如下:

SimpleObject Created
DerivedObject Created
Obejct start address: 0x96EA42D0
Value address: 0x96EA42D8
flag address: 0x96EA42E0
subValue address: 0x96EA42E8
SimpleObject size: 24
DerivedObject size: 32
DerivedObject Destroyed
SimpleObject Destroyed

根据上述输出结果,构造一个派生类实例时首先需要构造一个基类的实例,基类实例在派生类实例销毁后被销毁。
SimpleObject类大小是24个字节,DerivedObject类的大小是32个字节,DerivedObject增加了一个double类型的成员数据subValue,需要占用8个字节。由于DerivedObject类也需要一个虚函数表,因此DerivedObject派生类与SimpleObject基类使用同一个虚函数表,DerivedObject派生类在构造时不会再创建一个新的虚函数表,而是在SimpleObject基类的虚函数表中增加或修改,DerivedObject实例的虚函数表中会存储DerivedObject相应的虚函数实现,如果DerivedObject没有提供某个虚函数实现,则存储基类SimpleObject的虚函数实现。

4、多继承

C++语言提供多继承的支持,多继承中派生类可以有一个以上的基类。多继承是C++语言中颇受争议的一项特性,多继承在提供强大功能的同时也带来了容易造成错误的诸多不便。因此,后续很多面向对象程序设计语言取消了多继承支持,而是提供了更清晰的接口概念。
C++语言中仍然通过继承实现接口,在面向接口的编程模型,如COM,都采用多继承实现。如果需要开发一个文字处理软件,要求有些文档即可以打印有可以存储,有些文档只可以打印或存储。考虑到程序的可扩展性,比较好的设计是将打印和存储分别定义为两个接口,在接口中定义相应的方法。当一个类实现了打印和存储接口时,其对象即可以打印也可以存储。如果只实现了打印或存储,则只具备相应的功能。

#include 
#include 

using namespace std;

class BaseA
{
public:
    BaseA(int a)
    {
        m_a = a;
    }
    virtual void funcA()
    {
        cout << "BaseA::funcA()" <a <b <c <vptrA <vptrB <

上述代码中,Derived类继承自BaseA和BaseB类,funcA和funcB为虚函数。
C++应用程序性能优化(二)——C++对象模型
Derived派生类对象的内存模型如下:
C++应用程序性能优化(二)——C++对象模型
创建派生类时,首先需要创建基类的对象。由于多继承一个派生类中有多个基类,因此,创建基类的对象时要遵循一定的顺序,其顺序由派生类声明时决定,如果将Derived类的声明修改为:
class Derived : public BaseB, public BaseA
基类对象BaseB会被首先创建,BaseA对象其次被创建。基类对象销毁的顺序与创建的顺序相反。
多继承会引入很多复杂问题,菱形继承时很典型的一种。菱形继承示例代码如下:

#include 
#include 

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    void print()
    {
        cout << "name: " << m_name
             << " age: " << m_age <name1 << endl;
    cout << pTest->age1 << endl;
    cout << pTest->research << endl;
    cout << pTest->name2 << endl;
    cout << pTest->age2 << endl;
    cout << pTest->major << endl;
    cout << pTest->subject << endl;

    return 0;
}
// output:
// Doctor size: 28
// Bauer_1
// 31
// Computer
// Bauer_2
// 32
// Computer Engneering
// HPC

上述代码中,底层子类对象的内存局部如下:
C++应用程序性能优化(二)——C++对象模型
底层子类对象中,分别继承了中间层父类从顶层父类继承而来的成员变量,因此内存模型中含有两份底层父类的成员变量。
如果顶层父类含有虚函数,中间层父类会分别继承顶层父类的虚函数表指针,因此,底层子类对象内存布局如下:
C++应用程序性能优化(二)——C++对象模型

#include 
#include 

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    virtual void print()
    {
        cout << "name: " << m_name
             << " age: " << m_age <vptr1 << endl;
    cout << pTest->name1 << endl;
    cout << pTest->age1 << endl;
    cout << pTest->research << endl;
    cout << pTest->vptr2 << endl;
    cout << pTest->name2 << endl;
    cout << pTest->age2 << endl;
    cout << pTest->major << endl;
    cout << pTest->subject << endl;

    return 0;
}

// output:
// Doctor size: 28
// 0x405370
// Bauer_1
// 31
// Computer
// 0x40537c
// Bauer_2
// 32
// Computer Engneering
// HPC

虚继承是解决C++多重继承问题的一种手段,虚继承的底层实现原理与C++编译器相关,一般通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4(8)字节)和虚基类表(不占用类对象的存储空间)(虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。
在虚继承情况下,底层子类对象的布局不同于普通继承,需要多出一个指向中间层父类对象的虚基类表指针vbptr。
vbptr是虚基类表指针(virtual base table pointer),vbptr指针指向一个虚基类表(virtual table),虚基类表存储了虚基类相对直接继承类的偏移地址;通过偏移地址可以找到虚基类成员,虚继承不用像普通多继承维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

#include 
#include 

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    void print()
    {
        cout << "this: " << this <vbptr_left << endl;
    cout << *(int*)pTest->vbptr_left << endl;
    cout << pTest->research << endl;
    cout << pTest->vbptr_right << endl;
    cout << *(int*)pTest->vbptr_right << endl;
    cout << pTest->major << endl;
    cout << pTest->subject << endl;
    cout << pTest->name << endl;
    cout << pTest->age << endl;

    return 0;
}

// output:
// Doctor size: 28
// 0x40539c
// 12
// Computer
// 0x4053a8
// 0
// Computer Engneering
// HPC
// Bauer
// 30

上述代码没有虚函数,在G++编译器打印结果如上,底层子类对象的内存布局如下:
C++应用程序性能优化(二)——C++对象模型

#include 
#include 

using namespace std;

class People
{
public:
    People(string name, int age)
    {
        m_name = name;
        m_age = age;
    }
    virtual void print()
    {
        cout << "this: " << this <vbptr_left << endl;
    cout << std::hex << *(int*)pTest->vbptr_left << endl;
    cout << std::dec << *((int*)pTest->vbptr_left+8) << endl;
    cout << std::dec << *((int*)pTest->vbptr_left+16) << endl;
    cout << std::dec << *((int*)pTest->vbptr_left+24) << endl;

    cout << pTest->research << endl;
    cout << pTest->vbptr_right << endl;

    cout << pTest->major << endl;
    cout << pTest->subject << endl;
    cout << pTest->vptr_base << endl;

    cout << pTest->name << endl;
    cout << pTest->age << endl;

    return 0;
}

上述代码中,使用了虚继承,因此不同的C++编译器实现原理不同。
对于GCC编译器,People对象大小为char* + int + 虚函数表指针,Teacher对象大小为char*+虚基类表指针+A类型的大小,Student对象大小为char*+虚基类表指针+A类型的大小,Doctor对象大小为char* + int +虚函数表指针+char*+虚基类表指针+char*+虚基类表指针+char*。中间层父类共享顶层父类的虚函数表指针,没有自己的虚函数表指针,虚基类指针不共享,因此都有自己独立的虚基类表指针。
VC++、GCC和Clang编译器的实现中,不管是否是虚继承还是有虚函数,其虚基类指针都不共享,都是单独的。对于虚函数表指针,VC++编译器根据是否为虚继承来判断是否在继承关系中共享虚表指针。如果子类是虚继承拥有虚函数父类,且子类有新加的虚函数时,子类中则会新加一个虚函数表指针;GCC编译器和Clang编译器的虚函数表指针在整个继承关系中共享的。
G++编译器对于类的内存分布和虚函数表信息命令如下:

g++ -fdump-class-hierarchy main.cpp
cat main.cpp.002t.class

VC++编译器对于类的内存分布和虚函数表信息命令如下:
cl main.cpp /d1reportSingleClassLayoutX
Clang编译器对于类的内存分布和虚函数表信息命令如下:
clang -Xclang -fdump-record-layouts

5、构造与析构

C++标准规定,每个类都必须有构造函数,如果开发人员没有定义,则C++编译器会提供一个默认的构造函数,默认构造函数不带任何参数,也不会对成员数据进行初始化。如果类中定义了任何一种形式的构造函数,C++编译器将不再生成默认构造函数。
除了构造函数,C++标准规定,每个类都必须有拷贝构造函数,如果开发人员没有定义,则C++编译器会提供一个默认的拷贝构造函数,默认拷贝构造函数是浅拷贝,即按照对象的内存空间逐个字节进行拷贝,因此默认拷贝构造函数会带来隐含的内存问题。

#include 
#include 

class SimpleObject
{
public:
    int n;
    SimpleObject(int n)
    {
        this->n = n;
        buffer = new char[n];
        printf("SimpleObject Created\n");
    }
    virtual ~SimpleObject()
    {
        if(buffer != NULL)
        {
            delete buffer;
            printf("SimpleObject Destroyed\n");
        }
    }
private:
    //SimpleObject(const SimpleObject& another);
private:
    char* buffer;
};

int main()
{
    SimpleObject a(10);
    SimpleObject b = a;
    printf("Object size: %d\n", a.n);

    return 0;
}
在RHEL 7.3系统使用GCC编译器编译运行时会异常退出。

SimpleObject在构造时分配了n个字节的缓冲区,在析构时释放缓冲区。但由于没有定义拷贝构造函数,C++编译器会提供一个浅拷贝的默认拷贝构造函数,SimpleObject b = a语句会通过浅拷贝构造一个SimpleObject对象b,对象b的buffer和对象a的buffer指向同一块内存空间,在对象a和对象b析构时,这块内存空间被释放了两次,造成程序崩溃。如果不想通过赋值或拷贝构造函数构造对象,可以将拷贝构造函数定义为private,此时SimpleObject b = a;会在编译时报错。


网页名称:C++应用程序性能优化(二)——C++对象模型
本文链接:http://cxhlcq.com/article/jpoisg.html

其他资讯

在线咨询

微信咨询

电话咨询

028-86922220(工作日)

18980820575(7×24)

提交需求

返回顶部