IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> C++知识库 -> C++ 析构函数的坑 -> 正文阅读

[C++知识库]C++ 析构函数的坑

C++ 的析构函数,通常是用来在生命周期结束时释放对象的。最近看到了关于析构函数的一些坑,本文会有介绍,并不是最全的,但也算是一些记录。

1、什么时候编译器会生成析构函数?

每一个类都会存在析构函数,对于类类型(class type),如?structclassunion?这样的,如果没有自定义析构函数,那么编译器就会为它们生成内联(inline)、public 的析构函数。

对于一般的类类型而言,通常其生成的析构函数会是空的(empty body),所以在内联之后,直接就等同于消失了。

什么时候会看到有实现体的析构函数?

通常是继承链当中,存在某个类自定义了析构函数,那么编译器为了满足继承链上的析构,会为继承链上该类的每个子类都生成有实现体的析构函数。

class Grand{};

class Father : public Grand {
public:
    ~Father() {  } // 自定义析构函数
};

class Child : public Father{};

在上面这个案例当中,编译器会为 Child 类生成有实现体的析构函数,后面会有讲到原因,作为对比,Grand 类的是 empty body,直接 inline 消失了。

还有一个前提,那就是代码中需要存在对 Child 类的使用,不然编译器直接就不用生成它的析构函数的实现体了。

这是什么原因?因为在语法树当中,编译器首先会给它生成 implicit、inline、default 属性的析构函数声明。

|-CXXRecordDecl referenced class Child definition
| |-CXXDestructorDecl implicit used ~Child 'void () noexcept(false)' inline default
| | `-CompoundStmt

在代码中,如果存在对 Child 类的析构函数的使用需要,这时候编译器才开始为其生成相关的实现体(LLVM 实现)。

2、继承链的析构函数

2.1、栈对象

对于栈上的对象而言,析构函数会顺着继承链逆向进行调用:

class Father{
public:
    ~Father() { printf("fa "); }
};

class Child : public Father{
public:
    ~Child() { printf("ca "); }
};

int main() {
    Child c;
}
// output:
// ca fa

为什么可以调到 Father 类里面的析构函数?来看下编译器生成的简单的汇编指令(为讲解方便,删去了其他指令):

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、堆对象

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 类型。

main:
    bl  Father::~Father() [complete object destructor]

那么怎么样才能让编译器识别到需要从 Child 的析构函数调起呢?

答案是使用?virtual?标注。

class Father{
public:
    virtual ~Father() { printf("fa\n"); }
};

这样编译器就认识到,这个 Father 类的析构函数是个虚函数,会走虚表调用函数的方式,从实际对象类型的析构函数调起。如果不使用?virtual?的话,就会导致 UB (undefined behavior) 操作。

2.3、访问权限(access)

对于析构函数的调用,需要是 public 的访问权限,否则会导致编译错误。

error: base class 'Father' has private destructor
variable of type 'Child' has private destructor

实际上,对于访问权限的保证,在同一个编译单元内,是由编译器来进行保证的。而在不同的编译单元内,是由链接符号(private 的函数不会对外暴露符号)来进行保证的。

在语法树层面,会专门有一个 Decl 来表明其下方所有的 Decl 的访问权限。默认没找到则按其类类型的默认选项来确定(class 为 private,struct 为 public)。

class Father{
public:
    void test() {}
private:
    void func2() {}
};

/*
|-CXXRecordDecl referenced class Father definition
| |-AccessSpecDecl public
| |-CXXMethodDecl test 'void ()'
| |-AccessSpecDecl private
| |-CXXMethodDecl func2 'void ()'
*/

3、纯虚析构函数

对于基类的纯虚析构函数,其必须要有定义存在,否则会存在链接报错,因为基类的析构函数,会在子类被析构的时候调用到。

class AbstractBase {
 public:
    virtual ~AbstractBase() = 0;
};
AbstractBase::~AbstractBase() {}
class Derived : public AbstractBase {};
// AbstractBase obj;   // compiler error
Derived obj;           // OK

4、析构函数里的调用

猜猜下面这段代码会输出什么结果?

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 函数。

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)?来进行捕获。

那么危险在哪里呢?

class Bad {
public:
    ~Bad() noexcept(false) {
        throw 1;
    }
};

int main() {
    try {
        Bad bad;
        throw 2; // 如果没有这行,程序正常,异常能够捕获
    } catch(...) {
        std::cout << "Never print this\n";
    }
}

危险就在于析构函数可能是在栈展开(Stack unwinding)的时候被调用,也就是正在进行异常处理的过程中,如果又出现一个异常抛出,这时候程序被中止,因为不允许同时处理两个及以上数量的异常。

安全起见,析构函数也需要包一层异常的捕获。

class Bad {
public:
    ~Bad() noexcept(false) {
        try {
            throw 1;
        } catch(...) {
            std::cout << "Cover it\n";
        }
    }
};

5.1、智能指针

在使用智能指针时,析构函数的异常一定不能被抛到外面去,即使外面能够捕获且外面无异常。

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/quest

  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2021-08-17 01:23:14  更:2021-08-17 01:23:36 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年12日历 -2024/12/26 16:45:52-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码
数据统计