C++ 的析构函数,通常是用来在生命周期结束时释放对象的。最近看到了关于析构函数的一些坑,本文会有介绍,并不是最全的,但也算是一些记录。
1、什么时候编译器会生成析构函数?
每一个类都会存在析构函数,对于类类型(class type),如 struct
、class
、union
这样的,如果没有自定义析构函数,那么编译器就会为它们生成内联(inline)、public 的析构函数。
对于一般的类类型而言,通常其生成的析构函数会是空的(empty body),所以在内联之后,直接就等同于消失了。
什么时候会看到有实现体的析构函数?
通常是继承链当中,存在某个类自定义了析构函数,那么编译器为了满足继承链上的析构,会为继承链上该类的每个子类都生成有实现体的析构函数。
1
2
3
4
5
6
7
8
|
class Grand{};
class Father : public Grand {
public:
~Father() { } // 自定义析构函数
};
class Child : public Father{};
|
在上面这个案例当中,编译器会为 Child 类生成有实现体的析构函数,后面会有讲到原因,作为对比,Grand 类的是 empty body,直接 inline 消失了。
还有一个前提,那就是代码中需要存在对 Child 类的使用,不然编译器直接就不用生成它的析构函数的实现体了。
这是什么原因?因为在语法树当中,编译器首先会给它生成 implicit、inline、default 属性的析构函数声明。
1
2
3
|
|-CXXRecordDecl referenced class Child definition
| |-CXXDestructorDecl implicit used ~Child 'void () noexcept(false)' inline default
| | `-CompoundStmt
|
在代码中,如果存在对 Child 类的析构函数的使用需要,这时候编译器才开始为其生成相关的实现体(LLVM 实现)。
2、继承链的析构函数
2.1、栈对象
对于栈上的对象而言,析构函数会顺着继承链逆向进行调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class Father{
public:
~Father() { printf("fa "); }
};
class Child : public Father{
public:
~Child() { printf("ca "); }
};
int main() {
Child c;
}
// output:
// ca fa
|
为什么可以调到 Father 类里面的析构函数?来看下编译器生成的简单的汇编指令(为讲解方便,删去了其他指令):
1
2
3
4
5
6
|
Father::~Father() [base object destructor]:
ret
Child::~Child() [base object destructor]:
bl Father::~Father() [base object destructor]
main:
bl Child::~Child() [complete object destructor]
|
实际上,自定义的析构函数并非完全是自定义的行为,编译器会给子类的析构函数添加调用指令,从而能够调用继承链上自定义的析构函数。
2.2、堆对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class Father{
public:
~Father() { printf("fa\n"); }
};
class Child : public Father{
public:
~Child() { printf("ca\n"); }
};
int main() {
Father *f = new Child;
delete f;
}
// output:
// fa
|
注意,这里的析构函数的调用,是由 delete
引发的,因为堆对象是由程序员自行控制其内存。
这里只调用了 Father 类的析构函数,是因为编译器生成的析构函数调用,是根据其类型来进行判断的,而这里的变量 f 是 Father 类型。
1
2
|
main:
bl Father::~Father() [complete object destructor]
|
那么怎么样才能让编译器识别到需要从 Child 的析构函数调起呢?
答案是使用 virtual
标注。
1
2
3
4
|
class Father{
public:
virtual ~Father() { printf("fa\n"); }
};
|
这样编译器就认识到,这个 Father 类的析构函数是个虚函数,会走虚表调用函数的方式,从实际对象类型的析构函数调起。如果不使用 virtual
的话,就会导致 UB (undefined behavior) 操作。
2.3、访问权限(access)
对于析构函数的调用,需要是 public 的访问权限,否则会导致编译错误。
1
2
|
error: base class 'Father' has private destructor
variable of type 'Child' has private destructor
|
实际上,对于访问权限的保证,在同一个编译单元内,是由编译器来进行保证的。而在不同的编译单元内,是由链接符号(private 的函数不会对外暴露符号)来进行保证的。
在语法树层面,会专门有一个 Decl 来表明其下方所有的 Decl 的访问权限。默认没找到则按其类类型的默认选项来确定(class 为 private,struct 为 public)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class Father{
public:
void test() {}
private:
void func2() {}
};
/*
|-CXXRecordDecl referenced class Father definition
| |-AccessSpecDecl public
| |-CXXMethodDecl test 'void ()'
| |-AccessSpecDecl private
| |-CXXMethodDecl func2 'void ()'
*/
|
3、纯虚析构函数
对于基类的纯虚析构函数,其必须要有定义存在,否则会存在链接报错,因为基类的析构函数,会在子类被析构的时候调用到。
1
2
3
4
5
6
7
8
|
class AbstractBase {
public:
virtual ~AbstractBase() = 0;
};
AbstractBase::~AbstractBase() {}
class Derived : public AbstractBase {};
// AbstractBase obj; // compiler error
Derived obj; // OK
|
4、析构函数里的调用
猜猜下面这段代码会输出什么结果?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
class Father {
public:
virtual void test() { printf("Father\n"); }
virtual void normalVirtual() { printf("1 "); test(); }
virtual ~Father() { test(); }
};
class Child : public Father{
public:
void test() { printf("Child\n"); }
void normalVirtual() { printf("2 "); test(); }
~Child() { test(); }
};
int main() {
Father *f = new Child;
f->normalVirtual();
delete f;
}
// output:
// 2 Child
// Child
// Father
|
其实这段代码的结果,结合本文 2.2 的分析,编译器会为 Child::~Child()
插入对 Father::~Father()
的调用,走直接调用方式,所以这里两个类的 test 函数都会调用到 。
而 normalVirtual
函数是虚函数,所以会调用到 Child::normalVirtual()
函数,走虚表方式调用了 test 函数。
1
2
3
4
5
6
7
8
9
10
11
|
Father::normalVirtual():
bl vptr->test()
Child::normalVirtual():
bl vptr->test()
Father::~Father() [base object destructor]:
bl Father::test()
Child::~Child() [base object destructor]:
bl Child::test()
main:
bl vptr->normalVirtual()
bl vptr->~析构函数()
|
但为什么普通虚函数和析构函数的调用产生的行为会不一样?一个是直接调用,而另一个是虚表调用。
Standard mandates that the runtime type of the object is that of the class being constructed/destructed at this time, even if the original object that is being constructed/destructed is of a derived type.
C++ 标准里面规定了构造和析构是采用运行时类型,即使当前实际对象是其子类,所以构造函数和析构函数走的都是直接调用的方式。
5、析构函数与异常机制
很多文章都描述了,如果在析构函数里,没有进行捕获住内部异常的操作是非常危险的行为。
C++ 11 之后,析构函数默认是 noexcept(true)
的,会导致异常逃出析构函数后,程序被中止(terminate)。
为了能够传递异常,需要标注 noexcept(false)
来进行捕获。
那么危险在哪里呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class Bad {
public:
~Bad() noexcept(false) {
throw 1;
}
};
int main() {
try {
Bad bad;
throw 2; // 如果没有这行,程序正常,异常能够捕获
} catch(...) {
std::cout << "Never print this\n";
}
}
|
危险就在于析构函数可能是在栈展开(Stack unwinding)的时候被调用,也就是正在进行异常处理的过程中,如果又出现一个异常抛出,这时候程序被中止,因为不允许同时处理两个及以上数量的异常。
安全起见,析构函数也需要包一层异常的捕获。
1
2
3
4
5
6
7
8
9
10
|
class Bad {
public:
~Bad() noexcept(false) {
try {
throw 1;
} catch(...) {
std::cout << "Cover it\n";
}
}
};
|
5.1、智能指针
在使用智能指针时,析构函数的异常一定不能被抛到外面去,即使外面能够捕获且外面无异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class Bad {
public:
~Bad() noexcept(false) {
throw 1;
}
};
int main() {
try{
std::shared_ptr<Bad> bad = std::make_shared<Bad>();
// 使用下面这种普通指针,可以正常捕获异常,运行程序
// Bad *b = new Bad;
// delete b;
}
catch(...) {
std::cout << "Never print this\n";
}
}
|
参考
Destructors:https://en.cppreference.com/w/cpp/language/destructor
How Does Virtual Destructor Works:http://www.vishalchovatiya.com/part-3-all-about-virtual-keyword-in-c-how-virtual-destructor-works/
throwing exceptions out of a destructor:https://stackoverflow.com/questions/130117/throwing-exceptions-out-of-a-destructor