http://www.cublog.cn/u2/79570/showart_2084600.html
1 类、对象和内存
1.1 通过内存看对象
我 们先回顾一下类和对象的定义,类是定义同一类所有实例变量和方法的蓝图或原型;对象是类的实例化。从内存的角度可以对这两个定义这样理解,类刻画了实例的 内存布局,确定实例中每个数据成员在一块连续内存中的位置、大小以及对内存的解读方式;对象就是系统根据类刻画的内存布局去分配的内存。除了实例变量和方 法,类也可以定义类变量和类方法,这是我们通常所说的静态变量和静态函数,它们不属于某个具体的对象,而是属于整个类,所以不会影响对象的内存布局和内存 大小。通过以上的讨论我们可以知道:对象本质上就是一块连续的内存,对象的类型(类)就是对这块内存的解读方式。在c++中我们可以通过四个类型转换运算符改变对象的类型,这种转换改变的是内存的解读方式,不会修改内存中的值。修改对象内存值的合法途径是通过成员函数/友元函数修改对象的数据成员。通过成员函数修改对象的值是c++语 言保证对象安全的一种机制,但这种机制不是强制的,你可以通过暴力的非法手段避开这个机制(比如你可以取得对象的起始地址,然后根据对象的内存布局任意修 改内存的值),除了极其特殊情况,这种人为非法手段都应当被禁止,因为这种暴力代码难于理解、不便移植、极易出错;另外程序在运行过程中由于代码中的某些 缺陷也会非法修改对象内存的值,这是我们程序中许多疑难bug的根源。所以正确的编写类,理解对象在内存的运行特点,合理的控制对象的创建和销毁是一个程序稳定运行的基本保证。
1.2 不同内存区域的对象
在C++中,对象通常存放在三个内存区域:栈、堆、全局/静态数据区;相对应的,在这三个区域中的对象就被称为栈对象、堆对象、全局/静态对象。
全局/静 态数据区:全局对象和静态对象存放在该区,在该内存区的对象一旦创建后直到进程结束才会释放。在其生存期内可以被多个线程访问,它可以做为多线程通信的一 种方式,所以对于全局对象和静态对象要考虑线程安全,特别是对于函数中的局部静态变量,容易忘记它的线程安全性。全局对象和一些静态对象有一个特点:这些 对象的初始化操作先于main函数的执行,而且这些对象(可能分布在不同的源文件中)初始化顺序没有规定,所以在它们的初始化中不要启动线程,同时它们的初始化操作也不应有依赖关系。
堆:堆对象是通过new/malloc在堆中动态分配内存,通过delete/free释放内存的对象。我们可对这种对象的创建和销毁进行精确控制。堆对象在c++中的使用非常广泛,不同线程间、函数间的对象共享可以使用堆对象,大型对象一般也使用堆对象(栈空间是有限的),特别是虚函数多态性一般是由堆对象实现的。使用堆对象也有一些缺点:1.需要程序员管理生存周期,忘记释放会有内存泄露,多次释放可能造成程序崩溃,通过智能指针可以避免这个问题;2.堆对象的时间效率和空间效率没有栈对象高,堆对象一般通过某种搜索算法在堆中找到合适大小的内存,比较耗时间,另外从堆中分配的内存大小会比实际申请的内存大几个字节,比较耗空间,尤其是对于小型对象这种损耗是非常大的;3.频繁使用new/delete 堆对象会造成大量的内存碎片,内存得不到充分的使用。对于2,3两个问题可以通过内存一次分配,多次使用的方法解决,更好的方法是根据业务特点实现特定的内存池。
栈:栈 对象是自生自灭型对象,程序员无需对其生存周期进行管理。一般,临时对象、函数内的局部对象都是栈对象。使用栈对象是高效的,因为它不需要进行内存搜索只 进行栈顶指针的移动。另外栈对象是线程安全的,因为不同的线程有自己的栈内存。当然,栈的空间是有限的,所以使用中要防止栈溢出的出现,通常大型对象、大 型数组、递归函数都要慎用栈对象。
2 C++对象的创建和销毁
C++类有四个基本函数:构造函数、析构函数、拷贝构造函数、赋值运算符重载函数,这四个函数管理着C++对象的创建和销毁,正确而完整地实现这些函数是C++对象安全运行必要保证。
2.1 构造/析构
创建一个对象有两个步骤:1.在内存中分配sizeof(CAnyType) 字节数的内存;2.调用合适构造函数初始化分配的内存。C++对象的大小由三个因素决定:1.各个数据成员的大小;2.由字节对齐产生的填充空间的大小;3.为支持虚机制编译器添加的一个指针,大小是四个字节,虚机制指针有两种:1.支持虚函数的虚表指针,2.支持虚继承的虚基类指针。虚继承时,派生类只保存一份被继承的基类的实体,比如下面例子的菱形继承关系中,类D中只有一份类A的实体。另外,类A是一个空类,但sizeof(A)大小不是0,而是1,这是因为需要用这一个字节来唯一标识类A在内存中的不同对象。
Class A{};
Class B : virtual public A {};
Class C : virtual public A {};
Class D:public B, public C {}
由 上面讨论知道,类中除了有编程人员写的数据成员,有时还有一些由编译器为支持虚机制而偷偷给你添加的成员,这些成员我们在代码中不会直接用到,但有可能被 我们的代码非法修改。比如不恰当的构造函数会修改虚机制指针的值,在写构造函数时我们经常使用如下的代码对整个对象进行初始化:
memset(this, 0, sizeof(*this));
这种初始化方式只能在类不涉及虚机制的情况下使用,否则它会修改虚机制指针,使类对象的行为无定义。
销毁一个对象也有两个步骤:1.调用析构函数;2.向系统归还内存。析构函数的作用是释放对象申请的资源。析构函数通常是由系统自动调用的,在以下几种情况下系统会调用析构函数:1.栈对象生命周期结束时:包括离开作用域、函数正常return(不考虑NRV优化)、函数异常throw;2.堆对象进行delete操作时;3.全局对象在进程结束时。析构函数只在一种情况下需要被显式的调用,那就是用placement new构建的对象。当类里包含虚函数的时候我们应该声明虚析构函数,虚析构函数的作用是:当你delete一个指向派生类对象的基类指针时保证派生类的析构函数被正确调用。有许多资源泄露的问题就是因为没有正确使用虚析构函数造成的,这种资源泄露有两种:1.派生类里直接分配的资源;2.派生类里的成员对象分配的资源。尤其是第二类,隐蔽性非常高。
构 造和析构是一组被成对调用的函数,特别是对于栈对象,调用是由系统自动完成的,所以我们可以利用这一特性将一些需要成对出现的操作分别封装在构造和析构函 数里由系统自动完成,这样可以避免由于编程时的遗漏而忘记进行某种操作。比如资源的申请和释放,多线程中的加锁和解锁都可以利用栈对象的这一特性进行自动 管理。
2.2 拷贝/赋值
拷贝构造函数、赋值运算符重载函数是一对孪生兄弟,通常一个类如果需要显式写拷贝构造函数,那么它也需要显式写赋值运算符重载函数。拷贝构造函数的功能是用已存在的对象构造一个新的对象,赋值运算符重载函数的功能是用已存在的对象替换一个已存在的对象。看下面几条语句:
string str1 = “string test”; //调用带参数的构造函数
string str2(str1); //调用拷贝构造函数
string str3 = str1; //调用拷贝构造函数
string str4; //调用默认构造函数
str4 = str3; //调用赋值运算符重载函数
拷贝构造函数、赋值运算符重载函数原型如下:
class string
{
private:
char* m_pStr;
int m_nSize;
public:
string (); //默认构造函数
string (const char* pStr); //带参数的构造函数
~ string ();
string (const string & cOther); //拷贝构造函数
string & operator=(const string & cOther); //赋值运算符重载函数
}
这两个函数的参数类型都是const string &,我们知道对于c++对 象通常以常引用作函数的参数,这样可以提高参数的传递效率,以对象作为函数参数时会调用拷贝构造函数生成一个临时对象供函数使用,效率较低。拷贝构造函数 是一个很特殊的函数,对于其他函数用对象作为函数参数顶多是效率的损失,但对拷贝构造函数用对象作为函数参数就会形成无限递归调用,所以拷贝构造必须以常 引用作为参数。
拷贝构造函数在c++编译器中有缺省的实现,实现的方式是按位对内存进行拷贝(memcpy(this, & cOther, sizeof(string)),如果缺省的实现满足我们的要求那就不需要显式的去实现这个函数,否则就必须实现,判断是否满足位拷贝语义的依据是类的成员数据中是否需要动态分配其他资源,比如上面的string类,成员m_pStr需要从堆中分配内存来存放具体的字符串,这块堆内存是位拷贝语义无法正确管理的,所以在string对象进行拷贝/赋值时编程人员需要负责管理这块内存。通常在三种情况下会调用拷贝构造函数,1. 一个对象以值传递的方式传入函数;2. 一个对象以值传递的方式从函数返回;3. 一个对象需要通过另外一个对象进行初始化。如果你确保对类对象的使用不会出现以上三种情况,那就说明你根本不需要拷贝构造函数,直接将拷贝构造函数私有化是最安全的选择。从以上的讨论我们知道,对于拷贝构造函数有三种处理策略(对于赋值运算符重载函数同样适用):1.什么都不写,按缺省的处理;2.显式写拷贝构造;3.将拷贝构造私有化。在写一个类前,我们必须分析类自身的实现方式以及对类对象的使用方式,明确选择一种策略,如果你放弃选择你就为将来可能出现的bug埋下一个伏笔。
上面讨论了拷贝/赋值函数的选择策略,下面看看它们具体的实现方式。拷贝构造函数的功能由一个对象构造一个新的对象,只要一个Copy操作就可以完成。赋值运算符重载函数的功能是由一个对象替换一个已存在的对象,完成这个功能需要三个操作:自赋值检查、Clear原有对象、Copy新对象。如string类的实现:
class string
{
private:
char* m_pStr;
int m_nSize;
public:
string (); //默认构造
string (const char* pStr); //带参数的构造函数
~ string ();
string (const string & cOther) //实现拷贝构造函数
{
Copy(cOther);
}
string & operator=(const string & cOther) //实现赋值运算符重载函数
{
if (this != & cOther)
{
Clear();
Copy(cOther);
}
return *this;
}
private:
void Copy(const string & cOther)
{
m_pStr = new char [cOthre. m_nSize+1];
strcpy(m_pStr, cOther. m_pStr);
m_nSize = cOther.m_nSize;
}
void Clear()
{
if (m_pStr != NULL)
{
delete[] m_pStr;
m_pStr = NULL;
}
m_nSize = 0;
}
}
string类中这两个函数的实现模式可以在其他类中直接套用,只需要改动Copy和Clear()函数即可。
3 总结
作为c++程序员每天都要和类、对象以及内存打交道,写一个类实现某项功能不难,但要实现一个健壮的、可重用的、易扩展的类就不是很容易了。很多时候我们写一个类时用的还是c的思维,对类的四个基本函数考虑的不够周到仔细,对类对象在不同内存区域运行特点理解不够,容易产生一些低级的bug,而且对后续的代码维护扩展也带来难度。本文中对这些内容做了基本的介绍,希望对大家有些帮助。