C++ 里一个对象从创建到销毁,每一步都有一个函数负责:构造、拷贝、移动、赋值、析构。大多数时候你一个都不写,编译器替你生成;可类里一旦管着一块堆内存、一个文件或一把锁,生成的那些版本就会出错。这页从编译器生成了什么讲起,到 RAII 和异常为止,它们说的其实是同一件事:资源的释放要绑在对象的析构上。
1. 六个特殊成员函数:编译器替你写了什么
1.1 六个函数,默认都是“按成员逐个来”
| 函数 | 签名 | 什么时候调用 |
|---|---|---|
| 默认构造 | T() | T a; |
| 拷贝构造 | T(const T&) | T b = a;、按值传参 |
| 拷贝赋值 | T& operator=(const T&) | b = a;(b 已经存在) |
| 移动构造 | T(T&&) | T b = std::move(a);、返回局部对象 |
| 移动赋值 | T& operator=(T&&) | b = std::move(a); |
| 析构 | ~T() | 对象离开作用域、delete |
你不写,编译器生成的版本就是对每个成员(和基类)各做一遍同样的事:拷贝构造就是逐个拷贝成员,析构就是逐个析构成员。这对 int、std::string、std::vector 这类成员都对,因为它们自己知道怎么拷、怎么析构。
生成的默认构造也是逐个默认构造成员,可 int、指针这种内置类型的“默认构造”什么都不做。局部对象里没写初值的 int 成员是不确定的值,不是 0,所以内置类型的成员最好直接在声明处给初值:int count_ = 0;。
1.2 写了一个,别的会被删掉或退化
编译器并不总是六个都生成。规则的出发点是:你亲手写了其中一个,说明这个类在管什么特殊的东西,编译器就不敢再替你生成某些别的。
| 你写了 | 默认构造 | 拷贝构造 / 拷贝赋值 | 移动构造 / 移动赋值 |
|---|---|---|---|
| 什么都没写 | 生成 | 生成 | 生成 |
| 任何一个普通构造函数(拷贝、移动以外) | 不生成 | 生成 | 生成 |
| 析构函数 | 生成 | 生成(已不推荐依赖) | 不生成,退化成拷贝 |
| 拷贝构造或拷贝赋值 | 写了拷贝构造就不生成 | 另一个生成(已不推荐依赖) | 不生成,退化成拷贝 |
| 移动构造或移动赋值 | 写了移动构造就不生成 | 删除 | 另一个不生成 |
最容易踩的是第三行:只写了一个析构函数(哪怕是空的),移动操作就不生成了,std::move 过去调的是拷贝。下面这段程序里,OnlyDtor 只多了一个空析构函数:
struct Nothing { Tracer t; };
struct OnlyDtor { Tracer t; ~OnlyDtor() {} };
Nothing a; Nothing b = std::move(a); // 输出:move ctor
OnlyDtor c; OnlyDtor d = std::move(c); // 输出:copy ctor
(Tracer 是一个在每个特殊成员函数里打印一行的类。)std::is_move_constructible_v<OnlyDtor> 照样是真的:类型特征只问“能不能用右值构造”,拷贝构造接受 const T&,右值也能绑上去。所以移动“退化成拷贝”时没有任何报错,只是变慢了。反过来,只写了移动构造的类,拷贝构造被删除,std::is_copy_constructible_v 是假的,拷贝它直接编译不过。移动本身怎么工作,见左值、右值和移动语义。
2. 拷贝:浅拷贝和深拷贝
2.1 浅拷贝:两个对象指着同一块内存
一个自己管堆内存的类:
class Buffer {
public:
explicit Buffer(const char* s) : size_(std::strlen(s) + 1), data_(new char[size_]) {
std::memcpy(data_, s, size_);
}
~Buffer() { delete[] data_; }
private:
std::size_t size_;
char* data_;
};
Buffer a("hello");
Buffer b = a; // 编译器生成的拷贝构造
编译器生成的拷贝构造逐个拷成员,data_ 是个指针,拷的就是指针的值:b.data_ 和 a.data_ 指向同一块内存。离开作用域时 b、a 先后析构,同一块内存被 delete[] 两次(double free);在那之前,改 b 的内容 a 也跟着变。这叫浅拷贝。要的是深拷贝:新对象自己 new 一块,把内容拷过去。
2.2 三法则、五法则、零法则
Buffer 写了析构函数,是因为它亲手 new 了内存;可只要析构函数里要释放什么,编译器生成的拷贝就一定是错的,拷贝构造和拷贝赋值也得一起写。这就是三法则:析构、拷贝构造、拷贝赋值,写了一个,另外两个也要写。C++11 有了移动,再加上移动构造和移动赋值,就是五法则:不写移动不会出错,但按 1.2 的规则,移动会退化成拷贝,白白多一次 new 和 memcpy。
更好的办法是零法则:一个都不写,把资源交给会自己管理的成员。 Buffer 的 char* 换成 std::string 或 std::vector<char>,编译器生成的六个函数就全对了,因为每个成员自己会深拷贝、会移动、会释放。要独占一块资源(文件、句柄)就用 std::unique_ptr 加自定义删除器(见 智能指针)。真正需要自己写五个函数的,只有 std::string、std::vector 这种本身就是“资源管理者”的类。
2.3 拷贝赋值:自赋值和 copy-and-swap
拷贝赋值比拷贝构造难写,因为左边的对象已经有一块内存了。直觉的写法是先释放旧的,再按右边的大小分配新的、拷过去:
Buffer& operator=(const Buffer& o) {
delete[] data_; // 1. 释放旧的
size_ = o.size_;
data_ = new char[size_]; // 2. 分配新的:可能抛 std::bad_alloc
std::memcpy(data_, o.data_, size_); // 3. 自赋值时 o.data_ 已经在第 1 步被释放了
return *this;
}
两个问题。a = a 时 o 就是自己,第 1 步把要拷的数据先删了。第 2 步 new 失败抛异常时,data_ 还指着已经释放的内存,对象处在一个析构时会再释放一次的坏状态。
copy-and-swap 一次解决两个问题:参数按值传,进函数前已经用拷贝构造拷好了一份;函数里只把自己和这份副本的成员交换,旧的内容随副本离开函数时析构。
Buffer(const Buffer& o) : size_(o.size_), data_(new char[size_]) { // 深拷贝
std::memcpy(data_, o.data_, size_);
}
Buffer& operator=(Buffer o) noexcept { // 按值传:拷贝在进函数之前完成
swap(o);
return *this;
} // o 带着旧的 data_ 析构
void swap(Buffer& o) noexcept {
std::swap(size_, o.size_);
std::swap(data_, o.data_);
}
可能失败的那一步(分配和拷贝)发生在动 *this 之前:拷贝失败,异常在进函数前抛出,左边的对象一点没变;拷贝成功,剩下的只有交换两个指针,不会失败。自赋值也不用特判,拷一份再换,结果还是自己。代价是自赋值时白拷一次,而且旧对象有足够大的内存时也不会复用。上面这个 Buffer 用 GCC 16.2 编译,跑 a = b; a = a; Buffer c = a; 后打印出三个 world,没有重复释放。传值的参数还顺带让同一个函数兼任移动赋值:传进来的是右值时,副本是移动构造出来的。
3. 构造和析构的顺序
3.1 初始化列表:按声明的顺序,不按你写的顺序
构造函数的初始化列表(冒号后面那串)才是真正初始化成员的地方,进到函数体 {} 里时,成员已经全部构造好了,在函数体里再写 x = 1; 就成了“先默认构造、再赋值”两步。const 成员、引用成员、没有默认构造函数的成员只能在初始化列表里初始化,因为它们不能先“空着”再赋值。
成员初始化的顺序是它们在类里声明的顺序,和初始化列表里写的先后无关:
struct Derived : Base {
M x, y; // 声明顺序:x、y
Derived() : y("y"), x("x") { /* ... */ } // 写的顺序:y、x
};
实际先构造 x 再构造 y,GCC 开 -Wall 会给 -Wreorder 警告。顺序不一致本身不出错,出错的是成员之间有依赖时:比如 available_ 声明在 alloc_ 前面,初始化列表写 available_(alloc_ - len_),用到的 alloc_ 这时还没初始化。有依赖的成员,按依赖顺序声明,初始化列表也照这个顺序写。
3.2 explicit 和委托构造
只接受一个参数的构造函数,默认还兼做隐式类型转换:Buffer 的构造函数如果不加 explicit,void send(Buffer b); send("hi"); 就会悄悄从字符串构造一个 Buffer,Buffer b = "hi"; 也能编过。单参数构造函数一般都标 explicit,只有那种“本来就该能自动转换”的(比如从 int 构造一个有理数)才不标。
几个构造函数有一段共同的初始化时,可以让一个构造函数调用另一个(委托构造,C++11):Buffer() : Buffer("") {}。被委托的那个构造函数完整跑完,对象就算构造好了;委托的这个如果在自己的函数体里抛异常,析构函数会被调用,这一点和普通构造函数里抛异常不同(见 5.2)。
3.3 析构顺序:和构造正好反过来
构造一个派生类对象的顺序是:基类(它的成员,再它的构造函数体)→ 自己的成员(按声明顺序)→ 自己的构造函数体。析构完全反过来。上面那个 Derived 实际打印出来是:
ctor Base::b Base body ctor x ctor y Derived body
~Derived body dtor y dtor x ~Base body dtor Base::b
反过来的原因是后构造的可能依赖先构造的:成员构造时可以用基类,基类不会用到派生类的成员;析构时先拆掉依赖别人的,被依赖的最后走。析构函数体运行时,成员都还在,可以放心使用。
基类的析构函数要不要是虚函数,取决于会不会通过基类指针 delete 派生类对象:会,就必须是虚的,否则只调基类的析构函数,派生类的部分不析构,是未定义行为(见虚函数);不会被当作基类用的类(比如标了 final 的),不需要虚析构函数,加了反而让每个对象多一个 vptr。
4. RAII:把释放写进析构函数
4.1 资源跟着对象走
RAII(resource acquisition is initialization,资源获取即初始化)的意思是:构造函数里拿到资源,析构函数里释放,资源的寿命和一个对象绑在一起。C++ 保证一个对象离开作用域时一定调它的析构函数,不管是正常走到 }、中途 return、break,还是有异常穿过这一层,所以释放这一步不会被漏掉。
void append_log(const std::string& line) {
std::lock_guard<std::mutex> lock(log_mutex); // 构造时加锁
std::ofstream out("app.log", std::ios::app); // 构造时打开文件
if (!out) return; // 提前返回:文件没打开,锁照样解
out << line << '\n'; // 抛异常:文件照样关,锁照样解
} // 正常走到这里:先关文件,再解锁
C 里同样的函数要在每个返回点之前手动 unlock、fclose,常见写法是把清理代码集中到函数末尾、出错时 goto 过去;漏一个返回点就泄漏一个资源。RAII 把“记得释放”从每个调用者身上,挪到了写类的那个人身上,写一次析构函数,所有用法都对。标准库里的 std::lock_guard、std::unique_lock、std::fstream、std::unique_ptr、std::vector,都是 RAII。
RAII 管的不只是内存,凡是“拿了要还”的都算:锁、文件、socket、数据库连接、临时目录。和对象大不大、大小在编译期确不确定都没有关系。
4.2 自己写包装类:先把拷贝删掉
给一个裸指针写最简单的 RAII 包装:
class NumberHolder {
public:
explicit NumberHolder(Number* p) : p_(p) {}
~NumberHolder() { delete p_; }
NumberHolder(const NumberHolder&) = delete; // 不删,拷一份就 delete 两次
NumberHolder& operator=(const NumberHolder&) = delete;
private:
Number* p_;
};
{
NumberHolder h(create_number(kInteger)); // 要有名字:没名字的临时对象在这一行结束就析构了
use(h);
} // 在这里 delete
析构函数里释放资源,编译器生成的拷贝就会让两个对象各释放一次,所以要么写深拷贝,要么像这里一样禁止拷贝。“独占一个指针、离开作用域 delete、不能拷贝只能移动”,标准库里已经有了,就是 std::unique_ptr<Number>,自己写包装类时先想想它能不能直接用。
5. 异常:出错时栈怎么展开
5.1 throw 之后发生什么:栈展开
throw 以后,程序沿着调用链往回找最近的、类型匹配的 catch。每离开一层函数,这一层里已经构造好的局部对象都按构造的反顺序析构,这个过程叫栈展开(stack unwinding)。下面的程序里 h 抛异常,g 没有 catch,f 有:
struct Guard { const char* n; ~Guard() { std::printf(" ~Guard %s\n", n); } };
void h() { Guard g{"h"}; std::puts(" h throws"); throw std::runtime_error("disk full"); }
void g() { Guard g{"g"}; h(); std::puts(" never printed"); }
void f() {
Guard a{"f"};
try { g(); }
catch (const std::exception& e) { std::printf(" caught in f: %s\n", e.what()); }
}
栈展开会调析构函数,这是 RAII 能在异常时照样释放资源的原因。 C 里的 setjmp / longjmp 也能跳回很多层以外,但它只是恢复寄存器,不析构跳过的那些局部对象,在 C++ 里用它跳过有析构函数的对象是未定义行为。一个异常一直没被 catch 时,程序调 std::terminate 结束,这时栈展不展开由实现决定,所以 main 里通常要有一个兜底的 catch。
C++ 的异常是语言里的控制流机制,和 CPU 的“异常”(缺页、除零这类故障和陷阱)不是一回事:空指针解引用不会变成一个能 catch 的 C++ 异常,它在 Linux 上是一个 SIGSEGV 信号(见进程地图)。
5.2 构造函数可以抛,析构函数不要抛
构造函数没法返回错误码,抛异常是它报告失败的正常方式:文件打不开、内存分不到,就别让一个“半成品”对象存在。构造函数里抛出异常时,已经构造好的成员和基类会被析构,但这个对象自己的析构函数不会被调用,因为它从没构造完成。这就是为什么资源要放进成员(每个成员各自 RAII):构造函数里先 new 一块给裸指针成员、再做别的事抛了异常,那块内存没人释放。
析构函数反过来,不应该抛。栈展开时正在析构局部对象,如果某个析构函数又抛出一个异常,同时就有两个异常在飞,C++ 不知道该处理哪个,直接调 std::terminate。C++11 起析构函数默认就是 noexcept 的,在里面 throw 而没在函数内接住,也会直接 terminate。析构函数里可能失败的操作(比如关闭文件时刷盘失败),要么在析构函数里自己吞掉,要么另外提供一个 close() 让调用者显式调用、自己处理错误。
5.3 noexcept:承诺不抛
noexcept 是函数对调用者的承诺:我不会抛异常。真的抛了,异常不会往外传,程序直接 std::terminate。这个承诺有实际用处:std::vector 扩容时要把旧元素搬到新内存,只有元素的移动构造是 noexcept 的,它才敢用移动,否则用拷贝,因为搬到一半移动抛了异常,旧数组已经被移空,没法恢复原样(详见移动语义)。所以移动构造、移动赋值、swap、析构函数,能标 noexcept 的都标上。
5.4 异常安全的三个等级
一个函数抛异常以后,对象处在什么状态,分三个等级:
| 等级 | 抛异常以后 | 例子 |
|---|---|---|
| 基本保证 | 不泄漏资源,对象仍然合法(能析构、能再赋值),但值可能变了 | 大多数标准库操作 |
| 强保证 | 要么完全成功,要么像没调用过一样,状态回到调用前 | vector::push_back(元素的移动 noexcept 或可拷贝时)、copy-and-swap 的赋值 |
| 不抛保证 | 一定不抛 | 析构函数、swap、移动操作 |
强保证的通用做法和 copy-and-swap 一样:先在副本上做所有可能失败的事,成功了再用一个不会失败的操作(交换指针)换上去。不抛保证是这一切的地基:swap 和析构函数要是能抛,任何更高的保证都做不出来。
5.5 代价:不抛几乎不花钱,抛一次很贵
GCC、Clang 在 Linux 上用的是“零开销”异常:正常路径上不执行任何额外指令,展开时要用的信息(哪段代码有哪些对象要析构、catch 在哪)放在单独的 .eh_frame、.gcc_except_table 表里(见函数调用栈帧的 CFI)。代价全在抛出那一刻:在堆上分配异常对象,按返回地址一层层查表、逐层析构,再在运行时比对 catch 的类型,一次 throw 加 catch 是微秒级。本机(Windows,MinGW GCC 16.2 -O2)测了一个只隔一层函数的 throw + catch,平均约 1.5 µs 一次,不抛时那次调用约 1 ns。
正常路径也不是完全免费:可能抛异常的调用会限制编译器的部分优化,表本身让二进制变大。所以异常适合真正罕见的错误(文件不存在、内存不够、配置写错),不适合当作每秒发生成千上万次的普通分支,比如解析用户输入时“这一项不是数字”更适合用返回值(std::optional、std::expected)表示。