C/C++编程:析构函数

1060 篇文章 307 订阅

为什么要有析构函数

  • 构造函数初始化对象。换句话说,它创建供成员函数进行操作的环境。创建环境有时需要获取资源,比如文件、锁、内存,这些东西必须在使用后释放。
  • 从而引入了一个析构函数,在对象销毁时就一定会自动调用析构函数,就像对象创建时一定会调用构造函数一样

什么是析构函数

  • 析构函数是一个成员函数,该函数在对象(自动变量)超出范围时自动调用,或者通过delete显式调用每个类只能有一个析构函数
  • 析构函数具有与类相同的名称,前面有一个波形符 (~) 。 例如,声明 String 类的析构函数:~String()。
  • 如果未定义析构函数,则编译器将提供一个默认析构函数
    • 对于许多类,这就足够了。
    • 仅当类将句柄存储到需要释放的系统资源或拥有它们指向的内存的指针时,才需要定义自定义析构函数。

多个规则管理析构函数的声明。 析构函数:

  • 不接受任何参数
  • 不要将值返回 (或 void) 。
  • 不能声明为 const 、 volatile 或 static 。 但是,可以调用它们来销毁作为、或声明的对象const volatile static
  • 可以声明为virtual
    • 通过使用虚拟析构函数,无需知道对象的类型即可销毁对象:使用虚函数机制调用该对象的正确析构函数。
    • 请注意,析构函数也可以声明为抽象类的纯虚函数。

虚析构函数:析构函数可以声明为virtual

场景:什么时候析构函数应该声明为virtual

  • 多态基类应该声明为一个虚析构函数
  • 只有当类内含有至少一个虚函数,才为它声明virtual析构函数
    • 如果一个类的析构函数是virtual的,说明它可以作为基类被继承
    • 如果一个类的析构函数是virtual的,说明它至少由一个虚函数可以被子类实现(多态)
  • 如果一个类不是为了多态性,那么这个类的析构函数不应该是virtual的)、

说明:多态基类应该声明为一个虚析构函数

举个例子

由很多做法可以记录时间,因此,设计一个TimeKeeper基类和一个派生类作为不同的计时方法:

class TimeKeeper{
public:
	TimeKeeper();
	~TimeKeeper();  // 基类是非non-virtual析构函数
};

class AtomicClock : public TimeKeeper{}; // 原子钟
class WaterClock : public TimeKeeper{}; //水表
class WristWatch : public TimeKeeper{}; // 腕表

很多客户只想在程序中使用时间,不想操心时间怎么计算,这时候我们可以设计factory(工厂)函数,返回指针指向一个计时对象。

Factory函数会"返回一个基类指针,指向新生成的派生类对象",如下:

TimeKeeper* getTimeKeeper(); // 返回一个指针,指向一个TimeKeeper派生类的动态分配对象

其返回的TimeKeeper*对象一定是new出来的。因此为避免资源泄露,必须将每一个返回的对象delete掉:

TimeKeeper* ptk = getTimerKeeper(); 

delete ptk;

问题是getTimerKeeper返回的指针实际指向一个派生类对象,而其基类是non-virtual析构函数。这会导致重大错误:

  • 因为C++明白指出,当派生类对象经由一个基类指针被删除,而该基类带着一个non-virtual析构函数,其结果未定义----实际执行时通常发生的是对象的派生成分没有被销毁。
  • 如果getTimerKeeper返回的指针指向一个AtomicClock对象,AtomicClock成分(声明与AtomicClock类内的成员变量)不会被销毁,而AtomicClock 的析构函数也没有被调用。但是其基类成分通常会被销毁。这会导致资源泄漏】数据结构破坏等后果。

怎么解决呢?很简单。给基类一个virtual析构函数。此后删除派生类对象就会如你想要的那样。是的,它会销毁所有的对象,包括所有的派生类成分。

class TimeKeeper{
public:
	TimeKeeper();
	virtual ~TimeKeeper();
};

说明:如果类带有任何虚函数,它就应该有一个虚析构函数

原则

  • 任何类只要带有虚函数,说明它表意自己要作为基类,因此它就应该有一个虚析构函数。
  • 如果类不含有虚函数,通常表示它不意图作为一个基类。当类不作为基类时我们不应该将它的析构函数声明为virtual

原因

  • 程序体积会增加
    • 欲实现出virtual函数,对象必须携带某些信息,主要用来在运行期决定哪一个虚函数该被调用。这份信息通常是由一个vptr(virtual table pointer)指针指出。
    • vprt指向一个由函数指针构成的数组,称为虚函数表(virtual table、vtbl);每一个带有虚函数的类都有一个相应的虚函数表。
    • 当对象调用某一虚函数,实际被调用的函数取决于该对象的vptr所指的那个虚函数表—编译器在其中找适当的函数指针
  • 它不再能够传递给其他语言缩写的函数了

举个例子

class Point{
public:
	Point(int xCoord, int yCoord);
	~Point();
private:
	int x, y;
};
  • 如果int占用32bits,那么Point对象可塞入一个64bit缓存区中。甚至,这样一个Point对象可以被当作一个"64bit"量传给其他语言比如C写的函数。然后当Point的析构函数是vritual,形势起了变化。

  • 当Point类内含虚函数,其对象的体积会增加:在32bit计算机系统中将占用64bits(为了存放两个ints)~96bits(两个ints+vptr);在64bit计算机中可能占用64-128bits。因此,为Point添加一个vptr会增加其对象大小的50%~100%。Point对象不再能塞入一个64bit缓存区

  • C++的Point对象也不再和其他语言(比如C)内的相同声明有一样的结果,因此也就不再能把它传递到其他语言所写的函数,除非你明确补偿vptr。

纯虚析构函数

什么是纯虚析构函数

class AWOV{
public:
    virtual ~AWOV() = 0; // 声明纯虚析构函数
};

// 必须为纯虚析构函数提供一个定义:否则会报错
AWOV::~AWOV(){}; // 定义纯虚析构函数

什么时候要让类声明为纯虚析构函数

  • 当你不想要某个类被实例化时。也就是说,你不能为那种类型创建对象。这种类也叫做抽象类

为什么必须为纯虚析构函数提供一个定义

析构函数的运作方式是:

  • 最深层派生(most-derived)的那个类其析构函数最先被调用,然后是其每一个基类的析构函数被调用。
  • 编译期会在AWOV的派生类的析构函数创建一个对~AWOV的调用动作,所以你必须为这个函数提供一个定义。否则链接错误。

也就是说:

  • 纯虚析构函数最终需要一定会被被调用,以析构基类对象
  • 虽然是抽象类没有实体,但是编译器必须能找到纯虚析构函数的实体才能去调用它

虚析构函数 VS 纯虚析构函数

  • ps:
    • 只要某个类是虚析构函数,说明它可以作为基类,而且有多态性(通常其内部有一个虚函数可以被子类定制化实现)
    • 只要某个类是纯虚析构函数,说明它是一个抽象类,必须作为基类实现 (通常作为接口,定义行为)

纯虚析构函数 VS 普通析构函数

区别:

  • 基类析构函数为纯虚函数时,必须显示加上函数体。
  • 普通纯虚函数可以有函数体(可以有但最好不要加)

析构函数什么时候被调用

编译器自动调用析构函数

当下列事件之一发生时,编译器将隐式调用类的析构函数:

  • 具有块范围的本地(自动)对象超出范围。
  • 使用运算符分配的对象new使用进行了显式释放delete
  • 临时对象的生存期结束
  • 程序结束,并且存在全局或者静态对象
  • 使用析构函数的完全限定名显式调用了析构函数

析构函数可以随意调用类成员函数和访问类数据

对析构函数的使用有两个限制:

  • 不能采用其地址
  • 派生类不继承其基类的析构函数

显式析构函数调用

什么时候需要显示调用析构函数

  • 很少需要显式调用析构函数,可能会导致严重的错误

  • 显式调用析构函数对置于绝对地址的对象进行清理会很有用。

    • 这些对象通常采用布置new运算符进行分配。
    • delete无法释放此内存,因为它不是从免费存储分配。但是,对析构函数的调用可以执行相应的清理。

怎么写

s.String::~String();     // non-virtual call
ps->String::~String();   // non-virtual call

s.~String();       // Virtual call
ps->~String();     // Virtual call

编程原则:不要在析构中抛出异常

原因:为什么不要在析构中抛出异常?

C++并不禁止析构函数吐出异常,但是不鼓励这样做

这是有理由的。

看个例子:

class Widget{
public:
	//...
	~Widget(){..}; //假设这里会抛出一个异常
};

void doSomething(){
	std::vector<Widget> v;
} // v在这里被自动销毁

当v被销毁时,它有责任销毁其内含的所有异常。

  • 假设v有十个Widget,而在析构第一个元素的时候,有异常被抛出。其他九个Widget还是应该被销毁(否则它们保持的任何资源都会发生泄漏),因此v应该调用它们各个析构函数。
  • 但假设在那些调用期间,第二个Widget析构函数又抛出异常。现在有两个同时作用的异常,这对C++而言太多了。
  • 在两个异常同时存在的情况下,程序如果不是结束执行就是导致不明确行为

本例中它会导致不明确行为。

另外:如果一个异常被析构函数抛出而没有在函数内部捕获住,那么析构函数就不会完全运行(它会停在抛出异常的那个地方上)。这可能造成资源泄漏

场景:如果你的析构函数必须执行一个动作,而该动作可能会抛出一个异常,该怎么办?

场景

假设你使用一个类负责数据库连接:

class DBConnection{
public:
	static DBConnection create(); // 创建连接
	void close();                 // 关闭连接,失败时抛出异常
	//...
};

为了确保客户不忘记DBConnection 对象身上调用close,一个常用的方法是用来管理DBConnection资源的类,并在其析构函数中调用close:

class DBConn{  // 这个类用来管理DBConnection 对象
public:
	~DBConn{   // 确保数据库连接总是会关闭
		db.close();
	}
private:
	DBConnection  db;
}

从而,客户应该这样写:

{		// 开启一个区块
	DBConn dbc(DBConnection::create()); // 建立DBConnection 对象并交给DBConn对象以便管理
	//... 通过DBConn接口使用DBConnection 对象
}  // 在区块结束点,DBConn对象被销毁,因而自动为DBConnection 对象调用close

只要close()成功,ok。但如果该调用异常,DBConn析构函数就会抛出异常。这会很麻烦

怎么处理

  • 如果close抛出异常就**结束程序。通常通过调用abort完成(abort可以阻止异常从析构函数中抛出)
DBConn::~DBConn(){
	try{db.close();}
	catch(...){
		// 制作运转记录,记下对close的调用失败
		std::abort();
	}
}
  • 吞下因调用close而发生的异常(不推荐,因为它压制了"某些动作失败"的重要信息)
DBConn::~DBConn(){
	try{db.close();}
	catch(...){
		// 制作运转记录,记下对close的调用失败
	}
}

这两个方法都有缺点:它们都无法对"导致close抛出异常"的情况做出反应

一个较佳策略是重新设计DBConn接口, 比如DBConn自己可以提供一个close函数,使客户有机会对可能出现的问题做出反应。

  • DBConn也可以追踪其所管理的DBConnection是否已经被关闭,并在答案为否的情况下由其析构函数关闭之。这可防止遗失数据库连接。
  • 然而如果DBConnection析构函数调用失败,又将退回"强迫结束程序"或"吞下异常"的老路
class DBConn(){
public:
	void close(){  // 供客户使用的新函数
		db.close();
		closed = true;
	}
	~DBConn(){
		if(!closed){
			try{
				db.close();
			}catch(...){
				// 忽视或者结束程序
			}
		}
	}
}

总之,如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数之外的某个函数。因为析构函数抛出异常是危险的,总会带来"过早结束程序"或者"发生不明确行为"的风险

小结

  • 析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或者结束程序
  • 如果客户需要对某个操作函数运行期抛出的异常做出反应,那么类应该提供一个普通函数(而非析构函数)指向该操作

析构的顺序

析构顺序

构造函数与析构函数可以很好的与类层次配合。构造函数会“自上而下”的创建一个类对象

  • 首先,构造函数调用其基类的构造函数
  • 然后,调用成员函数的构造函数
  • 最好,执行自身的构造函数

析构函数以相反顺序“拆除”一个对象:

  • 将调用自身的析构函数
  • 按照非静态成员对象的析构函数在类声明中的显式顺序的相反顺序调用这些函数。构造这些成员时使用可选成员初始化列表不会影响构造或者析构的顺序
  • 非虚拟基类的析构函数以相反顺序调用
  • 虚拟基类的析构函数以声明的相反顺序被调用

特别的:

  • 一个virtual基类必须在任何可能使用它的基类之前构造,并在它们之后销毁。这种顺序保证了一个基类或者一个成员不会在它初始化完成之前或者已经被销毁之后使用。(虽然可以故意规避但请不要这样,否则将造成灾难性后果)
  • 构造函数按照声明顺序(而非初始化顺序)执行成员和基类的构造函数: 如果两个构造函数使用了不同的顺序,析构函数不能保证(即使能保证也会有严重的开销)按照构造相反顺序进行销毁
struct A1      { virtual ~A1() { printf("A1 dtor\n"); } };
struct A2 : A1 { virtual ~A2() { printf("A2 dtor\n"); } };
struct A3 : A2 { virtual ~A3() { printf("A3 dtor\n"); } };

struct B1      { ~B1() { printf("B1 dtor\n"); } };
struct B2 : B1 { ~B2() { printf("B2 dtor\n"); } };
struct B3 : B2 { ~B3() { printf("B3 dtor\n"); } };

int main() {
    // 析构函数以相反顺序“拆除”一个对象
    A1 * a = new A3;  // 1、2、3 ---> 3、2、1
    delete a;
    printf("\n");

    // 非virtual将会发生资源泄露
    B1 * b = new B3;
    delete b;
    printf("\n");

    B3 * b3 = new B3;
    delete b3;
}


在这里插入图片描述

多重继承的析构基类顺序

非virtual基类的析构函数以声明基类名称的反向顺序调用。 考虑下列类声明:

class MultInherit : public Base1, public Base2
...

在前面的示例中,先于 Base2 , 再调用 Base1 的析构函数。

实验

为什么要引入构造函数:因为需要在析构函数中清理资源

#include <stdlib.h>
#include <string.h> 

using namespace std;

class String {
public:
   String( char *ch );  //声明构造函数
   ~String();           //声明析构函数
private:
   char    *_text;
   size_t  sizeOfText;
};

//定义构造函数。
String::String( char *ch ) {
   sizeOfText = strlen( ch ) + 1;

   //动态分配正确数量的内存。
   _text = new char[ sizeOfText ];

   //如果分配成功,复制初始化字符串。
   if( _text )
      strcpy_s( _text, sizeOfText, ch );
}

// 定义析构函数。
String::~String() {
   // 释放以前为此字符串保留的内存。
   delete[] _text;
}

int main() {
   String str("The piper in the glen...");

	String p= new String('a');
	delete p;
}

实验:纯虚析构函数必须有实体,普通纯虚函数可以有实体但是最好不要加

#include <iostream>
using namespace std;
 
class Base
{
public:
	virtual void print() =0;   // 普通纯虚函数可以有函数体(可以有但最好不要加)
 
	virtual void print1() = 0   // 普通纯虚函数可以有函数体(可以有但最好不要加)
	{
		cout<< "Base print1" <<endl;
	}
	virtual ~Base() = 0   //基类析构函数为纯虚函数时,必须显示加上函数体。
	{
		cout<< "~Base" <<endl;
	}
};
class Deriver:public Base
{
public:
	void print()
	{
		cout<< "Deriver print" <<endl;
	}
	void print1()
	{
		cout<< "Deriver print1" <<endl;
	}
	~Deriver()
	{
		cout<< "~Deriver" <<endl;
	}
};
void main()
{
	Deriver d;
	
	d.print();
	//d.Base::print();//error
	d.print1();
	d.Base::print1();
}

应用:让对象仅能在堆上创建

场景

如果我们要求某个类的对象:

  • 对象必须显式销毁,不能隐式销毁;
  • 对象必须通过new来创建

那应该怎么实现呢?

实现:

有两种方法:

  • 将析构函数生成为private
  • 声明X的析构函数为=delete

在两种方法中,使用private更为灵活:

class NonLocal{
public:
    void destroy() {
        this->~NonLocal();
    };
private:
    ~NonLocal() {};  // 析构函数就是无参的
};

void user(){
  //  NonLocal xl;           // error, 对象不能在栈上生成,因为编译器没法(隐式)调用析构函数了

    NonLocal *p = new NonLocal ; //ok
    p->destroy();               //ok
    // delete p;                // error, 不能析构一个NonLocal
}
void user(){
	//NonLocal xl;        
	X *p = new NonLocal ; //ok
	//delete p;             // error, 不能析构一个NonLocal 
	p.destory();          //ok
}

原因:

  • 如果为类X声明了一个析构函数,那么每当一个X离开作用域或者被delete释放时,析构函数就会被隐式调用。
  • 这意味着我们通过声明X的析构函数为=delete或者private,就可以阻止其析构。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值