一句话核心结论:C++ 编译器会默默为你生成默认构造、拷贝构造、拷贝赋值、析构 4 个函数——但默默生成 ≠ 默默正确。本章 8 个条款教你怎么把”对象生命周期”这 5 把钥匙握在自己手里:virtual 析构、= delete、构造析构中勿调虚函数、operator= 的 copy-and-swap 范式、Rule of Three 的完整实现。
系列导航
前言:为什么”对象生命周期”是 C++ 最难的部分?
C 是面向过程的——malloc 给你一块内存,free 还给系统。配对好,game over。
C++ 是面向对象的——你声明一个对象,编译器帮你构造;你不再使用,编译器帮你析构。看起来”自动”,但所有的”自动”都隐含了选择:
- 谁来构造?(编译器生成的默认构造?用户编写的?)
- 怎么拷贝?(浅拷贝?深拷贝?禁止拷贝?)
- 怎么析构?(栈展开?delete?
virtual?) - 异常时怎么清理?(栈展开时析构顺序?)
对象生命周期(Object Lifetime) 是 C++ 区别于 C、Java 的核心复杂度。本章 8 个条款就是这本”生命周期手册”。
一、条款 05:了解 C++ 默默编写并调用哪些函数
1.1 一个反常识的实验
1 2 3 4 5 6 7 8
| class Empty {};
Empty e1; Empty e2(e1); Empty e3 = e1; e1 = e2;
|
反常识的答案:编译器默默为你写了 4 个函数:
1 2 3 4 5 6 7 8
| class Empty { public: Empty() = default; Empty(const Empty& other) = default; Empty& operator=(const Empty& other) = default; ~Empty() = default; };
|
1.2 这 4 个函数何时被”默默生成”?
| 函数 | 触发条件 | 是否总是生成? |
|---|
| 默认构造 | 类中没有任何用户声明的构造函数 | 否——一旦你写了构造函数,默认构造就不存在 |
| 拷贝构造 | 类中没有用户声明的拷贝构造(即 T(const T&)) | 仅在用到时生成 |
| 拷贝赋值 | 类中没有用户声明的拷贝赋值(即 operator=(const T&)) | 仅在用到时生成 |
| 析构 | 类中没有用户声明的析构 | C++11 起总是生成(即使你不写) |
C++11 之前的细微差别:C++98 中,如果基类的析构是 virtual,派生类的”默默析构”也总是 virtual。C++11 起这条规则泛化。
1.3 “默默生成”的内容是什么?
对内置类型成员:直接拷贝二进制(浅拷贝)。
1 2 3 4 5 6 7 8 9 10 11
| class NameObject { public: std::string nameValue; int objectValue; };
|
对类类型成员:调用该成员的拷贝构造。
对引用成员和 const 成员:
1 2 3 4 5 6 7 8
| class Widget { std::string& nameRef_; const int id_; };
Widget w1, w2; w1 = w2;
|
编译器不会为”含有引用或 const 成员”的类生成 operator=——但它仍然会生成拷贝构造(拷贝构造可以初始化引用和 const)。
1.4 关键的”非生成”情况
1 2 3 4 5 6 7 8 9 10 11
| class Base { public: Base(const Base&) = delete; };
class Derived : public Base { };
Derived d1, d2(d1);
|
C++11 之前的旧标准下,基类的”私有拷贝构造”会让派生类无法生成拷贝构造——这是经典的”悄悄崩盘”陷阱。
1.5 实战应用:什么时候依赖”默默生成”?
可以依赖的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Point { int x_, y_; };
class Person { std::string name_; int age_; };
|
不能依赖的情况:
1 2 3 4 5 6 7
| class String { char* data_; size_t size_; };
|
1.6 编译器生成的函数 vs = default
1 2 3 4 5 6 7
| class Widget { public: Widget() = default; Widget(const Widget&) = default; Widget& operator=(const Widget&) = default; ~Widget() = default; };
|
= default 告诉编译器:”我显式要求你按默认方式生成“——这在 C++11 之后是更清晰的写法。
1.7 关键启示
- 不要相信”空类没成本”——空类也有 1 字节大小(确保不同对象有不同地址)
- C++ 的”零规则”是首选——成员都是值语义的,就让编译器生成 4 个函数
- 遇到”原始指针”成员,立即警觉——可能需要 Rule of Three
二、条款 06:若不想使用编译器自动生成的函数,就该明确拒绝
2.1 经典场景:不可拷贝的类
1 2 3 4 5 6 7 8
| class CEO { std::string name_; };
CEO a, b; a = b;
|
2.2 解决方案:C++11 之前的”友元 + 私有”
1 2 3 4 5 6 7 8 9 10 11 12 13
| class CEO { public: CEO() = default; private: CEO(const CEO&); CEO& operator=(const CEO&); };
CEO a, b; a = b; CEO c(a);
|
为什么”只声明不定义”? 防止”友元函数不小心调用拷贝构造”——如果函数有定义,友元函数能访问 private 成员;如果只有声明,链接会失败(未定义符号)。
2.3 解决方案:C++11 起的 = delete
1 2 3 4 5 6 7
| class CEO { public: CEO() = default; CEO(const CEO&) = delete; CEO& operator=(const CEO&) = delete; };
|
= delete 的优势:
| 维度 | 友元 + 私有 | = delete |
|---|
| 错误信息 | “private 不可访问” | “已删除函数”——更清晰 |
| 友元能调用吗? | 能(虽然罕见) | 不能——任何情况都拒绝 |
| C++98 兼容 | ✅ | ❌(C++11+) |
2.4 = delete 的更多用途
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| void process(int x); void process(double x) = delete;
process(3.14);
template<typename T> void foo(T*); template<> void foo<void>(void*) = delete;
class Widget { public: Widget(int) {} Widget(double) = delete; };
Widget w(3.14);
|
2.5 派生类中的”拒绝”会被继承
1 2 3 4 5 6 7 8 9 10
| class Base { public: Base(const Base&) = delete; };
class Derived : public Base { };
Derived d1, d2(d1);
|
反过来的”陷阱”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Base { public: Base(const Base&) = default; };
class Derived : public Base { public: Derived(const Derived&) = delete; };
Derived d1, d2(d1);
Derived d; Base& b = d; Base b2(d);
|
2.6 关键启示
- 遇到”独一无二”语义的对象,第一时间想到
= delete = delete 应该在声明时就写——而不是等出现 bug 再补- C++11 之前用”友元 + 私有”——C++11 之后用
= delete
三、条款 07:为多态基类声明 virtual 析构函数
3.1 经典灾难:派生类对象只析构基类部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Base { public: ~Base() { std::cout << "~Base()\n"; } };
class Derived : public Base { int* data_; public: Derived() : data_(new int[100]) {} ~Derived() { std::cout << "~Derived()\n"; delete[] data_; } };
Derived* p = new Derived(); Base* pb = p; delete pb;
|
输出:
~Derived() 没被调用,data_ 指向的 100 个 int 泄漏了!
3.2 解决方案:virtual 析构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class Base { public: virtual ~Base() { std::cout << "~Base()\n"; } };
class Derived : public Base { int* data_; public: Derived() : data_(new int[100]) {} ~Derived() override { std::cout << "~Derived()\n"; delete[] data_; } };
Base* pb = new Derived(); delete pb;
|
输出:
3.3 virtual 析构的工作原理
graph TB
subgraph "对象内存布局"
VPTR["vptr<br/>(指向 vtable)"]
BASE["Base 成员"]
DERIVED["Derived 成员<br/>(含 data_)"]
end
VTABLE["vtable\n~Base() / ~Derived()\nfoo() / foo_derived()"]
VPTR -.->|指向| VTABLE
style VPTR fill:#FFB3C6,stroke:#F48FB1,color:#333
style BASE fill:#C7CEEA,stroke:#9FA8DA,color:#333
style DERIVED fill:#B5EAD7,stroke:#80CBC4,color:#333
style VTABLE fill:#E8D5F5,stroke:#CE93D8,color:#333
delete pb 的执行流程:
- 通过
pb 找到对象的 vptr - 查
vtable 找到实际类型(Derived)的析构函数 - 调用
~Derived()(释放 data_) - 调用
~Base()(析构基类部分)
3.4 什么时候不需要 virtual 析构?
不是所有基类都要 virtual 析构。判断标准:
| 场景 | 析构是否 virtual? |
|---|
| 多态基类(有其他虚函数) | ✅ 必须是 virtual |
| 作为基类被继承 + 通过基类指针删除 | ✅ 必须是 virtual |
不被继承(如 std::string、std::vector) | ❌ 不要 virtual |
| 纯资源容器(如 RAII 包装) | ❌ 不要 virtual |
final 类(C++11 起) | ❌ 不会被继承 |
反例:
1 2 3 4 5 6
| class Point { int x_, y_; public: virtual ~Point(); };
|
反例:
1 2 3
| std::string s; class MyString : public std::string {};
|
3.5 抽象基类的”纯虚析构”
有时候你想让基类无法被实例化(抽象),但又需要”析构”语义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class AbstractBase { public: virtual ~AbstractBase() = 0; };
AbstractBase::~AbstractBase() = default;
class Concrete : public AbstractBase { public: ~Concrete() override { } };
Concrete c;
|
陷阱:纯虚析构必须在类外提供定义——否则链接器报”undefined reference”。
3.6 virtual 析构的”成本”
| 成本 | 数量级 | 说明 |
|---|
| vptr 指针 | 8 bytes/对象 | 每个对象多一个指针 |
| vtable 项 | 8 bytes/类 | 每张 vtable 多一项 |
| 调用开销 | 一次间接寻址 | 几乎可忽略(编译器会优化) |
| 阻止空基类优化 | 视情况 | 可能影响内存布局 |
一般原则:能接受这 8 bytes 的,就用 virtual。
3.7 关键启示
- 多态基类的析构必须是 virtual——这是 C++ 的一条”铁律”
- 不要给”不会被继承”的类加 virtual——浪费内存
- C++ 标准库的类(除
std::enable_shared_from_this 等少数)都不是多态基类——不要继承它们
四、条款 08:别让异常逃离析构函数
4.1 析构函数抛异常的灾难
1 2 3 4 5 6 7 8 9
| class Widget { public: ~Widget() { if (!connection_.close()) { throw std::runtime_error("Connection close failed"); } } };
|
问题 1:C++ 析构期间(stack unwinding)有第二个异常 → std::terminate
1 2 3 4 5 6
| void process() { Widget w1, w2; throw std::runtime_error("oops"); }
|
问题 2:析构函数”应该”完成清理工作,抛异常会破坏不变量
4.2 解决方案 1:吞下异常 + 记录
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Widget { public: ~Widget() { try { connection_.close(); } catch (const std::exception& e) { std::cerr << "Failed to close: " << e.what() << "\n"; } } };
|
4.3 解决方案 2:把操作放在普通函数中
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 26 27 28 29 30
| class Widget { public: void close() { if (!connection_.close()) { throw std::runtime_error("close failed"); } closed_ = true; }
~Widget() { if (!closed_) { try { connection_.close(); } catch (...) { std::cerr << "Close failed in dtor\n"; } } } };
void process() { Widget w; w.close(); }
|
两种方案的对比:
| 方案 | 优点 | 缺点 |
|---|
| 吞下异常 | 简单 | 错误被吞掉,调试困难 |
| 普通函数 + 析构兜底 | 显式,让用户处理 | 需要用户记得调用 |
4.4 更复杂的场景:两个对象都析构
1 2 3 4 5
| void process() { Widget w1, w2; }
|
核心原则:
析构函数应该”承诺不抛”——通过 noexcept 明确告诉编译器。
1 2 3 4
| class Widget { public: ~Widget() noexcept; };
|
4.5 关键启示
- 析构函数绝对不应该抛异常——这是 C++ 的一条铁律
- C++11 起的析构函数默认
noexcept——明确写出来更好 - 把”可能失败”的操作抽到普通函数——让用户主动调用
五、条款 09:绝不在构造和析构过程中调用 virtual 函数
5.1 反常识的实验
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 26 27 28 29 30 31 32 33 34 35 36
| class Base { public: Base() { std::cout << "Base ctor\n"; init(); } virtual void init() { std::cout << "Base::init()\n"; } virtual ~Base() { std::cout << "Base dtor\n"; cleanup(); } virtual void cleanup() { std::cout << "Base::cleanup()\n"; } };
class Derived : public Base { public: Derived() { std::cout << "Derived ctor\n"; } void init() override { std::cout << "Derived::init()\n"; } ~Derived() { std::cout << "Derived dtor\n"; } void cleanup() override { std::cout << "Derived::cleanup()\n"; } };
Derived d;
|
输出:
1 2 3 4 5 6
| Base ctor Base::init() ← 灾难!调的是 Base::init(),不是 Derived::init()! Derived ctor Derived dtor Base dtor Base::cleanup() ← 同样,调的是 Base::cleanup()!
|
为什么? 构造 Derived 时,先构造 Base 部分——此时对象的类型是”正在构造的 Base”,vptr 指向 Base 的 vtable。Derived 部分还没初始化。
5.2 这个 bug 的危险性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| class Base { int x_; public: Base() { logConstruction(); } virtual void logConstruction() { std::cout << "Base constructed, x_ = " << x_ << "\n"; } };
class Derived : public Base { std::string data_; public: Derived() : Base(), data_("hello") { } void logConstruction() override { std::cout << "Derived constructed, data_ = " << data_ << "\n"; } };
|
5.3 解决方案:两段式构造
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class Base { int x_; public: Base() : x_(0) { } void logConstruction() { std::cout << "Base constructed, x_ = " << x_ << "\n"; } };
class Derived : public Base { std::string data_; public: Derived() : Base(), data_("hello") { logConstruction(); } };
|
5.4 解决方案:让派生类主动调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class Base { public: Base() { } virtual void onConstruction() { } };
class Derived : public Base { public: Derived() : Base() { onConstruction(); } };
|
5.5 关键启示
- 构造和析构中禁止调虚函数——vptr 没指向”正确”的 vtable
- 两段式构造:基类构造(纯 C++ 操作)+ 派生类构造(再调虚函数)
- NVI 模式(Non-Virtual Interface):用 public non-virtual 包 private virtual,从根上断绝”构造时调虚函数”的可能
六、条款 10:让 operator= 返回 *this 的引用
6.1 链式赋值的语义
1 2
| int a, b, c; a = b = c = 10;
|
C/C++ 的内置类型,operator= 返回引用——这样才能支持连续赋值。
6.2 C++ 类应该模仿这个语义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Widget { public: Widget& operator=(const Widget& rhs) { return *this; }
Widget& operator+=(const Widget& rhs) { return *this; }
Widget& operator*=(int factor) { return *this; } };
|
6.3 不这么写会怎样?
1 2 3 4 5 6 7 8 9 10 11
| class BadWidget { public: void operator=(const BadWidget& rhs) { } };
BadWidget a, b, c; (a = b) = c; a = b = c;
|
6.4 关键启示
这是一个协议性的条款——虽然你的类可能不需要链式赋值,但所有 operator= 都应该返回 *this 的引用。这样能避免用户在别处链式赋值时翻车。
6.5 标准库和所有内置类型的约定
| 操作符 | 应返回 |
|---|
+= -= *= /= %= | *this 引用 |
<<= >>= &= ` | =^=` |
++ --(前置) | *this 引用 |
| 解引用、箭头、函数调用 | 见相关条款 |
七、条款 11:在 operator= 中处理”自我赋值”
7.1 自我赋值的危险
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Bitmap { };
class Widget { Bitmap* pb_; public: Widget& operator=(const Widget& rhs) { delete pb_; pb_ = new Bitmap(*rhs.pb_); return *this; } };
Widget w; w = w;
|
问题:先 delete 了 pb_,然后想用 *rhs.pb_ 复制——但 rhs 就是 *this,rhs.pb_ 就是已删除的 pb_。
7.2 解决方案 1:证同测试(Identity Test)
1 2 3 4 5 6 7
| Widget& operator=(const Widget& rhs) { if (this == &rhs) return *this; delete pb_; pb_ = new Bitmap(*rhs.pb_); return *this; }
|
问题:如果 new Bitmap 抛异常,pb_ 指向已删除的内存——异常不安全。
7.3 解决方案 2:精心安排语句顺序
1 2 3 4 5 6 7
| Widget& operator=(const Widget& rhs) { Bitmap* pOrig = pb_; pb_ = new Bitmap(*rhs.pb_); delete pOrig; return *this; }
|
问题:还是不够好——如果 new Bitmap 抛异常,原 pb_ 没被删除(但也没被破坏)。比方案 1 安全。
7.4 终极方案:copy-and-swap
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Widget { Bitmap* pb_; public: Widget(const Widget& rhs) : pb_(rhs.pb_ ? new Bitmap(*rhs.pb_) : nullptr) {}
void swap(Widget& other) noexcept { std::swap(pb_, other.pb_); }
Widget& operator=(Widget rhs) { swap(rhs); return *this; } };
|
为什么这是终极方案?
| 维度 | 证同测试 | 精心安排 | copy-and-swap |
|---|
| 自我赋值安全 | ✅ | ✅(要小心) | ✅(天然) |
| 异常安全 | ❌ | ⚠️ | ✅(strong) |
| 代码简洁 | 5 行 | 5 行 | 5 行 |
| 自动处理 self-move | ❌ | ❌ | ✅ |
7.5 self-move 的问题
C++11 起,std::move 可能导致”自我移动”:
1 2
| Widget w; w = std::move(w);
|
copy-and-swap 天然处理:rhs 是 w 的拷贝/移动版本,与 *this 是不同对象。
7.6 关键启示
- 任何管理资源的类,operator= 都要处理”自我赋值”
- copy-and-swap 是最佳方案——代码简洁、异常安全、自然处理 self-move
- “传值”是核心技巧——
Widget rhs 接受 const& 或&&,编译器自动选择拷贝/移动
八、条款 12:复制对象时勿忘其每一个成分
8.1 反例:拷贝时漏掉成员
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Customer { std::string name_; std::string phone_; public: Customer(const Customer& rhs) : name_(rhs.name_) {}
Customer& operator=(const Customer& rhs) { name_ = rhs.name_; return *this; } };
|
结果:
1 2 3
| Customer c1("Alice", "555-1234"); Customer c2(c1); std::cout << c2.phone();
|
8.2 编译器为什么不报警?
phone_ 已经被默认初始化(std::string 的默认构造)——不会触发编译错误,只能靠”程序员自觉”。
8.3 解决方案:拷贝 + 赋值用一个函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class Customer { std::string name_; std::string phone_; int priority_; public: Customer(const Customer& rhs) : name_(rhs.name_), phone_(rhs.phone_), priority_(rhs.priority_) {}
Customer& operator=(const Customer& rhs) { copyFrom(rhs); return *this; }
private: void copyFrom(const Customer& rhs) { name_ = rhs.name_; phone_ = rhs.phone_; priority_ = rhs.priority_; } };
|
为什么这样? 加新字段时,只改 copyFrom 一处——不会漏。
8.4 派生类的”拷贝”陷阱
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Base { int x_; public: Base(int x) : x_(x) {} };
class Derived : public Base { int y_; public: Derived(const Derived& rhs) : y_(rhs.y_) { } };
|
正确做法:
1 2 3 4 5 6 7 8 9
| Derived(const Derived& rhs) : Base(rhs), y_(rhs.y_) { }
Derived& operator=(const Derived& rhs) { Base::operator=(rhs); y_ = rhs.y_; return *this; }
|
为什么 operator= 不能用初始化列表? 因为初始化列表是”构造新对象”用的,operator= 是”修改已存在对象”——必须显式调用基类的 operator=。
8.5 关键启示
- 拷贝构造 / 拷贝赋值 = 拷贝每一个成员(包括基类)
- 抽取统一的
copyFrom 函数——避免漏改 - 派生类的拷贝:拷贝构造用基类构造,operator= 用
Base::operator= - 新加字段时,立即更新所有相关函数——这是审 code review 的必查项
九、综合实战:Rule of Three 的完整实现
9.1 Rule of Three 是什么?
如果一个类需要”析构函数”,它几乎一定也需要”拷贝构造”和”拷贝赋值”。这三件事要么都不写(让编译器生成),要么都自己写。
9.2 经典 String 实现
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| class String { char* data_; size_t size_;
public: String() : data_(new char[1]{'\0'}), size_(0) {}
String(const char* s) : size_(std::strlen(s)) { data_ = new char[size_ + 1]; std::memcpy(data_, s, size_ + 1); }
String(size_t n, char c) : size_(n) { data_ = new char[size_ + 1]; std::memfill(data_, c, size_); data_[size_] = '\0'; }
String(const String& other) : size_(other.size_) { data_ = new char[size_ + 1]; std::memcpy(data_, other.data_, size_ + 1); }
String& operator=(String rhs) { swap(rhs); return *this; }
void swap(String& other) noexcept { std::swap(data_, other.data_); std::swap(size_, other.size_); }
~String() { delete[] data_; }
size_t size() const { return size_; } const char* c_str() const { return data_; }
char& operator[](size_t i) { return data_[i]; } const char& operator[](size_t i) const { return data_[i]; } };
void swap(String& a, String& b) noexcept { a.swap(b); }
|
9.3 测试
1 2 3 4 5 6 7 8 9 10 11 12
| int main() { String s1("hello"); String s2 = s1; String s3("world"); s3 = s1; s1 = String("temporary");
std::cout << s1.c_str() << "\n"; std::cout << s2.c_str() << "\n"; std::cout << s3.c_str() << "\n"; return 0; }
|
9.4 Rule of Three 的现代扩展:Rule of Five
C++11 起,加入移动语义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class String {
String(String&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = new char[1]{'\0'}; other.size_ = 0; }
String& operator=(String&& rhs) noexcept { if (this != &rhs) { delete[] data_; data_ = rhs.data_; size_ = rhs.size_; rhs.data_ = new char[1]{'\0'}; rhs.size_ = 0; } return *this; } };
|
5 个函数:
| 函数 | 何时写 |
|---|
| 析构 | 类管理资源 |
| 拷贝构造 | 析构 + 拷贝构造 = 完整 |
| 拷贝赋值 | 同上 |
| 移动构造 | 类管理资源,且移动比拷贝快 |
| 移动赋值 | 同上 |
9.5 Rule of Zero:最好的情况
1 2 3 4 5 6 7 8 9 10 11 12
| class String { std::unique_ptr<char[]> data_; size_t size_; public: String(const char* s) : size_(std::strlen(s)) { data_ = std::make_unique<char[]>(size_ + 1); std::memcpy(data_.get(), s, size_ + 1); } };
|
“Rule of Zero”:把资源管理交给标准库,自己不写 5 个函数——这是最现代、最推荐的写法。
十、8 个条款的”生命周期”全景
graph TB
A["对象出生\n(构造)"] --> A1["条款 05:编译器生成默认构造?"]
A --> A2["条款 06:拒绝拷贝?"]
A --> A3["条款 09:构造中勿调虚函数"]
B["对象使用\n(拷贝/赋值)"] --> B1["条款 10:operator= 返回 *this"]
B --> B2["条款 11:自我赋值?copy-and-swap"]
B --> B3["条款 12:拷贝每个成分"]
C["对象死亡\n(析构)"] --> C1["条款 07:多态基类 virtual 析构"]
C --> C2["条款 08:析构不抛异常"]
C --> C3["条款 09:析构中勿调虚函数"]
style A fill:#C7CEEA,stroke:#9FA8DA,color:#333
style A1 fill:#B5EAD7,stroke:#80CBC4,color:#333
style A2 fill:#B5EAD7,stroke:#80CBC4,color:#333
style A3 fill:#B5EAD7,stroke:#80CBC4,color:#333
style B fill:#E8D5F5,stroke:#CE93D8,color:#333
style B1 fill:#FFDAB9,stroke:#FFAB76,color:#333
style B2 fill:#FFDAB9,stroke:#FFAB76,color:#333
style B3 fill:#FFDAB9,stroke:#FFAB76,color:#333
style C fill:#FFB3C6,stroke:#F48FB1,color:#333
style C1 fill:#FFF9C4,stroke:#F9A825,color:#333
style C2 fill:#FFF9C4,stroke:#F9A825,color:#333
style C3 fill:#FFF9C4,stroke:#F9A825,color:#333
核心思路:
- 出生:让编译器帮我们写,但小心”构造期间调虚函数”
- 使用:让 operator= 链式可用、异常安全、考虑自我赋值
- 死亡:多态基类必须 virtual 析构,析构不抛异常
十一、常见误区与陷阱
11.1 误区 1:依赖”默默生成”的拷贝函数
1 2 3 4 5 6 7 8 9 10 11 12
| class Buffer { char* data_; size_t size_; public: Buffer(size_t n) : data_(new char[n]), size_(n) {} };
Buffer b1(100); Buffer b2 = b1;
|
11.2 误区 2:多态基类析构不加 virtual
1 2 3 4 5 6 7 8 9 10 11 12
| class Shape { public: ~Shape() {} }; class Circle : public Shape { double* vertices_; public: ~Circle() { delete[] vertices_; } };
Shape* s = new Circle(); delete s;
|
11.3 误区 3:析构函数抛异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class FileHandle { FILE* fp_; public: ~FileHandle() { if (fp_) { if (std::fclose(fp_) != 0) { throw std::runtime_error("close failed"); } } } };
void process() { FileHandle f; throw std::runtime_error("oops"); }
|
11.4 误区 4:构造/析构中调虚函数
1 2 3 4 5 6 7 8 9 10 11
| class Base { public: Base() { init(); } virtual void init() {} }; class Derived : public Base { int* data_; public: Derived() : data_(new int[10]) {} void init() override { } };
|
11.5 误区 5:operator= 返回 void
1 2 3 4 5 6 7
| class BadWidget { public: void operator=(const BadWidget&) { } };
BadWidget a, b, c; a = b = c;
|
11.6 误区 6:拷贝赋值漏掉基类
1 2 3 4 5 6 7 8 9
| class Base { }; class Derived : public Base { int y_; public: Derived& operator=(const Derived& rhs) { y_ = rhs.y_; return *this; } };
|
十二、C++11/14/17 的演进
| 条款 | C++98 时代 | C++11/14/17 时代 |
|---|
| 05 | 编译器生成 4 个函数 | = default 显式要求 |
| 06 | 友元 + 私有 | = delete |
| 07 | virtual 析构 | final 类不需要 virtual |
| 08 | try/catch 吞异常 | noexcept 显式声明 |
| 09 | 两段式构造 | final 阻止继承 |
| 10 | 链式赋值 | 不变 |
| 11 | 证同测试 | copy-and-swap 更优 |
| 12 | 手动同步 | 自动化工具辅助 |
C++11/17 的新写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class Widget { public: Widget() = default; Widget(const Widget&) = default; Widget& operator=(const Widget&) = default; ~Widget() = default;
Widget& operator=(const Widget&&) = delete;
~Widget() noexcept { }
void init() override; };
|
十三、面试高频考点
13.1 必背题
| 题目 | 答案要点 |
|---|
| 编译器会为”空类”生成哪些函数? | 默认构造、拷贝构造、拷贝赋值、析构(C++11 起总是生成析构) |
= delete 和 private 拷贝构造的区别? | = delete 任何场景都拒绝;private 仍可被友元调用 |
多态基类为什么必须 virtual 析构? | 否则 delete base_ptr 不会调用派生类析构,导致泄漏 |
| 析构函数能抛异常吗? | 不能——析构抛异常 + 栈展开抛异常 = terminate |
| 构造/析构中能调虚函数吗? | 不能——vptr 还没指向派生类 vtable,调用的是基类版本 |
| 什么是 Rule of Three? | 拷贝构造、拷贝赋值、析构 三者要么都不写,要么都自己写 |
| 什么是 copy-and-swap? | operator= 传值 + swap,天然处理自我赋值和异常安全 |
operator= 为什么要返回 *this? | 支持链式赋值(a = b = c) |
13.2 高频追问
| 追问 | 关键点 |
|---|
引用成员的类能生成 operator= 吗? | 不能——引用不能重新绑定 |
| 派生类的拷贝构造怎么写? | 显式调用基类的拷贝构造:Derived(const D& rhs) : Base(rhs), ... |
| 派生类的 operator= 怎么写? | 显式调用基类的 operator=:Base::operator=(rhs); |
final 类的析构要 virtual 吗? | 不需要——不能被继承 |
| self-move 会怎么样? | 取决于实现;copy-and-swap 天然处理 |
| Rule of Five 是什么? | 拷贝构造、拷贝赋值、移动构造、移动赋值、析构 |
| 什么是 Rule of Zero? | 不写这 5 个函数,让标准库(智能指针)管理资源 |
十四、配套实验
14.1 实验 1:Rule of Three 完整实现
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| #include <iostream> #include <cstring> #include <algorithm>
class String { char* data_; size_t size_; public: String() : data_(new char[1]{'\0'}), size_(0) { std::cout << "Default ctor\n"; } String(const char* s) : size_(std::strlen(s)) { data_ = new char[size_ + 1]; std::memcpy(data_, s, size_ + 1); std::cout << "C-string ctor: " << s << "\n"; }
String(const String& other) : size_(other.size_) { data_ = new char[size_ + 1]; std::memcpy(data_, other.data_, size_ + 1); std::cout << "Copy ctor\n"; }
String& operator=(String rhs) { std::cout << "Copy assignment (swap)\n"; swap(rhs); return *this; }
void swap(String& other) noexcept { std::swap(data_, other.data_); std::swap(size_, other.size_); }
~String() { std::cout << "Dtor: " << (size_ ? data_ : "<empty>") << "\n"; delete[] data_; }
const char* c_str() const { return data_; } size_t size() const { return size_; } };
int main() { String s1("hello"); String s2 = s1; String s3("world"); s3 = s1;
s1 = s1;
return 0; }
|
编译运行:
1 2
| g++ -std=c++17 -Wall rule_of_three.cpp -o rule_of_three ./rule_of_three
|
14.2 实验 2:多态基类的 virtual 析构
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 26 27
| #include <iostream>
class Base { public: Base() { std::cout << "Base ctor\n"; } virtual ~Base() { std::cout << "Base dtor\n"; } };
class Derived : public Base { int* data_; public: Derived() : data_(new int[100]) { std::cout << "Derived ctor\n"; } ~Derived() override { std::cout << "Derived dtor (free data_)\n"; delete[] data_; } };
int main() { Base* pb = new Derived(); std::cout << "--- about to delete ---\n"; delete pb; return 0; }
|
14.3 实验 3:= delete 的威力
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include <iostream>
class Unique { public: Unique() = default; Unique(const Unique&) = delete; Unique& operator=(const Unique&) = delete; };
int main() { Unique u1; return 0; }
|
十五、回到 8 条黄金法则
| 条款 | 黄金法则 |
|---|
| 05 | 编译器生成 4 个函数:不要盲目依赖,检查你的成员 |
| 06 | 想禁止拷贝?立刻 = delete |
| 07 | 多态基类析构 = virtual |
| 08 | 析构函数绝对不抛异常(noexcept) |
| 09 | 构造/析构中绝不调虚函数 |
| 10 | 所有 operator= 家族返回 *this 引用 |
| 11 | operator= 用 copy-and-swap(最优雅) |
| 12 | 拷贝时勿忘每个成分(含基类) |
十六、结尾思考题
思考题 1:以下代码会输出什么?为什么?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class A { public: A() { std::cout << "A ctor\n"; } ~A() { std::cout << "A dtor\n"; } };
class B : public A { public: B() { std::cout << "B ctor\n"; } ~B() { std::cout << "B dtor\n"; } };
int main() { A* p = new B(); delete p; }
|
思考题 2:为什么 operator= 通常不用 noexcept 标记,但 swap 通常用 noexcept 标记?
思考题 3:copy-and-swap 看起来很好,但有没有它解决不了的问题?
思考题 4:你的项目里,有哪些类应该用 Rule of Zero 改写?
十七、本篇速查表
| 主题 | 关键 API / 模式 | 适用场景 |
|---|
| 编译器生成的 4 个函数 | 默认构造 / 拷贝构造 / 拷贝赋值 / 析构 | 类内只有值类型成员 |
= delete | T(const T&) = delete; | 不可拷贝、不可移动、禁止特定重载 |
= default | T() = default; | 显式要求编译器生成 |
virtual 析构 | virtual ~T() {} | 多态基类 |
= 0 纯虚析构 | virtual ~T() = 0; | 抽象基类 |
noexcept 析构 | ~T() noexcept; | 显式承诺不抛 |
| 构造中勿调虚函数 | 两段式构造 | 所有继承体系 |
operator= 返回 *this | return *this; | 所有赋值类操作 |
| copy-and-swap | T& operator=(T rhs) { swap(rhs); ... } | 异常安全的赋值 |
copyFrom 统一函数 | 私有 void copyFrom(const T&); | 避免拷贝漏字段 |
| Rule of Three | 拷贝 + 赋值 + 析构 | 管理资源的类 |
| Rule of Five | + 移动构造 + 移动赋值 | C++11 起的现代 C++ |
| Rule of Zero | 让标准库管资源 | 优先方案 |
十八、系列导航
下一篇:第 3 篇《资源管理:RAII 范式与智能指针》——条款 13-17 一起讲透 C++ 资源管理:为什么 auto_ptr 被淘汰、unique_ptr 的所有权独占、shared_ptr 的引用计数、weak_ptr 打破循环引用、自定义 deleter、智能指针在 STL 容器中的正确用法。
行动建议:
- 今天:检查你的多态基类,析构是不是
virtual - 今天:检查你的”独一无二”语义类,是否用了
= delete - 本周:把所有”原始指针 + 手动 delete”的代码,改造成
unique_ptr/shared_ptr - 本周:把所有
operator= 改写成 copy-and-swap 模式 - 思考:你的项目里,哪些类应该用
Rule of Zero 改写?