对应教材 :\[A Tour of C 3rd Edition-2022.pdf\|A Tour] §5 Classes · §6 Essential Operations · \[Programming Principles and Practice Using C++ 3rd Edition-2024.pdf\|PPP] ch17 · \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] ch17(查证) 阶段定位 :全计划的心脏------你将第一次从"使用 RAII 组件"跨到"理解 RAII 为什么这么设计"。读完这一课,你会亲手写出
vector的骨架,看懂拷贝、移动、返回值优化到底发生了什么。 前置 :\[阶段二-骨架与工具\|阶段二](A Tour §1--4)。本课所有示例代码均用g++ -std=c++20 -Wall -Wextra编译运行验证过。 第 1 步 只做存储 → 第 2 步 深拷贝 → 第 3 步 移动 → 第 4 步noexcept。这一周值得慢工出细活。
0. 本课地图
这一课解决三个问题,也是三个递进的层次:
- 类型为什么能"自带行为"? ------ 具体类型(concrete type):
complex、Vector为什么"就像内建类型"。答案:表示(representation)就在定义里。 - 对象的一生由谁控制? ------ 六大关键操作:构造/析构/拷贝/移动。答案:语言保证,不是你记得。
- "复制"到底发生什么? ------ 深拷贝 vs 移动 vs RVO 三者的差别。这是本课验收的核心。
一句话总纲(我在 A Tour §6.1 里的原话):
构造、析构、拷贝、移动不是逻辑上独立的四件事,它们是"一个匹配的集合"。你写了其中任何一个(尤其是析构),就必须检查另外几个。
为什么这一课叫"心脏"? 因为 RAII 的全部秘密都在这里。回想\[C++之父交谈录#二、C++ 的历史与演化\|1979 年的实验手册]------"new function / delete function"这个 pair,从第一天起就是 C++ 的心脏;而这一课,你要亲手把它写出来。
第一讲:具体类型(Concrete Types)------ A Tour §5.2
是什么
具体类型的行为"就像内建类型"。它的表示(representation)是定义的一部分。
看 complex 的定义------它就是一个 double re, im,和 int 一样直接:
cpp
class complex {
double re, im; // 表示:两个 double,就写在类里
public:
complex(double r, double i) :re{r}, im{i} {} // 构造 {r,i}
complex(double r) :re{r}, im{0} {} // 构造 {r,0}
complex() :re{0}, im{0} {} // 默认构造 {0,0}
double real() const { return re; }
double imag() const { return im; }
complex& operator+=(complex z) { re += z.re; im += z.im; return *this; }
complex& operator-=(complex z) { re -= z.re; im -= z.im; return *this; }
};
complex operator+(complex a, complex b) { return a += b; }
complex operator-(complex a, complex b) { return a -= b; }
bool operator==(complex a, complex b) { return a.real()==b.real() && a.imag()==b.imag(); }
要点:"表示在定义里"------这意味着
- 对象可以直接放栈上(不是只能 new 出来用指针访问);
- 可以直接引用对象,
sizeof(complex)就是两个double; - 可以立即完整地初始化(构造函数保证);
- 可以拷贝、可以移动。
为什么(这根连着"零开销抽象")
如果表示不在定义里(像 Java 的对象、Python 的 everything),访问任何成员都要走一层间接寻址。具体类型把表示摊在你面前,编译器看得到精确布局,才能生成直接的、无装箱、无查找的代码。 这就是\[C++之父交谈录#四、五个根本技术\|"直接映射硬件 + 零开销抽象"]在类型层面的落地------sizeof(complex) 就是 16,一个字节的税都没收(阶段一你亲手量过)。
Vector 是一个"资源句柄"(resource handle) ------表示只有 double* elem + int sz 两个词(本质是 [p:q) 那对指针),真正的大块数据放在自由存储上,通过 elem 指向它。栈上是薄薄的一层句柄,堆上是连续的元素 ------这正是 vector 快的原因。
代码逐行
拿 complex 里最有代表性的一行讲:
cpp
complex& operator+=(complex z) { re += z.re; im += z.im; return *this; }
complex&返回值 :返回*this的引用。这样a += b += c可以链式,也和内建int的+=行为一致------"修改型运算符"返回引用是惯例。complex z按值传参 :利用"按值传参会复制",直接修改这个副本,不影响调用者。如果参数很大,这里就会改成const complex&;对 16 字节的complex,按值更快。const成员函数 (double real() const):表示"这个函数不修改对象"。const 和非 const 对象都能调用 const 成员;只有非 const 对象能调用非 const 成员。 这是类型系统在替你区分"只读视角"和"可写视角"。
常见坑(对照你的 C++98 习惯)
- ❌ 以为"类"就等于"面向对象的类层次"。不是。
complex、Vector是具体类型------它不带虚函数、不进层次,就是一个"自带行为的类型"。能用具体类型解决的,别急着上继承。(这也是\[C++之父交谈录#四、五个根本技术\|我一直反对把 C++ 叫"面向对象语言"]的原因------OO 只是它的一半。) - ❌ 把
struct Point { int x, y; }当纯数据袋子,然后用一堆全局函数去操作它。现代 C++ 把数据和行为捆成一个具体类型,你才能写不变量、才能 RAII。 - ❌ 忘了给"不修改对象的成员函数"标
const。不标,const 对象(尤其const Vector&参数)就没法调用它,编译器会报错逼你改。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §5.2 (必读):具体类型的完整论证 +
complex完整代码。§5.2.2 的Vector是"资源句柄"的第一次亮相。 - \[Programming Principles and Practice Using C++ 3rd Edition-2024.pdf\|PPP] §17.2 (必读):
operator[]从"返回值"→"返回指针"→"返回引用"的三次演进------这是理解"引用 vs 值"的最佳教学材料。 - \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] §17.3(查证):表示、具体类型的形式化定义。
- \[C++之父交谈录#四、五个根本技术\|交谈录:直接映射硬件](重读):布局即契约------具体类型的哲学根基。
第二讲:六大关键操作(Essential Operations)------ A Tour §6.1
是什么
一个类的"关键操作"是一组语言规则假设它们存在的操作。C++11 起,一个完整的类有七个:
cpp
class X {
public:
X(Sometype); // ① 普通构造:创建对象
X(); // ② 默认构造:无参创建
X(const X&); // ③ 拷贝构造:复制对象
X(X&&); // ④ 移动构造:转移资源
X& operator=(const X&); // ⑤ 拷贝赋值:清理目标并复制
X& operator=(X&&); // ⑥ 移动赋值:清理目标并转移
~X(); // ⑦ 析构:清理
};
(教材里常说的"六大"或"六大关键操作",指的是除"普通构造"外的 ②③④⑤⑥⑦------它们由编译器按需自动生成。)
为什么(这一讲的心法)
Rule of Zero / Rule of All:要么一个都不写(全靠默认),要么全写。一旦你亲手写了其中任何一个(尤其是析构),就必须检查其他几个。
为什么?因为如果你写了析构,说明这个类拥有资源(它要释放点什么)。而拥有资源的类,默认的"成员级拷贝"(memberwise copy)几乎一定是错的------它会复制指针而不是复制指针指向的东西,造成浅拷贝、double-free、泄漏(第三讲会完整演示这场灾难)。
一个类什么时候需要析构? 一句话:它获取了资源 。资源不光是内存------文件、锁、线程、套接字全是资源。凡是用 new/fopen/lock/thread 获取过东西的类,都要思考:"谁负责释放?"
这段心法的出处 :1979 年我写下 "new function / delete function" 时想的正是这件事------每个资源都必须有一个拥有者,构造获取、析构释放 。后来它有了名字 RAII,\[C++之父交谈录#四、五个根本技术\|这是我唯一敢说"我为之骄傲"的东西]。垃圾回收解决不了这个问题:GC 只管内存,你
fopen拿到的文件句柄、lock拿到的锁,GC 一个都不会帮你释放。
代码逐行
Rule of Zero------什么都不写,编译器合成正确的成员级语义:
cpp
struct Z {
std::vector<double> v; // v 管好自己的内存
std::string s; // s 也是
};
// Z 的构造/拷贝/移动/析构全部自动生成,且全部正确------因为成员都是 RAII 类型
cpp
Z z1;
Z z2 = z1; // 深拷贝:z2.v 和 z1.v 的元素各自独立
z2.v.push_back(1.0);
// z1.v.size() 仍是 0 ------ 深拷贝,互不影响
=delete------明确禁止某个操作(抽象基类的经典做法):
cpp
struct Shape {
Shape(const Shape&) = delete; // 不允许拷贝
Shape& operator=(const Shape&) = delete;
virtual ~Shape() = default; // 虚析构还是要的
};
explicit------抑制单参构造的隐式转换:
cpp
struct Vector {
explicit Vector(int s) : sz{s} {} // 单参构造,explicit
int sz;
};
Vector v1(7); // OK
Vector v2 = 7; // 编译错误:explicit 抑制了 int→Vector 隐式转换
常见坑
- ❌ 只写析构、不写拷贝------这是 C++ 里最典型的资源泄漏/双释放源头(马上演示)。
- ❌ 把"拷贝/移动"当高级话题往后拖。它们是类型安全的地基,不是优化技巧。
- ❌ 单参构造不写
explicit。我见过void f(const Vector&); f(10);这种代码------隐式把 10 变成"10 个元素的 Vector",编译器不会拦你,语义却完全错了。除非有充分理由,单参构造一律explicit。(这呼应\[C++之父交谈录#四、五个根本技术\|1982 年那次"强制检查"之争]:类型系统拦住的是"拿错的值悄悄溜进来"。)
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §6.1.1 (必读):七个操作的完整清单 + Rule of Zero +
=default/=delete。 - \[Programming Principles and Practice Using C++ 3rd Edition-2024.pdf\|PPP] §17.5 (必读):七种 essential operations 的逐条决策逻辑(何时需要默认构造、何时需要析构)。§17.5.1 是
explicit的完整论证。 - \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] §17.2(查证):默认构造、特殊成员函数的规范细节。
- \[C++之父交谈录#四、五个根本技术\|交谈录:RAII](重读):资源的定义 + 为什么"释放该由语言保证,不是程序员纪律"。
第三讲:拷贝 vs 移动 vs RVO------ A Tour §6.2
这是本课验收的核心,我用一个带打印的 Tracer 类,让你亲眼看三种操作各被调用了多少次。
是什么
三种"让对象到另一个地方"的方式,成本天差地别:
| 操作 | 做什么 | 成本 |
|---|---|---|
| 深拷贝 | 复制资源:新分配内存 + 逐元素复制 | O(n),大对象很贵 |
| 移动 | 转移所有权:把对方指针"偷"过来,把对方置空 | O(1),常数 |
| RVO(copy elision) | 编译器直接把返回值构造在目标位置,什么都不发生 | 0,白嫖 |
为什么(移动是硬件革命的回应)
还记得\[C++之父交谈录#六、从 C++98 到现代 C++:迁移指南\|交谈录第六章]说的吗------2005 年单核性能停涨,C++11 的每个特性都是对这场硬件革命的回应。 move 语义是其中最大的一项:在 C++98 里,"从一个函数返回一个大 vector"意味着整体拷贝,慢得让人宁可写指针。C++11 的右值引用 && 让你能说"我要的不是复制,是搬走",于是"返回大对象"从奢侈变成常态。
把"传输"和"拷贝"分开 ------这是 C++11 对整个生态位的重新武装。没有 move,
vector永远只能当参数传引用;有了 move,vector可以当返回值"飞"出来,零拷贝。
代码逐行(跑一遍你就懂)
cpp
#include <iostream>
struct Tracer {
int id;
static int copies, moves;
Tracer(int i) : id{i} {}
Tracer(const Tracer& a) : id{a.id} { ++copies; std::cout << " [copy]\n"; }
Tracer(Tracer&& a) noexcept : id{a.id} { ++moves; std::cout << " [move]\n"; }
};
int Tracer::copies = 0, Tracer::moves = 0;
Tracer make_value() { Tracer t{42}; return t; } // 返回局部对象
int main() {
std::cout << "1) 左值拷贝初始化:\n";
Tracer a{1};
Tracer b = a; // copies +1, moves +0
std::cout << " copies=" << Tracer::copies << " moves=" << Tracer::moves << "\n\n";
std::cout << "2) 返回局部对象:\n";
int c0 = Tracer::copies, m0 = Tracer::moves;
Tracer c = make_value();
std::cout << " 新 copies=" << Tracer::copies - c0
<< " moves=" << Tracer::moves - m0
<< "(全 0 = RVO 生效,对象直接构造在 c 里)\n\n";
std::cout << "3) std::move 显式转右值:\n";
int c1 = Tracer::copies, m1 = Tracer::moves;
Tracer d = std::move(c);
std::cout << " 新 copies=" << Tracer::copies - c1
<< " moves=" << Tracer::moves - m1 << "\n";
}
输出(g++ 15,-std=c++20,我实际跑的结果):
ini
1) 左值拷贝初始化:
[copy]
copies=1 moves=0
2) 返回局部对象:
新 copies=0 moves=0(全 0 = RVO 生效,对象直接构造在 c 里)
3) std::move 显式转右值:
[move]
新 copies=0 moves=1
三个结论,背下来:
- 拷贝发生在"还有用的时候" ------
b = a,a 之后还要用,只能复制。 - 返回局部对象 → 0 次拷贝 0 次移动 ------编译器(C++17 起被标准强制)把对象直接构造在目标位置,这叫 RVO / copy elision。你什么都不用做,就能白拿这份优化。
std::move显式转右值 → 移动 1 次 ------注意,std::move本身什么都没"动",它只是把参数转成右值引用(相当于一种 cast),真正的"搬"发生在移动构造/移动赋值里。
常见坑
- ❌ 以为
std::move(x)"移动了 x"。没有。 它只产生一个右值引用,让编译器"允许从 x 移走"。真正的移动在构造函数里:elem{a.elem}, sz{a.sz}; a.elem = nullptr; - ❌ moved-from(被移动过)的对象之后继续用。规范只保证它"处于可析构、可重新赋值"的状态,不代表它还有原值。move 之后别碰它的内容。
- ❌ 把"深拷贝"当默认。默认拷贝是浅拷贝(成员级复制),对拥有资源的类这是 bug,不是选择。
- ❌ 认为
-O0下也能拿到 RVO。RVO 是标准保证的(C++17 起,prvalue 初始化时强制),但最好还是编译一次亲眼确认 ------这就是上面Tracer的用途。
教材指引(可跳转)
- \[A Tour of C 3rd Edition-2022.pdf\|A Tour] §6.2 (必读):§6.2.1 讲拷贝容器(为什么默认拷贝是灾难 + 拷贝构造/拷贝赋值完整代码),§6.2.2 讲移动容器(
Vector::Vector(Vector&&)完整代码)。 - \[Programming Principles and Practice Using C++ 3rd Edition-2024.pdf\|PPP] §17.4(必读,强烈推荐):§17.4 用图把"浅拷贝共享元素"画得明明白白;§17.4.3 Copy terminology(shallow vs deep、value vs reference semantics);§17.4.4 Moving。
- \[The C++ Programming Language 4th Edition-2013.pdf\|TCPL] §17.4(查证):移动语义、右值引用的规范表述。
- \[C++之父交谈录#六、从 C++98 到现代 C++:迁移指南\|交谈录:C++11 是硬件革命的回应](重读):move 语义在整个 C++11 图景里的位置。
第四讲:手写简化 Vector 四步走------ 本课核心练习
这是整个教案里最值得花一周的练习。我按 PPP 第 17 章的教学顺序,把它拆成四步。每步都先跑一个最小验证,确认前一步没问题再进下一步。
第 1 步:只做存储(你能看见灾难在敲门)
cpp
class Vector {
public:
Vector(int s) : elem{new double[s]}, sz{s} {
for (int i = 0; i != s; ++i) elem[i] = 0; // 初始化元素
}
~Vector() { delete[] elem; } // 析构:释放
double& operator[](int i) { return elem[i]; }
int size() const { return sz; }
private:
double* elem; // 指向元素数组
int sz; // 元素个数
};
这一步看起来能用,其实埋着雷 :它只有构造和析构,没有拷贝构造和拷贝赋值。所以默认的拷贝是"成员级浅拷贝":
cpp
void bad_copy(Vector v1) { // 按值传入 → 触发默认拷贝构造
Vector v2 = v1; // v2.elem == v1.elem,两个 Vector 共享同一块内存!
v1[0] = 2;
std::cout << v2[0]; // 输出 2,不是 0!v2 的"元素"被 v1 改了
} // 退出作用域:v2 析构 delete[] elem,v1 析构又 delete[] 同一块内存 → double free
我实际用 -fsanitize=address 跑过这个 bad_copy,ASan 明确报:
c
ERROR: AddressSanitizer: attempting double-free
这就是"只写析构不写拷贝"的下场。 记住这个画面,它是后面所有规则的理由------也是\[C++之父交谈录#三、背后的决策逻辑\|我为什么说"优化时先删掉聪明复杂的东西,bug 就藏在那里"]:这个 bug 不是藏在复杂里,是藏在"想当然"里。
第 2 步:加深拷贝(深拷贝构造 + 拷贝赋值)
cpp
Vector(const Vector& a) : elem{new double[a.sz]}, sz{a.sz} {
for (int i = 0; i != sz; ++i) elem[i] = a.elem[i]; // 复制元素,不是复制指针
}
Vector& operator=(const Vector& a) {
double* p = new double[a.sz]; // 先分配新空间
for (int i = 0; i != a.sz; ++i) p[i] = a.elem[i]; // 复制元素
delete[] elem; // 再释放旧空间(拷贝完成后才释放!)
elem = p;
sz = a.sz;
return *this;
}
两个细节你必须懂:
- 为什么拷贝赋值先
new后delete[]? 因为如果先释放旧元素、再复制时抛了异常,你的对象就"空"了。先复制到临时指针p,成功后再释放旧的------万一中途抛异常,旧值还在,对象不变。这叫强异常保证。 this是什么 :成员函数里指向"调用它的那个对象"的指针,return *this返回自身引用,让v1 = v2 = v3可以链式。
第 3 步:加移动(移动构造 + 移动赋值)
cpp
Vector(Vector&& a) noexcept : elem{a.elem}, sz{a.sz} { // 把 a 的指针"偷"过来
a.elem = nullptr; // 然后把 a 置空
a.sz = 0;
}
Vector& operator=(Vector&& a) noexcept {
if (this != &a) { // 防自赋值:v = std::move(v)
delete[] elem; // 释放自己旧的
elem = a.elem; // 偷对方的
sz = a.sz;
a.elem = nullptr; // 置空对方
a.sz = 0;
}
return *this;
}
移动为什么 O(1)? 拷贝要把 n 个元素逐个复制;移动只搬三个词(elem、sz 两个成员 + 置空对方),n 多大都无所谓。这就是**"搬走一个百万元素的 vector"和"搬走一个 int"同价**。
为什么移动构造要 noexcept? 因为 std::vector 扩容时,如果移动构造可能抛异常 ,标准库为了保证"失败时原容器不变",只能退回去用拷贝;而拷贝 O(n)。你标了 noexcept,它才敢用 O(1) 的移动。这是"移动快"真正兑现的时刻。
第 4 步:完整版(把三步拼起来)
cpp
#include <iostream>
#include <algorithm>
class Vector {
public:
Vector(int s) : elem{new double[s]}, sz{s} {
for (int i = 0; i != s; ++i) elem[i] = 0;
}
// ---- 第 4 步:移动(noexcept)----
Vector(Vector&& a) noexcept : elem{a.elem}, sz{a.sz} {
a.elem = nullptr; a.sz = 0;
}
Vector& operator=(Vector&& a) noexcept {
if (this != &a) {
delete[] elem;
elem = a.elem; sz = a.sz;
a.elem = nullptr; a.sz = 0;
}
return *this;
}
// ---- 第 2 步:深拷贝 ----
Vector(const Vector& a) : elem{new double[a.sz]}, sz{a.sz} {
for (int i = 0; i != sz; ++i) elem[i] = a.elem[i];
}
Vector& operator=(const Vector& a) {
double* p = new double[a.sz];
for (int i = 0; i != a.sz; ++i) p[i] = a.elem[i];
delete[] elem; elem = p; sz = a.sz;
return *this;
}
// ---- 第 1 步:构造/析构/访问 ----
~Vector() { delete[] elem; }
double& operator[](int i) { return elem[i]; }
const double& operator[](int i) const { return elem[i]; }
int size() const { return sz; }
private:
double* elem;
int sz;
};
Vector fill() { // 返回一个 Vector
Vector res(3);
for (int i = 0; i < res.size(); ++i) res[i] = i * 1.5;
return res; // 移动(或 RVO),不拷贝
}
int main() {
Vector v1(3);
v1[0] = 1; v1[1] = 2; v1[2] = 3;
Vector v2 = v1; // 深拷贝
v1[0] = 99;
std::cout << "v2[0]=" << v2[0] << "(深拷贝,应=1)\n";
Vector v3 = std::move(v1); // 移动:v1 被掏空
std::cout << "v3[0]=" << v3[0] << " v1.size()=" << v1.size() << "(应=0)\n";
Vector v4 = fill(); // 返回:移动或 RVO
std::cout << "v4[2]=" << v4[2] << " v4.size()=" << v4.size() << '\n';
}
输出(我已实际跑过):
scss
v2[0]=1(深拷贝,应=1)
v3[0]=99 v1.size()=0(应=0)
v4[2]=3 v4.size()=3
编译与验证命令:
bash
g++ -std=c++20 -Wall -Wextra vector.cpp -o vector
g++ -std=c++20 -Wall -Wextra -fsanitize=address vector.cpp -o vector_asan && ./vector_asan
# 无 "double-free"、无 "leak" 报告,才算过关
常见坑
- ❌ 先
delete[] elem再复制 → 自赋值v = v直接崩,且丢失强异常保证。 - ❌ 移动赋值忘了
if (this != &a)→v = std::move(v)会把刚释放的elem又赋给自己,悬空。 - ❌ 移动构造漏标
noexcept→std::vector扩容退化为拷贝,性能掉回 O(n)。 - ❌ 忘了加
const double& operator[](int) const→ 所有const Vector&参数都访问不了元素,编译器报错。
附讲:抽象类型与类层次(A Tour §5.3--5.5,速览)
这一课的主线是具体类型。但 A Tour 第 5 章还有另一半------抽象类型。你只需要先建立一个"存在感",细节留到\[阶段五-STL全貌\|阶段五]和\[阶段七-沉淀与实战\|阶段七]再深入。
一句话 :抽象类型把接口和表示彻底分离 ------用纯虚函数 =0 定义接口,派生类提供实现,用户通过基类引用/指针访问,完全不知道底层是什么。
cpp
class Container { // 抽象类:纯接口,不能有对象
public:
virtual double& operator[](int) = 0; // 纯虚函数
virtual int size() const = 0;
virtual ~Container() {} // 虚析构:通过基类指针删除派生对象
};
三个必须现在记住的点:
- 抽象类没有对象 :
Container c;编译错误,它只能做接口(Container* p = new Vector_container(10);)。 - 有虚函数就该有虚析构 :否则通过基类指针
delete派生对象时,派生类析构不会被调用,资源泄漏。 - 多态对象只能通过指针/引用访问:因为派生类大小未知,不能按值存进基类变量(对象切片)。
完整内容我在\[阶段五-STL全貌\|阶段五]讲义里会结合
vector<Shape*>展开。这里你只需知道:具体类型管"值",抽象类型管"接口"------两者是类型安全的两翼,不是二选一。 这正是\[C++之父交谈录#四、五个根本技术\|我说的"把泛型和 OO 对立起来,是我最反对的教条"]的另一面。
练习与参考答案
练习 1:把 Point 升级成具体类型(对应教案"你动手做")
从阶段二的裸数据袋子,升级成带构造、成员函数、operator<< 的完整具体类型。
cpp
#include <iostream>
struct Point {
int x, y;
Point(int xx = 0, int yy = 0) : x{xx}, y{yy} {} // 构造 + 默认参数
};
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << '(' << p.x << ", " << p.y << ')';
}
int main() {
Point p{3, 4};
Point q; // 默认构造 → {0,0}
std::cout << p << ' ' << q << '\n'; // (3, 4) (0, 0)
}
参考要点 :成员初始化列表 : x{xx}, y{yy};非成员 operator<<(对称运算符惯例上放外面);Point 不需要析构/拷贝------没有资源,Rule of Zero 适用。
练习 2:手写 Vector 四步走(本课核心,必做)
按第四讲四步实现,完成编译 + ASan 验证。验收红线:
- 代码里没有裸
new/delete泄漏(ASan 无报告); - 能对着白板画出"拷贝、移动各发生了什么";
- 能说出第 4 步
noexcept的作用。
练习 3(选做):用打印追踪构造/析构
这是 PPP §17.5.2 的"手指练习"。给一个类加打印,观察对象什么时候构造、什么时候析构。如果你能解释下面的输出,你对对象生命周期就通了:
cpp
struct X {
int val;
void out(const std::string& s, int nv) {
std::cout << this << "->" << s << ": " << val << " (" << nv << ")\n";
}
X() : val{0} { out("X()", 0); } // 默认构造
X(int x) : val{x} { out("X(int)", x); } // 普通构造
X(const X& x) : val{x.val} { out("X(copy)", x.val); } // 拷贝构造
X(X&& x) : val{x.val} { out("X(move)", x.val); x.val = 0; } // 移动构造
X& operator=(const X& x) { out("X copy assign", x.val); val = x.val; return *this; }
X& operator=(X&& x) { out("X move assign", x.val); val = x.val; x.val = 0; return *this; }
~X() { out("~X()", 0); } // 析构
};
X make(int i) { X a(i); return a; }
int main() {
X loc{4}; // X(int)
X loc2{loc}; // X(copy)
X loc3 = make(7); // X(int) + RVO(loc3 直接构造在 make 里)
loc2 = X{5}; // X(int) + 移动赋值(临时对象被 move-assign 进 loc2)
// main 结束:3 个局部对象析构,顺序和构造相反
}
你能说出的关键点 :析构顺序与构造相反(后构造的先析构);make(7) 里 X a(i) 构造后直接 RVO 到 loc3,没有拷贝没有移动;临时对象 X{5} 赋值给 loc2 走的是移动赋值 ,赋值后临时对象立刻析构。(注意:这个类因为声明了移动操作,编译器会自动删除拷贝赋值------这正是第二讲"写了其中一个就要检查其他几个"的现实例证。若想保留拷贝赋值,需显式声明。)
验收自测(对应教案"怎么算过关")
读完这一课,不看资料,你应该能答出:
- 具体类型和抽象类型的关键差别是什么? ------ 具体类型的表示在定义里,可以放栈上、直接访问、拷贝移动;抽象类型接口与实现分离,只能通过指针/引用访问。
- 一个类什么时候需要析构? ------ 它获取了资源(内存/文件/锁/线程)。有指针或引用成员的类尤其要警惕。
- 深拷贝、移动语义、RVO 三者的区别? ------ 深拷贝复制资源 O(n);移动转移所有权 O(1);RVO 是编译器把返回值直接构造在目标位置,0 成本。
- 为什么移动构造要标
noexcept? ------std::vector扩容时若移动可能抛异常,为了保证强异常保证只能用拷贝 O(n);noexcept让它敢用移动 O(1)。 std::move(x)做了什么? ------ 什么都没搬。它把 x 转成右值引用,让编译器允许从 x 移走;真正的移动发生在移动构造/赋值里。- 手写
Vector在 ASan 下无报告吗? ------ 这是硬性红线,跑过才算过关。
完成这一课,你就从"会用 RAII 组件"跨到了"能写 RAII 组件"。下一课(\[阶段四-模板与泛型\|阶段四])我们会把这个
Vector变成模板------让Vector<double>变成Vector<T>,然后加上 concepts 约束。你已经站在了理解 STL 的门口。