深入理解析构函数与生命周期

C++ 对象生命周期

C++ 中一个类可以具有构造函数和析构函数。

  • 构造函数固定为 类名(构造函数参数列表)
  • 析构函数固定为 ~类名()

  • 构造函数和析构函数都没有返回值类型。

  • 析构函数不得拥有任何参数,但构造函数可以有。
struct Class {
    Class() {
        puts("构造函数");
    }

    ~Class() {
        puts("析构函数");
    }
};
int main() {
    puts("进入 main");
    Class c;
    puts("离开 main");
}

运行结果:

进入 main
构造函数
离开 main
析构函数

这是 C++ 中最基本的现象。每当一个对象被创建时,会调用构造函数,每当一个对象离开定义了他的函数体时,会调用析构函数。

函数体指的是从 {} 之间的代码块。

其中构造函数中通常负责创建资源,析构函数中通常销毁资源。对于智能指针和 vector 而言,这个资源就是内存。

为什么要及时销毁不用的资源?只分配不释放,一个程序占用的内存和其他各种资源就会越来越多,这种程序如果长期运行,会吃光整个系统的所有资源然后被 Linux 内核视为危险进程而杀死。除非你的程序只会运行一会会,如果是长期运行的程序,例如服务器,必须严格管理所有自己曾经分配过的内存,不用时就立即释放,不要占着茅坑不拉史。

} 被誉为最伟大的运算符,就是因为他可以触发析构函数,帮你自动释放掉资源,你就不用自己费心手动释放内存,和其他各种资源了。

三大存储周期

在进一步深入之前,我们必须明确以下术语:自动存储周期、动态存储周期、静态存储周期。

变量定义在不同的位置,其生命周期(构造函数和析构函数调用的时机)会有所不同。

比如一个变量定义在函数体内、类体内、通过 new 创建,之类的。

  1. 自动存储周期,这种变量直接定义在函数体内。俗称“栈上”或“局部变量”
void func() {
    Class a;  // a 是自动存储周期
}
  • 构造时机:当变量定义时被调用。
  • 析构时机:当变量所在的 {} 代码块执行到 } 处时调用。
  1. 动态存储周期,这种变量通过 new 来创建。俗称“堆上”或“堆对象”
void func() {
    Class *p = new Class;  // *p 是动态存储周期
    delete p;              // 释放动态分配的内存
}
  • 构造时机:当变量通过 new 创建时被调用。
  • 析构时机:当 delete 被调用时被调用。

特别注意,p 依然是“栈上变量”,p 指向的 *p 才是“堆上变量”!

用律师语再说一遍:p 是自动存储周期,p 指向的 *p 才是动态存储周期!(白律师最满意的一集)

指针本身,和指针指向的对象,是两个东西,不要混淆。

p 本身会随着 func 的 } 而析构,但是 *p 的类型是 Class *,是一个 C 语言原始指针,原始指针属于 C 语言原始类型,没有析构函数。也就是说,抵达 } 时,p 名义上会析构,但是他没有析构函数,并不会产生任何作用。这一切和 p 指向的对象 *p 没有任何关系,你需要手动 delete 才会调用到 *p 的析构函数,并释放分配的内存。

  • new 分为两部分:内存分配 + 对象构造
  • delete 分为两部分:对象析构 + 内存释放

智能指针的优势在于,智能指针是个 C++ 类,具有定制的析构函数。当 } 抵达,智能指针本身由于自动存储周期的规则析构时,其会 delete p,利用动态存储周期的规则,触发智能指针指向对象的析构函数,也就是从而调用 *p 的析构函数。

  1. 静态存储周期,这种变量又要具体分三种情况,俗称“全局变量”或“静态变量”

(1) 定义在名字空间内,不论是不是 static 或 inline(在名字空间中,static 和 inline 影响的只是“符号可见性”,而不是存储周期)

namespace hello {
Class s;         // s 是静态存储周期
static Class s;  // s 是静态存储周期
inline Class s;  // s 是静态存储周期
}
  • 构造时机:当程序启动时调用(main 函数之前);对 DLL 来说则是 DLL 首次加载时调用。
  • 析构时机:当程序退出时调用(main 函数之后)。

(2) 注意,全局名字空间是一个特殊的名字空间,外面没有包裹任何 namespace 时就属于这种情况,俗称“全局变量”。所以下面这种也属于“在 (全局) 名字空间内”:

Class s;         // s 是静态存储周期
static Class s;  // s 是静态存储周期
inline Class s;  // s 是静态存储周期

(3) 定义在类内的静态成员变量,也就是通过 static 修饰过的成员变量(在类内,static 就影响存储周期了,inline 继续只影响“符号可见性”)

struct Other {
    static Class s;
};
Class Other::s;             // s 是静态存储周期

struct Other {
    inline static Class s;  // s 是静态存储周期
};

struct Other {
    Class a;                // a 不是静态存储周期,而是跟随其所属的 Other 结构体的存储周期
};
  1. 定义在类内的成员变量(没有 static 的),跟随所属类的存储周期
struct Other {
    Class a;  // a 跟随 Other 结构体的存储周期
};

Other o;      // o.a 是静态存储周期

int main() {
    Other o;  // o.a  是自动存储周期
    Other *p; // p->a 是动态存储周期
}
  • 构造时机:当 Other 结构体构造时调用。
  • 析构时机:当 Other 结构体析构时调用。

总结

  • 自动存储周期 - 函数的局部变量,自动析构
  • 动态存储周期 - 通过 new 创建的,delete 时析构
  • 静态存储周期 - 全局变量,程序结束时析构

析构函数的逆天大坑

定义了析构函数,就必须删除移动构造函数、移动赋值函数、拷贝构造函数、拷贝赋值函数

原因很复杂,整个故事要从 boost 当年如何设计出右值引用到图灵的停机问题讲起,讲了你也记不住,只需要记住结论:

如果你要定义析构函数,就必须删除移动构造函数、移动赋值函数、拷贝构造函数、拷贝赋值函数

虚类的析构函数必须是虚的

  • -Wnon-virtual-dtor
  • -Wdelete-non-virtual-dtor

TODO: 介绍

临时变量的生命周期是一行

TODO

int main() {
    std::string const &s = std::string("hello");
    std::cout << s;  // OK
}
std::string const &identity(std::string const &s) {
    return s;
}

int main() {
    std::string const &s = identity(std::string("hello"));
    std::cout << s;  // BOOM!
}