首页 > 代码库 > 避免在析构函数中编写代码

避免在析构函数中编写代码

上篇文章中,我们介绍了为什么应该彻底避免编写拷贝构造函数和赋值操作符。今天这篇我们讨论下为什么应该避免在析构函数中编写代码。即让析构函数为空。

例如:

virtual ~MyClass()
{
}

我们用空析构函数这个术语表示花括号内没有代码的析构函数。

需要编写析构函数可能有如下几个原因:

  • 在基类中,可能需要声明虚拟析构函数,这样就可以使用一个指向基类的指针指向一个派生类的实例。
  • 在派生类中,并不需要把析构函数声明为虚拟函数,但是为了增强可读性,也可以这样做。
  • 可能需要声明析构函数并不抛出任何异常。
对于最后一种情况,我们将详细讨论。在C++中,从析构函数中抛出异常被认为是不好的思路。这是因为析构函数常常是在一个异常已经被抛出的情况下被调用的,在这个过程中再次抛出异常将导致程序终止(或崩溃),这很可能违背程序员的初衷。因此,在有些类中,析构函数被声明为如下:

virtual ~ScppAssertFailedException() throw ()
{
}

这意味着我们保证不会从这个析构函数中抛出异常。因此,我们可以看到有时候需要编写析构函数。现在我们可以讨论析构函数为什么应该为空。何时需要在析构函数中出现实质性的代码呢?只有在析构函数或类的其他方法中获取了一些资源,并且在这个类的对象被销毁时应该释放这些资源时才应该这样,例如:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		if(first_name != NULL)
			first_name_ = new string(first_name);
		if(last_name != NULL)
			last_name_ = new string(last_name);
	}

	~PersonDescription()
	{
		delete first_name_;
		delete last_name_;
	}

private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	string* first_name_;
	string* last_name_;
};

这个类的设计违背了我们在前几篇文章中所讨论的原则。首先,我们看到每次添加一个表述个人描述的新元素时,都需要在析构函数中 添加对应的清理代码,这就违背了“不要迫使程序记住某些事情”的原则。以下是改进的设计代码:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		if(first_name != NULL)
			first_name_ = new string(first_name);
		if(last_name != NULL)
			last_name_ = new string(last_name);
	}
private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	string* first_name_;
	string* last_name_;
};

在这个例子中,我们根本不需要编写析构函数,因为编译器会为我们自动生成一个析构函数完成这些任务,在减少工作量的同时,也减少了出现脆弱代码的可能性。但是,这并不是选择第二种设计的主要原因。在第一个例子当中,存在一种更为严重的潜在危害。

假设我们决定增加安全检查,检查调用者是否提供了名字和姓氏:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		<span style="color:#ff0000;">SCPP_ASSERT(first_name != NULL ,"First name must be provided");
		first_name_ = new string(first_name);
        
		SCPP_ASSERT(last_name != NULL ,"Last name must be provided");
		last_name_ = new string(last_name);</span>
	}
	
	~PersonDescription()
	{
		delete first_name_;
		delete last_name_;
	}
private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	string* first_name_;
	string* last_name_;
};

正如我们之前讨论的那样,程序中的错误可能会终止程序,但也有可能抛出一个异常。现在我们就陷入这种麻烦之中:从构造函数抛出异常是一种不好的思路。为何呢?如果我们试图在堆栈上创建一个对象,并且构造函数正常的完成了它的任务(不抛出异常),那么当这个对象离开作用域之后,它的析构函数会被调用。但是,如果构造函数并没有完成它的任务,而是抛出了一个异常,析构函数将不会被调用。

因此,在前面的例子当中,如果我们假设提供了名字却没有提供姓氏,表示名字的字符串将被分配内存,但永远不会被删除,因此导致了内存泄露。但是,情况还不至于无可挽回。更进一步观察,如果我们有一个包含了其他对象的对象,一个重要的问题是:哪些析构函数将被调用?哪些析构函数将不被调用?

以下是用一个小试验来说明:

class A 
{
public:
	A()
	{
		cout<<"Creating A"<<endl;
	}
	~A()
	{
		cout<<"Destroying A"<<endl;
	}
};

class B 
{
public:
	B()
	{
		cout<<"Creating B"<<endl;
	}
	~B()
	{
		cout<<"Destroying B"<<endl;
	}
};

class C  : public A
{
public:
	C()
	{
		cout<<"Creating C"<<endl;
		Throw "Don't like C";
	}
	~C()
	{
		cout<<"Destroying C"<<endl;
	}

private:
	B b_;
};

注意,C类通过合成(即C类拥有一个B类型的数据成员)包含了B类。它还通过继承包含了A类型的对象(即在C类型的对象内部有一个A类型的对象)。现在,如果C的构造函数抛出一个异常,会发生什么情况呢?看以下的代码:

int main()
{
	cout<<"Testing throwing from constructor."<<endl;
	try{
		C c;
	}catch(...)
	{
		cout<<"Caught an exception."<<endl;
	}

	return 0;
}

运行后将产生下面的输出:

Testing throwing from constuctor.
Creating A
Creating B
Creating C
Destroying B
Destroying A
Caught an exception.

注意,只有C的析构函数没有被执行,A和B的析构函数都被调用。因此上面问题的答案既简单又符合逻辑:对于允许构造函数正常结束的对象,析构函数将会被调用,即使这些对象是一个更大对象的一部分,而后者的构造函数并没有正常结束。因此,让我们用智能指针重写上面的示例代码,引入安全检查:

class PersonDescription
{
public:
	PersonDescription(const char* first_name, const char* last_name)
		: first_name_(NULL), last_name_(NULL)
	{
		SCPP_ASSERT(first_name != NULL ,"First name must be provided");
		first_name_ = new string(first_name);
        
		SCPP_ASSERT(last_name != NULL ,"Last name must be provided");
		last_name_ = new string(last_name);
	}
	
	~PersonDescription()
	{
		delete first_name_;
		delete last_name_;
	}
private:
	PersonDescription(const PersonDescription&);
	PersonDescription& operator = (const PersonDescription&);

	<span style="color:#ff0000;">scpp::ScopedPtr<string> first_name_;
	scpp::ScopedPtr<string> last_name_;</span>
};

即使第2个安全检查抛出一个异常,指向first_name_的智能指针的析构函数仍然会被调用,并执行它的清理工作。另一个附带的好处是,我们并不需要操心把这些智能指针初始化为NULL,这是自动完成的。因此,我们看到从构造函数抛出异常是一种潜在的危险行为:对应的析构函数将不会被调用,因此可能会存在问题,除非析构函数是空函数。

总结:

从构造函数中抛出异常时为了避免内存泄露,在设计类的时候,使析构函数保持为空函数。

避免在析构函数中编写代码