阶段三讲义:类型与对象(Classes & Essential Operations)

对应教材\[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. 本课地图

这一课解决三个问题,也是三个递进的层次:

  1. 类型为什么能"自带行为"? ------ 具体类型(concrete type):complexVector 为什么"就像内建类型"。答案:表示(representation)就在定义里
  2. 对象的一生由谁控制? ------ 六大关键操作:构造/析构/拷贝/移动。答案:语言保证,不是你记得
  3. "复制"到底发生什么? ------ 深拷贝 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 习惯)

  • ❌ 以为"类"就等于"面向对象的类层次"。不是。 complexVector 是具体类型------它不带虚函数、不进层次,就是一个"自带行为的类型"。能用具体类型解决的,别急着上继承。(这也是\[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

三个结论,背下来:

  1. 拷贝发生在"还有用的时候" ------b = a,a 之后还要用,只能复制。
  2. 返回局部对象 → 0 次拷贝 0 次移动 ------编译器(C++17 起被标准强制)把对象直接构造在目标位置,这叫 RVO / copy elision。你什么都不用做,就能白拿这份优化。
  3. 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;
}

两个细节你必须懂:

  1. 为什么拷贝赋值先 newdelete[] 因为如果先释放旧元素、再复制时抛了异常,你的对象就"空"了。先复制到临时指针 p,成功后再释放旧的------万一中途抛异常,旧值还在,对象不变。这叫强异常保证
  2. 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 个元素逐个复制;移动只搬三个词(elemsz 两个成员 + 置空对方),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 又赋给自己,悬空。
  • ❌ 移动构造漏标 noexceptstd::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() {}                // 虚析构:通过基类指针删除派生对象
};

三个必须现在记住的点

  1. 抽象类没有对象Container c; 编译错误,它只能做接口(Container* p = new Vector_container(10);)。
  2. 有虚函数就该有虚析构 :否则通过基类指针 delete 派生对象时,派生类析构不会被调用,资源泄漏。
  3. 多态对象只能通过指针/引用访问:因为派生类大小未知,不能按值存进基类变量(对象切片)。

完整内容我在\[阶段五-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 走的是移动赋值 ,赋值后临时对象立刻析构。(注意:这个类因为声明了移动操作,编译器会自动删除拷贝赋值------这正是第二讲"写了其中一个就要检查其他几个"的现实例证。若想保留拷贝赋值,需显式声明。)


验收自测(对应教案"怎么算过关")

读完这一课,不看资料,你应该能答出:

  1. 具体类型和抽象类型的关键差别是什么? ------ 具体类型的表示在定义里,可以放栈上、直接访问、拷贝移动;抽象类型接口与实现分离,只能通过指针/引用访问。
  2. 一个类什么时候需要析构? ------ 它获取了资源(内存/文件/锁/线程)。有指针或引用成员的类尤其要警惕。
  3. 深拷贝、移动语义、RVO 三者的区别? ------ 深拷贝复制资源 O(n);移动转移所有权 O(1);RVO 是编译器把返回值直接构造在目标位置,0 成本。
  4. 为什么移动构造要标 noexcept ------ std::vector 扩容时若移动可能抛异常,为了保证强异常保证只能用拷贝 O(n);noexcept 让它敢用移动 O(1)。
  5. std::move(x) 做了什么? ------ 什么都没搬。它把 x 转成右值引用,让编译器允许从 x 移走;真正的移动发生在移动构造/赋值里。
  6. 手写 Vector 在 ASan 下无报告吗? ------ 这是硬性红线,跑过才算过关。

完成这一课,你就从"会用 RAII 组件"跨到了"能写 RAII 组件"。下一课(\[阶段四-模板与泛型\|阶段四])我们会把这个 Vector 变成模板------让 Vector<double> 变成 Vector<T>,然后加上 concepts 约束。你已经站在了理解 STL 的门口。

相关推荐
飞鸟真人1 小时前
C++ lambda 捕获完整梳理
c++·闭包·lamda
東隅已逝,桑榆非晚1 小时前
c++内存管理
c++·笔记
是个西兰花1 小时前
C++11:线程库与线程安全问题
开发语言·c++
少司府2 小时前
C++进阶:智能指针
开发语言·数据结构·c++·b树·算法·c·智能指针
水饺编程3 小时前
编程数学:三角函数基础01,直角三角函数
c语言·c++·windows·visual studio
初願致夕霞3 小时前
C/C++传统程序内存分布(代码实测)
java·c语言·c++
牛油果子哥q3 小时前
C++对接LLM完整工程:异步HTTP请求、JSON解析、超时容错、重连兜底
c++·http·json
会周易的程序员3 小时前
aiDgeController软PLC控制通讯协议文档
c++·物联网·网关·iot·ipc·进程间通讯
(Charon)4 小时前
【C++】网络缓冲区设计(四):epoll + Reactor中Buffer的完整读写流程
开发语言·c++