前言
对象模型对于c++来说是至关重要的,只有深入理解了对象模型,才能在程序性能优化方面做到游刃有余,才能避免程序开发过程中一些不易发现的内存错误,从而改善程序性能,提高程序质量。本章就来讲讲对象模型那些事。
对象的生命周期
对于不同创建方式创建的对象,其生命周期各不相同。生命周期可以简单理解为某个对象从创建到销毁的过程;创建对象需要分配一定的内存空间,销毁对象则会释放这部分内存。
创建对象的方式可以分为三种
1.定义变量的方式
2.new 的方式
3.通过实现创建对象
通过定义变量的方式创建对象
#include <stdio.h>
#include <stdlib.h>
class A
{
public:
A()
{
printf("A 创建\n");
}
~A()
{
printf("A 销毁\n");
}
};
class B
{
public:
B()
{
printf("B 创建\n");
}
~B()
{
printf("B 销毁\n");
}
};
A a;
void test()
{
printf("test()开始\n");
A a1;
static B b;
printf("test()结束\n");
}
int main()
{
printf("main()开始\n");
test();
test();
static B b1;
printf("main()结束\n");
return 0;
}
-
通过定义变量创建对象时,变量的作用域决定了对象的生命周期。当进入变量的作用域时,对象被创建;退出变量的作用域时,对象被销毁。
-
a是全局变量,所以其作用域是整个程序,其在main函数之前被创建,main函数结束后再被销毁。
-
第一次进入test时a1和b都会被创建,但是b是静态变量,所以第二次进入test函数时只创建了a1。声明为静态变量的对象在第一次进入作用域时会被创建,直到程序退出时才被销毁。
-
最后b1与b类似,只会创建一次,在程序结束后销毁。
通过new 的方式创建对象
class A
{
public:
A()
{
printf("A 创建\n");
}
~A()
{
printf("A 销毁\n");
}
};
A* createA()
{
return new A();
}
void deleteA(A* p)
{
delete p;
p = NULL;
}
int main()
{
A* a = createA();
std::cout << "赋值前地址:" << pA << std::endl;
a = createA();
std::cout << "赋值后地址:" << pA << std::endl;
deleteA(a);
return 0;
}
-
当时用new去创建一个对象时,系统会在堆上分配相应的内存空间,这块空间不会自己释放,需要调用delete去主动释放,这就很容易造成内存的泄露。
-
上述代码中,createA函数中创建了一个A对象后将地址返回并存储到指针变量a中;随后再次调用了createA函数创建了一个A对象,将地址赋值给了a,此时a指针指向的是第二次创建的对象,这从结果图中可以看到。而第一次创建的对象已经没有了指针指向。调用deleteA销毁对象时,其实只销毁了第二次创建的对象,第一次创建的对象会一直存在,直到程序结束。虽然程序没有报错,但其实已经出现了内存的泄露。
-
注意:指针a指向的对象被销毁,但指针a还存在于栈中,此时a变成一个悬空指针,还指向堆中对象被销毁的位置,所以要将NULL赋值给a。
通过实现创建对象
通常是指一些隐藏的临时对象的创建和销毁,其通常拷贝构造函数创建。
class A
{
public:
A()
{
printf("A 创建\n");
}
A(const A& other)//拷贝构造函数
{
printf("A 通过拷贝创建\n");
}
~A()
{
printf("A 销毁\n");
}
};
A test(A a)
{
std::cout << "进入函数test"<< std::endl;
A b;
return b;
}
int main()
{
A a;
a = test(a);
return 0;
}
- 上述代码中,test函数是通过值传递,在调用test函数时,需要构造一个a的副本,调用一次拷贝构造函数,创建了一个临时对象变量,会影响程序性能。
- 可以通过传递引用的方式去避免中间对象的产生。
对象内存布局
一个c++对象中主要包括成员函数与成员变量,成员函数包括静态成员函数,非静态成员函数,虚函数;成员变量则包括静态成员变量与非静态成员变量。
只有非静态成员变量属于类的对象上。
class A
{
public:
static int n;
double value;
//int value;
char flag;
A()
{
printf("A 创建n\n");
}
virtual ~A()
{
printf("A 销毁n\n");
}
double getValue()
{
return value;
}
static int getn()
{
return n;
}
virtual void test()
{
printf("virtual void test()\n");
}
};
int main()
{
A a;
printf("A start address: 0x%X\n", &a);
printf("a size: %d\n", sizeof(a));
printf("A size: %d\n", sizeof(A));
return 0;
}
上边的代码,使用X64跑一下,结果如下:对象a的大小为24个字节。
- 因为value为double类型,占8个字节,flag为char类型,为1个字节,但由于内存对齐,也占用了8个字节,64位编译器中虚函数指针占用8个字节,所以一共为24个字节。
如果value改为int类型呢?没错,此时对象a只占16个字节,因为类中最大占用内存为8,所以flag只需要再补3个字节即可。
这又是为什么呢?
c++ 内存对齐原则
- 第一个成员在结构体变量偏移量为0 的地址处,也就是第一个成员必须从头开始。(有虚函数指针的虚函数指针放最前边)
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。譬如int类型变量,要从4的倍数开始。
- 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍,不足的要补齐。vs默认为8,linux默认为4。
- 如果嵌套结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是 所有最大对齐数(包含嵌套结构体的对齐数)的整数倍。
来个例子巩固一下:
class B
{
public:
static int n;
double value;
//int value;
char flag;
B()
{
printf("A 创建n\n");
}
virtual ~B()
{
printf("A 销毁n\n");
}
double getValue()
{
return value;
}
static int getn()
{
return n;
}
virtual void test()
{
printf("virtual void test()\n");
}
};
我们用vs自带的Developer Command Prompt工具去查看。进入类B所在文件夹,运行一下命令:
cl /d1 reportSingleClassLayoutB "文件名"
从上图可以看出,虚函数表指针占据了前4个字节,所以变量char i从4开始,又因为规则3,所以value只能从8开始,系统会给i补上3个字节。flag只占一个字节,从12开始,但因为规则4,系统也会为flag补上3个字节。所以一共16个字节。
总结
本文介绍了一些关于c++对象的相关知识,如有纰漏,请多指正。