构造函数实现类对象的创建,析构函数完成对象删除时资源释放。两者成对出现,每个自定义类数据类型,都必须定义构造函数和析构函数。当定义对象时,类的构造函数会由编译默认调用,同样对象释放时,对象的析构函数由编译器默认调用,实现对象释放。
在实际编程中,实现文件的操作是一件很平常事情。我们可定义一个CFile文件类型:
// CFile文件操作类,完成文件的操作动作。
class CFile
{
public:
CFile();
~CFile();
// 文件打开操作
virtual Open(char *pszFileName, int iOpenType);
// 文件关闭操作
virtual Close();
// 文件读操作
virtual Read(void *pBuffer, int iBufferLength);
// 文件写操作
virtual Write(void *pBuffer, int iBufferLength);
};
// text 类型数据文件操作类
class CTextFile: public CFile {.....};
// word 类型数据文件操作类
class CWordFile: public CFile {.....};
// bin 类型数据文件操作类
class CBinFile: public CFile {.....};
以继承的方式实现各类特征的文件操作。可通过工厂方式实现各类特性的文件对象创建,并返回基类CFile文件指针pMyFile,通过pMyFile指针实现文件操作。例如:
CFile *pMyFile = ......;
...
delete pMyFile;
分析这段代码会发现,其中存在很多的陷阱,例如:delete pMyFile时,pMyFile对象没有完全释放。这是由于基类的析构函数为非虚函数导致的。
小心陷阱
- 在C++中,当一个派生类对象通过使用一个基类指针删除,而这个基类有一个非虚的析构函数,则结果是未定义的。运行时比较有代表性的后果是对象的派生部分不会被销毁。然而基类部分很可能已被销毁,这就导致了一个奇怪的“部分析构”对象,这是一个资源泄露。
- 排除这个问题:给基类一个虚析构函数。于是删除一个派生类对象的时就有了你所期望的正确行为:销毁整个对象,不仅包括基类持有资源还包括派生类持有的资源。
这个问题,可通过在基类中把析构函数声明为虚函数解决:
// CFile文件操作类,完成文件的操作动作。
class CFile
{
public:
CFile();
virtual ~CFile();
// 文件打开操作
virtual Open(char *pszFileName, int iOpenType);
// 文件关闭操作
virtual Close();
// 文件读操作
virtual Read(void *pBuffer, int iBufferLength);
// 文件写操作
virtual Write(void *pBuffer, int iBufferLength);
};
// text 类型数据文件操作类
class CTextFile: public CFile {.....};
// word 类型数据文件操作类
class CWordFile: public CFile {.....};
// bin 类型数据文件操作类
class CBinFile: public CFile {.....};
但是,如果没有类继承关系,不应该将析构函数不声明为虚函数,因为虚函数的实现要求对象携带额外的信息,这些信息用于在运行时确定该对象应该调用哪一个虚函数。上述信息被称为VPTR(virtual table pointer,虚函数表指针)指针。VPTR指向一个被称为VTBL(virtual table,虚函数表)的函数指针数组,每一个包含虚函数的类都关联到VTBL。当一个对象调用了虚函数,实际的被调用函数通过下面的步骤确定:首先找到对象VPTR指向的VTBL,然后在VTBL中寻找合适的函数指针。这样会使类所占用的内存增加(具体请参考实用经验 65的论述)。
同样,不假思索的把析构函数声明为一个虚函数和从不把析构函数声明为虚函数一样都不是什么明智之举。因为把析构函数声明为虚函数会带来对象空间大小的增加。
当且仅当一个类至少存在一个虚函数才需要把析构函数声明为virtual。原因如下:
(1)如果一个类有虚函数功能,那么它将经常作为一个基类使用。
(2)如果它是一个基类,那么它的派生类经常使用new来分配。
(3)如果一个派生类对象使用new来分配,并且通过一个指向它的基类的指针来控制,那么它经常通过一个指向它的基类的指针来删除它(如果基类没有虚析构函数,结果将是不确定的,实际发生时,派生类的析构函数永远不会被调用)。
在继承机制下,把基类的析构函数声明为一个纯虚函数往往是一个明智之举。纯虚成员函数通常没有定义;它们是在抽象类中声明,然后在派生类中实现。例如:
class CFile
{
public:
virtual int Open (char *pszFileName, int iOpenType =0x666)=0;
virtual int Close()=0;
};
但是,在某些情况下,我们却不仅要声明一个纯virtual成员函数,而且还要定义纯virtual成员函数。最常见的例子是纯虚析构函数。
class CFile // 纯虚文件接口类
{
public:
virtual ~CFile() = 0; // 声明一个纯虚析构函数。
};
CFile ::~CFile() {} // 定义析构函数。
为什么说定义纯虚析构函数非常重要?
(1)派生类的析构函数会自动调用其基类的析构函数。这个过程是递归的,最终抽象类的纯虚析构函数也会被调用。
(2)如果纯虚析构函数只声明而无定义,那么就会造成运行时崩溃。(在很多情况下,这个错误会出现在编译期,但谁也不担保一定是这样)纯虚析构函数的哑元实现(Dummy implementation)能够保证代码的安全性。
例如:
// Text文件类型操作类
class CTextFile: public CFile
{
public:
// 文件打开操作
int open(const string & pathname, int mode);
// 文件关闭操作
int close();
~CTextFile ();
};
File * pf = new CTextFile;
delete pf; // OK, 会调用CFile::~CFile()
在某些情况下,定义其它纯虚成员函数也是非常有用的(比如说在调试应用程序以及记录应用程序的日志时)。例如,在一个不应该被调用,但是由于缺陷而被调用的基类中,如果有一个纯虚成员函数,那么我们可以为它提供一个定义。代码如下:
class Abstract
{
public:
virtual int func()=0;
//..
};
int Abstract::func()
{
std::cerr<<"got called from thread " << thread_id <<
"at: "<< gettimeofday() << std::endl;
}
这样,我们就可以记录所有对纯虚函数的调用,并且还可以定位错误代码;不为纯虚函数提供定义将会导致整个程序无条件地终止。
其实,我们遇到的析构函数应用远非这些。保护性的析构函数就是其中一种。采用了保护性析构函数,可实现我们梦寐以求的访问控制。
如果一个类被继承,同时定义了基类以外的数据成员对象,且基类析构函数不是virtual修饰的,那么当基类指针或引用指向派生类对象并析构(例如自动对象在函数作用域结束时;或者通过delete)时,会调用基类的析构函数而导致派生类定义的数据成员没有被析构,产生内存泄露等问题。虽然把析构函数定义成virtual的可以解决这个问题,但是当其它成员函数都不是virtual函数时,会在基类和派生类引入VPTR,而引入VPTR造成运行时的性能损失。如果确定不需要直接而是只通过派生类对象使用基类,可以把析构函数定义为protected(这样会导致基类和派生类外使用自动对象和delete时的错误,因为访问权限禁止调用析构函数),就不会导致以上问题。
从语法上来讲,一个函数被声明为protected或者private,那么这个函数就不能从“外部”直接被调用了。对于protected的函数,子类的“内部”的其他函数可以调用之。而对于private的函数,只能被本类“内部”的其他函数所调用。
如果不想让外面的用户直接构造一个类(假设这个类的名字为A)的对象,而希望用户只能构造这个类A的子类,那你就可以将类A的构造函数/析构函数声明为protected,而将类A的子类的构造函数/析构函数声明为public。例如:
class A
{
protected:
A(){}
public:
....
};
calss B : public A
{
public:
B(){}
....
};
A a; // 错误的,无法定义
B b; // 正确,可定义
请谨记
- 多态情况下,请将基类的析构函数声明为virtual,以防产生不必要的麻烦。
- 如果将基类的析构函数定义为纯虚函数,请保证此纯虚函数包含实现,以防在析构时抛出异常。