前言

在使用资源前,我们需要做一些准备工作保证资源能正常使用,在使用完资源后,我们需要做一些扫尾工作保证资源没有泄露,这就是构造与析构了,这和编程语言是无关的,而是使用资源的一种方式。C++只不过是把这个过程内置到了语言本身,规定了构造函数和析构函数的形式以及执行时机。

编译器的无私奉献

下面这段代码很好理解

#include <iostream>
class A
{
public:
    A()
    {
        std::cout << "A\n";
    }
    ~A()
    {
        std::cout << "~A\n";
    }
};
int main()
{
    A local;
    return 0;
}

如果执行的话,会输出

A
~A

对于一个从C转到C++的人,我就很纠结为什么我没有调用A::A()A::~A(),它们却执行了。

在GDB面前,程序是没有秘密的,因此就让我们开始GDB,看看剥去高级语言的外衣后的程序是什么样子。
用GDB的disassemble命令查看汇编代码,可以看到实际上调用了A::A()callq 0x400888 <A::A()>)和A::~A()callq 0x4008a6 <A::~A()>)。果然没有任何神奇的地方,函数都是需要被调用才会执行的,只不过我没有做的时候,编译器帮我做了。

(gdb) disassemble
Dump of assembler code for function main():
   0x0000000000400806 <+0>:     push   %rbp
   0x0000000000400807 <+1>:     mov    %rsp,%rbp
   0x000000000040080a <+4>:     push   %rbx
   0x000000000040080b <+5>:     sub    $0x18,%rsp
=> 0x000000000040080f <+9>:     lea    -0x11(%rbp),%rax
   0x0000000000400813 <+13>:    mov    %rax,%rdi
   0x0000000000400816 <+16>:    callq  0x400888 <A::A()>
   0x000000000040081b <+21>:    mov    $0x0,%ebx
   0x0000000000400820 <+26>:    lea    -0x11(%rbp),%rax
   0x0000000000400824 <+30>:    mov    %rax,%rdi
   0x0000000000400827 <+33>:    callq  0x4008a6 <A::~A()>
   0x000000000040082c <+38>:    mov    %ebx,%eax
   0x000000000040082e <+40>:    add    $0x18,%rsp
   0x0000000000400832 <+44>:    pop    %rbx
   0x0000000000400833 <+45>:    pop    %rbp
   0x0000000000400834 <+46>:    retq
End of assembler dump.

编译器除了帮我们调用构造函数和析构函数外,如果我们没有写构造函数和析构函数,编译器会帮我们补上默认的构造函数和析构函数吗?
在下面的情况下,编译器会帮我们补上默认的构造函数

  • 类成员变量有构造函数:默认的构造函数里就是为了调用一下类成员变量的构造函数
  • 类的父类有构造函数:默认的构造函数就是为了调用一下父类的构造函数。父类是否有默认构造函数,同样取决于上一种情况。
  • 类的父类有虚函数:默认的构造函数就是为了设置一下虚函数表

在下面的情况下,编译器会帮我们补上默认的析构函数

  • 类成员变量有自己的析构函数:默认的析构函数里就只是为了调用一下类成员变量的析构函数
  • 类的父类有自己的析构函数:默认的析构函数为了调用父类的析构函数

从上面我们也可以看出编译器不做无用之事。当不需要构造函数或析构函数时,编译器就不会补上默认的构造函数和析构函数。我们知道C语言中是没有构造函数和析构函数的,可以简单的认为符合C语言语法的自定义类型,编译器都不会补上默认的构造函数和析构函数。大家可以了解下POD类型

”符合C语言语法的自定义类型“的描述是不准确的,这是在将class视为struct,忽略权限关键字publicprotectedprivate的基础上说的,毕竟C中没有这些关键字。

构造和析构的时机

下面描述的前提是存在构造函数和析构函数

当实例化对象时,会执行构造函数,而实例化对象分为两种情况

  • 定义变量,如A a;。需要特别注意的是通过thread_local修饰定义的变量,在首次在线程中使用时才会执行构造函数。
  • new实例化,如new A;

    构造函数是无法主动调用的

当对象存储期结束时,就会执行析构函数,存储期分为

  • 静态存储期:进程退出时执行析构函数,如全局变量和静态局部变量
  • 自动存储期:离开变量的作用域时执行析构函数,如普通局部变量
  • 动态存储期:new实例化的对象,在delete时会执行析构函数。
  • 线程存储期:线程退出时执行析构函数,如thread_local修饰的变量

    因为析构函数是可以主动调用的,所以delete也可以只释放内存而不调用析构函数。

构造和析构的顺序

顺序就一句话:先构造后析构。分两部分来理解

  • 为什么需要先构造后析构
  • 如何实现先构造后析构

    先构造意味着先定义,但这只在同一文件中生效,不同文件之间的全局变量构造顺序是不确定的。

为什么需要先构造后析构

原因很朴素:先构造的对象说明其可能会被后续的对象使用,因此为了程序运行安全,必须等到其使用者结束使用后,才能析构该对象即在那些之后构造的对象析构后才能析构。

先构造后析构也是保证我们安全使用资源的一个原则。假设我们把一个功能的初始化封装为init(),把功能的销毁封装为destroy(),一般destroy()中资源销毁的顺序是init()中资源申请的逆序。

基于以上,我们就能很容易的理解

  • 为什么父类的构造函数先执行:因为本类的构造函数可能要用到父类的东西
  • 为什么类成员变量的构造函数先执行:因为本类的构造函数内可能要用到类成员变量

如何实现先构造后析构

普通局部变量的先构造后析构,就是编译器按照定义顺序插入对应的构造函数,然后再逆序插入析构函数。

int main()
{
    A local_1;
    A local_2;
    return 0;
}

其汇编如下

0x000000000040088f <+9>:     lea    -0x12(%rbp),%rax
0x0000000000400893 <+13>:    mov    %rax,%rdi               # local_1的地址
0x0000000000400896 <+16>:    callq  0x40093c <A::A()>
0x000000000040089b <+21>:    lea    -0x11(%rbp),%rax
0x000000000040089f <+25>:    mov    %rax,%rdi                 # local_2的地址
0x00000000004008a2 <+28>:    callq  0x40093c <A::A()>
0x00000000004008a7 <+33>:    mov    $0x0,%ebx
0x00000000004008ac <+38>:    lea    -0x11(%rbp),%rax
0x00000000004008b0 <+42>:    mov    %rax,%rdi                 # local_2的地址
0x00000000004008bf <+57>:    callq  0x40095a <A::~A()>
0x00000000004008c4 <+62>:    mov    %ebx,%eax
0x00000000004008c6 <+64>:    jmp    0x4008e2 <main()+92>
0x00000000004008c8 <+66>:    mov    %rax,%rbx
0x00000000004008cb <+69>:    lea    -0x12(%rbp),%rax
0x00000000004008cf <+73>:    mov    %rax,%rdi                # local_1的地址
0x00000000004008d2 <+76>:    callq  0x40095a <A::~A()>

全局变量和静态局部变量在析构函数的设置上稍有差别。

A global;
int main()
{
    return 0;
}

因为全局变量的构造在main()之前,所以在A::A()上设置断点。执行后,打印调用栈如下

(gdb) bt
#0  A::A (this=0x601171 <gloabl>) at main.cpp:15
#1  0x0000000000400856 in __static_initialization_and_destruction_0 (
    __initialize_p=1, __priority=65535) at main.cpp:23
#2  0x0000000000400880 in _GLOBAL__sub_I_gloabl () at main.cpp:27
#3  0x000000000040090d in __libc_csu_init ()
#4  0x00007ffff771fe55 in __libc_start_main (main=0x400806 <main()>, argc=1,
    argv=0x7fffffffec48, init=0x4008c0 <__libc_csu_init>,
    fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffec38)
    at libc-start.c:246
#5  0x0000000000400739 in _start ()

让我们回到frame 1截取一小段它的汇编代码

   0x0000000000400851 <+64>:    callq  0x400882 <A::A()>
=> 0x0000000000400856 <+69>:    mov    $0x601058,%edx
   0x000000000040085b <+74>:    mov    $0x601171,%esi
   0x0000000000400860 <+79>:    mov    $0x4008a0,%edi
   0x0000000000400865 <+84>:    callq  0x4006d0 <__cxa_atexit@plt>

我们发现在执行完A::A()后,还调用了__cxa_atexit@plt。看到__cxa_atexit@plt,有没有觉得很熟悉,是不是立即就想到int atexit( void (*function)(void))。我们知道atexit()用于注册一个在进程退出时执行的函数,那么这里是不是注册了全局变量的析构函数?
mov $0x4008a0,%edi就是给`__cxa_atexit@plt传递参数,可知注册的函数地址是0x4008a0。我们可以轻易地发现A::~A()的地址就是0x4008a0

类成员函数的第一个参数是thismov $0x601171,%esi0x601171就是变量global的地址。

由此我们知道全局变量的析构函数是在执行构造函数后,注册为进程退出时的执行函数。我们知道通过atexit()注册的函数的执行顺序是先注册的后执行即FILO,__cxa_atexit也是一样,也就实现了先构造后析构。静态局部变量的析构函数也是一样的设置方式

假设一个类继承自一个有构造函数的类,且其成员变量也拥有构造函数。我们知道会先执行父类和成员变量的构造函数,然后再执行本类的构造函数。按这样的描述,岂不是要在每个实例化该类的地方加上很多额外的代码了,编译器会这么蠢么?让我们来看一下

class A
{
public:
    A(){}
    ~A(){}
};
class B
{
public:
    B(){}
    ~B(){}
};
class C : public A
{
public:
    C(){ std::cout << "C\n" };
    ~C(){}
    B a;
};
int main()
{
    C local;
    return 0;
}

查看汇编,可知实际调用的仍是C::C(),并非在C::C()之前插入A和B的构造函数,

   0x00000000004006e6 <+16>:    callq  0x400788 <C::C()>
   0x00000000004006eb <+21>:    mov    $0x0,%ebx
   0x00000000004006f0 <+26>:    lea    -0x11(%rbp),%rax
   0x00000000004006f4 <+30>:    mov    %rax,%rdi
   0x00000000004006f7 <+33>:    callq  0x4007b0 <C::~C()>

而是在C::C()的第一行代码前,插入了A和B的构造函数。

Dump of assembler code for function C::C():
   0x0000000000400788 <+0>:     push   %rbp
=> 0x0000000000400789 <+1>:     mov    %rsp,%rbp
   0x000000000040078c <+4>:     sub    $0x10,%rsp
   0x0000000000400790 <+8>:     mov    %rdi,-0x8(%rbp)
   0x0000000000400794 <+12>:    mov    -0x8(%rbp),%rax
   0x0000000000400798 <+16>:    mov    %rax,%rdi
   0x000000000040079b <+19>:    callq  0x400758 <A::A()>
   0x00000000004007a0 <+24>:    mov    -0x8(%rbp),%rax
   0x00000000004007a4 <+28>:    mov    %rax,%rdi
   0x00000000004007a7 <+31>:    callq  0x400770 <B::B()>
   0x00000000004007ac <+36>:    nop
   0x00000000004007ad <+37>:    leaveq
   0x00000000004007ae <+38>:    retq

当然C::~C()的最后一行代码之后,也会插入A和B的析构函数。

常见问题

问题通常都来自于错误的构造顺序。
一种情况是a.cpp中定义的全局变量A使用了b.cpp中定义的全局变量B,实际A先构造,此时A使用到了还未构造的B,程序会出现异常。建议是保证不同全局变量之间是独立的。如果存在使用关系,则定义为指针类型,延迟到main()中再按预期的顺序依次实例化。

还有一种更常见的情况是使用了静态局部变量。因为静态局部变量包含在函数内部,更隐晦,所以更容易出现问题。问题通常是在进程退出时出现的,静态局部变量先析构了,导致程序异常。

后话

构造与析构是一种资源使用机制,我们常用C++的构造函数和析构函数来实现RAII(Resource Acquisition Is Initialization),保证诸如锁、内存等资源的正确使用和释放。

以上的代码都在www.onlinegdb.com上运行调试的。不同平台,不同编译器,其底层实现会存在差异,高级语言本就是为了隐藏这些底层差异,因此不必纠结于具体实现,而是要关注思维方式。

C++系列总结——构造与析构的更多相关文章

  1. STL—对象的构造与析构

    STL内存空间的配置/释放与对象内容的构造/析构,是分开进行的.   对象的构造.析构         对象的构造由construct函数完成,该函数内部调用定位new运算符,在指定的内存位置构造对象 ...

  2. C++浅析——继承类中构造和析构顺序

    先看测试代码,CTEST 继承自CBase,并包含一个CMember成员对象: static int nIndex = 1; class CMember { public: CMember() { p ...

  3. Effective C++ -----条款09:绝不在构造和析构过程中调用virtual函数

    在构造和析构期间不要调用virtual函数,因为这类调用从不下降至derived class(比起当前执行构造函数和析构函数的那层).

  4. 【09】绝不在构造和析构过程中调用virtual方法

    1.绝不在构造和析构过程中调用virtual方法,为啥? 原因很简单,对于前者,这种情况下,子类专有成分还没有构造,对于后者,子类专有成分已经销毁,因此调用的并不是子类重写的方法,这不是程序员所期望的 ...

  5. C++不能中断构造函数来拒绝产生对象(在构造和析构中抛出异常)

    这是我的感觉,具体需要研究一下- 找到一篇文章:在构造和析构中抛出异常 测试验证在类构造和析构中抛出异常, 是否会调用该类析构. 如果在一个类成员函数中抛异常, 可以进入该类的析构函数. /// @f ...

  6. STL——空间配置器(构造和析构基本工具)

    以STL的运用角度而言,空间配置器是最不需要介绍的东西,它总是隐藏在一切组件(更具体地说是指容器,container)的背后,默默工作,默默付出.但若以STL的实现角度而言,第一个需要介绍的就是空间配 ...

  7. 魔法方法:构造和析构 - 零基础入门学习Python041

    魔法方法:构造和析构 让编程改变世界 Change the world by program 构造和析构 什么是魔法方法呢?我们来系统总结下: - 魔法方法总是被双下划线包围,例如__init__ - ...

  8. 再探Delphi2010 Class的构造和析构顺序

    发了上一篇博客.盒子上有朋友认为Class的构造和析构延迟加载.是在Unit的初始化后调用的Class的构造.在Unit的反初始化前调用的Class的析构函数. 为了证明一下我又做了个试验 unit ...

  9. Effective C++_笔记_条款09_绝不在构造和析构过程中调用virtual函数

    (整理自Effctive C++,转载请注明.整理者:华科小涛@http://www.cnblogs.com/hust-ghtao/) 为方便采用书上的例子,先提出问题,在说解决方案. 1 问题 1: ...

随机推荐

  1. javascript 编程风格 部分精要

    1 换行保持两个缩进(通常是一行太长) 运算符前后加一个空格,包括赋值运算符和逻辑运算符 括号运算符,左括号之后,右括号之前不应该有空格 段代码无关,添加空行 命名驼峰式,一般首字母小写,其他单词首字 ...

  2. python | Elasticsearch-dsl常用方法总结(join为案例)

    Elasticsearch DSL是一个高级库,其目的是帮助编写和运行针对Elasticsearch的查询.它建立在官方低级客户端(elasticsearch-py)之上. 它提供了一种更方便和习惯的 ...

  3. S-CMS企建v3二次SQL注入

    S-CMS企建v3二次SQL注入 0x01 前言 继上一篇的S-CMS漏洞再来一波!首发T00ls 0x2 目录 Sql注入二次SQL注入 0x03 Sql注入 漏洞文件:\scms\bbs\bbs. ...

  4. Android单元测试之一:基本概念

    Android单元测试之一:基本概念 简单介绍 单元测试是应用程序测试策略中的基本测试,通过对代码进行单元测试,一方面可以轻松地验证单个单元的逻辑是否正确,另一方面在每次构建之后运行单元测试,可以快读 ...

  5. flex弹性布局心得

    概述 最近做项目用flex重构了一下网页中的布局,顺便学习了一下flex弹性布局,感觉超级强大,有一些心得,记录下来供以后开发时参考,相信对其他人也有用. 参考资料: Solved by Flexbo ...

  6. [Swift]LeetCode22. 括号生成 | Generate Parentheses

    Given n pairs of parentheses, write a function to generate all combinations of well-formed parenthes ...

  7. Spring Boot 最核心的 25 个注解,都是干货!

    学习和应用 Spring Boot 有一些时间了,你们对 Spring Boot 注解了解有多少呢?今天栈长我给大家整理了 Spring Boot 最核心的 25 个注解,都是干货! 你所需具备的基础 ...

  8. MySQL casting from decimal to string(mysql decimal 转 varchar)

    今天群里一个哥们问我mysql怎么将decimal转成varchar,经过查阅资料发现,mysql好像不能将decimal直接转换成varchar,但是可以转成char,原文链接:http://sta ...

  9. RAC集群数据库连库代码示例(jdbc thin方式,非oci)

    1.RAC集群数据库连库代码示例(jdbc thin方式,非oci):jdbc.driverClassName=oracle.jdbc.driver.OracleDriverjdbc.url=jdbc ...

  10. redis 系列22 复制Replication (下)

    一. 复制环境准备 1.1 主库环境(172.168.18.201) 环境 说明 操作系统版本 CentOS  7.4.1708  IP地址 172.168.18.201 网关Gateway 172. ...