写Java代码的时候,遇到错误总是喜欢抛出异常,简单实用。最近开始写C++代码,发现异常没那么简单,使用须谨慎。
翻阅了《Effective C++》 《More Effective C++》《Inside The C++ Object Model》的相关章节,大概弄明白了一些东东,总结在本文。
本文不是总结普适的C++异常机制,还没有这个内力哈! 主要是结合构造函数和析构函数,来总结异常对他俩的影响。构造函数和析构函数本来就很折磨脑筋,再叠加上异常机制,确实比较复杂。
异常与析构函数
本节内容较少,因此先说。构造函数放到下一节讨论。
绝对不要将异常抛出析构函数
这一条在《Effective C++》 《More Effective C++》中均被作为独立章节讲解,可见其重要性。
有一点不要误解:析构函数的代码当然可以throw异常,只是这个异常不要被抛出析构函数之外。如果在析构函数中catch住异常,并且不再抛出,这就不会带来问题。
至于原因,有两点。我们先看第一点。
异常被抛出析构函数之外,往往意味着析构函数的工作没有做完。如果析构函数需要释放一些资源,异常可能导致资源泄露,使得程序处于一个不安全的状态。
如下面的伪代码所示,异常导致p不能free,从而造成内存泄露。
class A
{
public:
~A()
{
throw exception;
free(p);
}
};
OK,这个问题好办,我好好写代码,确保析构函数释放所有的资源之后,才抛出异常。这还不行吗?
class A
{
public:
~A()
{
free(p);
throw exception;
}
};
嗯,确实不行。我们来看第二个原因。
如果两个异常同时存在:第一个异常还没有被catch,第二个异常又被抛出,这会导致C++会调用terminate函数,把程序结束掉!
这简直是灾难,远比资源泄漏要严重。
那么,什么时候会同时出现两个异常呢?看下面的代码。
void f()
{
A a; // 没错,就是前面的class A
throw exception;
}
f()抛出异常后,会进行stack-unwinding。在这个过程中,会析构所有的active local object。所谓active local object,就是已经构造完成的局部对象,例如上面的对象a。
调用a的析构函数时,(第一个)异常还没有被catch。可是a的析构函数也抛出了(第二个)异常。这时,两个异常同时存在了。程序会毫不留情地结束!
这个理由足够充分了:再也不要让异常逃离你的析构函数!
异常与构造函数
构造函数本来就是一件难以琢磨的东东,背后做了很多事情:成员对象的构造、基类成分的构造、虚表指针的设置等。这些事情本来就很纠结了,再让构造函数抛出异常,会出现怎样的悲剧呢?
有一点比较安慰:异常即使被抛出构造函数之外,也不会造成程序结束。那么,是否存在资源泄漏的问题呢?不可一概而论,我们分情况分析。
对象自身的内存如何释放
对象有可能在栈上,也可能在堆上,我们分两种情况讨论。
// 对象在栈上
f()
{
A a;
}
// 对象在堆上
f()
{
A * a = new A();
}
如果对象是在栈上,那么函数退栈自然会释放a占用的空间,无需多虑。
如果对象是在堆上,我们还得两种情况讨论:
- 如果是new运算符抛出的异常,那么堆空间还没有分配成功,也就无需释放
- 如果是构造函数抛出的异常,堆空间已经分配成功,那么编译器会负责释放堆空间(Inside The C++ Object Model, p301)
可见,对象本身的内存,是不会泄露的。
成员对象和基类成分怎么办
成员对象和基类成分的内存,会随着对象自身内存的释放而被一起释放,没什么问题。
但是,有一点需要谨记:如果一个对象的构造函数抛出异常,那么该对象的析构函数不会被调用。
原因很简单:如果对象没有被构造完整,析构函数中的某些代码可能会有风险。为了避免这类意外问题,编译器拒绝生成调用析构函数的代码。
那么,成员对象的基类成员对象的析构函数,会被调用吗?如果不会调用,则可能出现资源泄漏。答案是,会被调用。见下面的代码。
class B : class C
{
A a;
A * pa;
public:
B()
{
pa = new A();
}
~B()
{
delete pa;
}
};
如果B的构造函数抛出异常,编译器保证:成员对象a的析构函数、基类C的析构函数会被调用(Inside The C++ Object Model, p301)。
成员指针怎么办
注意上述代码中的pa,它指向一块堆空间,由于B的析构函数不会被调用了,内存就会出现泄漏。
这还真是一个问题,编译器也不能帮我们做更多事情,只能由程序员自己负责释放内存。
我们可能要这样写代码
class B : class C
{
A a;
A * pa;
public:
B()
{
pa = new A();
try {
throw exception;
} catch(...)
{
delete pa; //确保释放pa
throw;
}
}
~B()
{
delete pa;
}
};
这样的代码难看很多,有一种建议的做法就是:用智能指针包装pa。智能指针作为B的成员对象,其析构函数是可以被自动调用的,进而释放pa。
析构函数如何被自动调用
上面提到:
- 普通函数抛出异常时,所有active local object的析构函数都会被调用
- 构造函数抛出异常时,所有成员对象以及基类成分的析构函数都会被调用
那么,这是怎么实现的呢?
我们以第一种情况为例,分析实现细节。看下面的代码:
f()
{
A a1;
if (...) { // 某些条件下,抛出异常
throw exception;
}
A a2;
throw exception; // 总会抛出异常
}
如果L5抛出异常,那么对象a1会被析构。如果L8抛出异常,那么对象a1 a2都要被析构。编译器是怎么知道,什么时候该析构哪些对象的呢?
支持异常机制的编译器,会做一些”簿记“工作,将需要被析构的对象登记在特定的数据结构中。编译器将上述代码分成不同的区段,每个区段中需要被析构的对象,都不相同。
例如,上述代码中,L3 L4~L7 L8就是三个不同的区段:
- 如果L3抛出异常,那么没有对象需要析构
- 如果L4~L7抛出异常,那么a1需要被析构
- 如果L8抛出异常,那么a1和a2都要析构
编译器通过分析代码,簿记这些区段以及需要析构的object list。运行时,根据异常抛出时所在的区段,查找上述的数据结构,就可以知道哪些对象需要被析构。
构造函数抛出异常时,成员对象及基类成分被析构的原理,是类似的。在C++运行时看来,构造函数只是普通的函数而已。
总结
C++的异常机制,给编译器和运行时均带来了一定的复杂度和代价。上述的”簿记“工作,只是冰上一角。
关于异常的使用,也有很多坑。怎么throw 怎么catch,都是有讲究的。有空下次再做总结。
分享到:
相关推荐
### C#编程艺术:构造函数与析构函数的奥秘 C#作为一种强大的面向对象的编程语言,在软件开发领域占据着举足轻重的地位。它不仅受到C++和Java的影响,还结合了现代编程语言的优点,如垃圾回收、类型安全、泛型支持...
本话题聚焦于一个特定的编程概念:当在构造函数中抛出异常时,析构函数是否会执行。这个问题涉及到类对象的生命周期、异常处理以及垃圾回收机制。 首先,我们需要了解构造函数和析构函数的基本概念。构造函数是类的...
- **资源获取即初始化(RAII)**:采用RAII技术,可以在构造函数中获取资源,并在析构函数中释放资源。这种方法可以确保无论何时对象生命周期结束,资源都会被正确释放。 - **异常安全的设计模式**:使用诸如“惰性...
strcpy 函数和类 String 的构造函数、析构函数、赋值函数和重载运算符函数 strcpy 函数是 C 语言中最基本的字符串拷贝函数,它的原型是 `char *strcpy(char *strDest, const char *strSrc);`,其中 `strDest` 是...
本题主要考察的是如何为一个名为`String`的类编写构造函数、拷贝构造函数、析构函数以及赋值运算符。下面将详细解释这些函数的作用以及如何实现它们。 1. **构造函数**: - `String(const char *str = NULL)` 是一...
#### 二、析构函数与异常 在C++中,析构函数是一种特殊的成员函数,它的主要职责是在对象生命周期结束时释放资源。按照C++的标准,析构函数可以抛出异常,但这通常被认为是不好的做法。原因在于,如果析构函数抛出...
在C++中,析构函数的调用顺序与构造函数相反:先调用派生类的析构函数,然后逐级向上调用父类的析构函数,直至最基类的析构函数。 当我们涉及到类的继承时,构造函数和析构函数的调用顺序会变得更加复杂。例如,在...
2. **成员类对象析构函数**:然后,按照与构造函数相反的顺序,依次调用成员对象的析构函数。 3. **基类析构函数**:最后,调用基类的析构函数。 析构函数特别适用于管理动态分配的资源,如内存、文件句柄等,以...
析构函数可以确保即使在异常情况下,这些资源也能得到适当的释放。 然而,需要注意的是,C#的析构函数并不像C++那样是确定性的。在C#中,析构函数的调用时机是不确定的,依赖于垃圾回收机制。这意味着不能依赖析构...
标准C++中定义构造函数是一个对象构建自己,分配所需资源的地方,一旦构造函数执行完毕,则表明这个对象已经诞生了,有自己的行为和内部的运行状态,之后还有对象的消亡过程(析构函数的执行)。可谁能保证对象的...
此外,还包括了析构函数和赋值运算符重载,以确保类对象的生命周期管理和资源释放。 ### `String`类定义 ```cpp class String { public: // 默认构造函数 String(const char* str = NULL); // 拷贝构造函数 ...
了解并熟练运用不同类型的构造函数,以及它们与继承、工厂方法、析构函数的关系,对于编写高效、健壮的C#代码至关重要。在实际开发中,正确使用构造函数能帮助我们更好地管理和控制对象的状态,提高代码的可读性和可...
- 对象包含需要特别释放资源的析构函数,拷贝构造函数需正确处理资源的管理。 ### 如何实现拷贝构造函数 实现拷贝构造函数的关键在于深复制,对于每个数据成员,都需要检查是否需要复制,特别是指针成员,需要分配...
析构函数与构造函数相反,用于对象生命周期结束时清理资源。C#中的析构函数以`~`符号开头,主要用于释放非托管资源,如文件、网络连接等。 例如: ```csharp class ResourceWrapper { private IntPtr handle; ...
最后,尝试在析构函数中抛出异常会导致致命错误。 构造函数和析构函数是PHP中处理对象生命周期的关键工具,它们帮助开发者确保对象的正确创建和清理,从而维持代码的整洁和高效。掌握这两个概念对于编写健壮的面向...
2. **析构函数与异常安全**: C++标准保证在构造函数抛出异常后,会调用已构造部分的析构函数,这被称为基本异常安全保证。然而,它并不包括对未完全构造的基类或成员对象的清理。在上述例子中,`B`的构造函数先...
析构函数与构造函数不同,父类的析构函数不会被引擎自动调用,子类的析构函数体中必须显式调用parent::__destruct()以执行父类的析构函数。如果子类未定义析构函数,则会继承父类的析构函数。 析构函数有一些特殊的...
基类dormitory,其有DormiNum和静态变量DormiMaxNum,构造函数,析构函数,一般函数GetDormiNum(), GetCountMan(), SetDormiNum()及一个纯虚函数display();2. dormitory的派生类room类,内有公有成员变量RN,构造函数、...
1、标准C++中定义构造函数是一个对象构建自己,分配所需资源的地方,一旦构造函数执行完毕,则表明这个对象已经诞生了,有自己的行为和内部的运行状态,之后还有对象的消亡过程(析构函数的执行)。可谁能保证...
### 构造函数与析构函数中的异常处理 #### 异常处理背景 在C++编程语言中,异常处理机制是十分重要的一个方面,尤其是在构造函数和析构函数中的应用。异常处理允许程序在遇到不可预料的情况时进行适当的响应,避免...